Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: test

on:
push:
branches: [main]
pull_request:

jobs:
core:
# The core has no dependencies; these tests need no backend SDK or key.
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install ".[dev]"
- run: python -m pytest -v tests/test_core.py

gemini:
# The ADK runner tests stub the model, so no GOOGLE_API_KEY is needed.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ".[gemini,dev]"
- run: python -m pytest -v tests/test_adk.py

format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install black
- run: black --check --diff .
9 changes: 6 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
dist
build
behalf.egg-info/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.pytest_cache/
Binary file removed behalf/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file removed behalf/__pycache__/core.cpython-312.pyc
Binary file not shown.
17 changes: 12 additions & 5 deletions behalf/runner/adk.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
_PY_TYPE = {str: str, int: int, float: float, bool: bool, list: list, dict: dict}


async def _create_session(service, **kwargs):
"""Create an ADK session; create_session is a coroutine in ADK >= 1.0 and sync before."""
session = service.create_session(**kwargs)
if inspect.isawaitable(session):
session = await session
return session


def _to_adk_tool(ts: ToolSpec, confirm_fn: ConfirmFn) -> FunctionTool:
"""Wrap one ToolSpec as an ADK FunctionTool.

Expand Down Expand Up @@ -100,9 +108,8 @@ async def _run_once(
name="behalf", model=self.model, instruction=instruction, tools=adk_tools
)
session_service = InMemorySessionService()
# TODO: confirm create_session is sync vs async in the installed ADK
session = session_service.create_session(
app_name=self.app_name, user_id="local", session_id="s1"
session = await _create_session(
session_service, app_name=self.app_name, user_id="local", session_id="s1"
)
runner = Runner(
agent=agent, app_name=self.app_name, session_service=session_service
Expand Down Expand Up @@ -155,8 +162,8 @@ async def finalize_setup(manifest: dict) -> str:
tools=[finalize],
)
session_service = InMemorySessionService()
session = session_service.create_session(
app_name=self.app_name, user_id="local", session_id="setup"
session = await _create_session(
session_service, app_name=self.app_name, user_id="local", session_id="setup"
)
runner = Runner(
agent=agent, app_name=self.app_name, session_service=session_service
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "behalf"
version = "0.1.0"
version = "0.1.1"
description = "A small framework for scoped agents: a brief, a typed toolset (read/action), a pluggable model backend."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
264 changes: 264 additions & 0 deletions tests/test_adk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""Tests for the Gemini (Google ADK) runner that need no API key.

They are skipped when google-adk is not installed (pip install behalf[gemini]).
"""

import asyncio
import json
import os
import sys

import pytest

sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

pytest.importorskip("google.adk")

from google.adk.sessions import InMemorySessionService # noqa: E402

import behalf.runner.adk as adk # noqa: E402
from behalf import ToolSpec # noqa: E402
from behalf.runner.adk import ADKRunner, _create_session, _to_adk_tool # noqa: E402


def _text(obj) -> dict:
return {"content": [{"type": "text", "text": json.dumps(obj)}]}


def _schema(decl) -> dict:
"""Parameter schema of a declaration, for ADK versions with and without JSON schema."""
if getattr(decl, "parameters_json_schema", None):
return decl.parameters_json_schema
params = decl.parameters
if params is None:
return {}
return {
"properties": {
k: {"type": v.type.name.lower()} for k, v in params.properties.items()
},
"required": list(params.required or []),
}


# _create_session: the bug was calling create_session synchronously; in ADK >= 1.0
# it is a coroutine, so the "session" had no .id and every run failed.


def test_create_session_with_installed_adk():
session = asyncio.run(
_create_session(
InMemorySessionService(), app_name="t", user_id="u", session_id="s1"
)
)
assert session.id == "s1"


def test_create_session_accepts_sync_and_async_services():
class Sync:
def create_session(self, **kw):
return {"id": kw["session_id"]}

class Async:
async def create_session(self, **kw):
return {"id": kw["session_id"]}

for service in (Sync(), Async()):
session = asyncio.run(_create_session(service, session_id="x"))
assert session == {"id": "x"}, type(service).__name__


# _to_adk_tool: ADK derives the schema from a synthesized signature; every
# argument and type in the ToolSpec must survive, and the confirm gate must hold.


def test_tool_declaration_carries_every_argument_and_type():
spec = ToolSpec(
"run",
"run something",
{
"name": str,
"n": int,
"ratio": float,
"flag": bool,
"items": list,
"opts": dict,
},
lambda args: None,
)
decl = _to_adk_tool(spec, lambda n, a: True)._get_declaration()
assert decl.name == "run" and decl.description == "run something"
schema = _schema(decl)
types = {k: v["type"] for k, v in schema["properties"].items()}
assert types == {
"name": "string",
"n": "integer",
"ratio": "number",
"flag": "boolean",
"items": "array",
"opts": "object",
}
assert set(schema["required"]) == set(spec.input_schema)


def test_tool_with_no_arguments():
spec = ToolSpec("ping", "no args", {}, lambda args: None)
decl = _to_adk_tool(spec, lambda n, a: True)._get_declaration()
assert not _schema(decl).get("properties")


def test_tool_invoke_unwraps_json_and_text_results():
calls = []

async def handler(args):
calls.append(args)
return _text({"ok": True, "got": args})

tool = _to_adk_tool(
ToolSpec("look", "d", {"path": str}, handler), lambda n, a: True
)
assert asyncio.run(tool.func(path="/x")) == {"ok": True, "got": {"path": "/x"}}
assert calls == [{"path": "/x"}]

async def plain(args):
return {"content": [{"type": "text", "text": "not json"}]}

tool = _to_adk_tool(ToolSpec("say", "d", {}, plain), lambda n, a: True)
assert asyncio.run(tool.func()) == "not json"

async def raw(args):
return {"value": 1}

tool = _to_adk_tool(ToolSpec("raw", "d", {}, raw), lambda n, a: True)
assert asyncio.run(tool.func()) == {"value": 1}


def test_action_tool_gates_on_confirmation():
acted = []

async def act(args):
acted.append(args)
return _text("done")

seen = []

def deny(name, args):
seen.append((name, args))
return False

spec = ToolSpec("act", "d", {"x": int}, act, kind="action", confirm=True)
assert asyncio.run(_to_adk_tool(spec, deny).func(x=1)) == "cancelled by user"
assert acted == [] and seen == [("act", {"x": 1})]
assert asyncio.run(_to_adk_tool(spec, lambda n, a: True).func(x=2)) == "done"
assert acted == [{"x": 2}]

# read tools and unconfirmed actions never consult confirm_fn
read = ToolSpec("look", "d", {}, act)
unconfirmed = ToolSpec("act2", "d", {}, act, kind="action", confirm=False)
for spec in (read, unconfirmed):
asyncio.run(_to_adk_tool(spec, deny).func())
assert len(seen) == 1


# run_agent: drive ADKRunner with the real session service and a stub in place
# of ADK's Runner, so the whole path runs without a model.


class StubRunner:
"""Stands in for google.adk.runners.Runner: calls every tool once, then
emits one final text event. Records what it was given."""

created = []

def __init__(self, agent, app_name, session_service):
self.agent = agent
self.app_name = app_name
self.session_service = session_service
StubRunner.created.append(self)
self.calls = []

async def run_async(self, user_id, session_id, new_message):
self.calls.append((user_id, session_id, new_message.parts[0].text))
# The session must exist in the service: proves create_session was awaited.
session = await self.session_service.get_session(
app_name=self.app_name, user_id=user_id, session_id=session_id
)
assert session is not None and session.id == session_id
results = {}
for tool in self.agent.tools:
results[tool.name] = await tool.func(
**{"q": "hi"} if tool.name == "look" else {}
)

class Part:
text = json.dumps(results)

class Content:
parts = [Part()]

class Event:
content = Content()

yield Event()


def test_run_agent_end_to_end_with_stubbed_adk_runner(monkeypatch):
monkeypatch.setattr(adk, "Runner", StubRunner)
StubRunner.created.clear()
log = []

async def look(args):
log.append(("look", args))
return _text({"saw": args["q"]})

async def act(args):
log.append(("act", args))
return _text("acted")

tools = [
ToolSpec("look", "look", {"q": str}, look),
ToolSpec("act", "act", {}, act, kind="action", confirm=True),
]
runner = ADKRunner(model="gemini-test", app_name="app", verbose=False)
result = asyncio.run(
runner.run_agent("system", "user prompt", tools, lambda n, a: True)
)
assert result is None # results flow through tool side effects, like SDKRunner
assert log == [("look", {"q": "hi"}), ("act", {})]

(stub,) = StubRunner.created
assert stub.app_name == "app" and stub.agent.model == "gemini-test"
assert stub.agent.instruction == "system"
assert [t.name for t in stub.agent.tools] == ["look", "act"]
assert stub.calls == [("local", "s1", "user prompt")]


def test_run_agent_denied_action_is_cancelled(monkeypatch):
monkeypatch.setattr(adk, "Runner", StubRunner)
acted = []

async def act(args):
acted.append(args)
return _text("acted")

async def look(args):
return _text("ok")

tools = [
ToolSpec("look", "look", {"q": str}, look),
ToolSpec("act", "act", {}, act, kind="action", confirm=True),
]
runner = ADKRunner(verbose=False)
asyncio.run(runner.run_agent("s", "u", tools, lambda n, a: n != "act"))
assert acted == []


def test_make_runner_gemini():
from behalf import make_runner

runner = make_runner("gemini", "gemini-2.5-pro")
assert isinstance(runner, ADKRunner) and runner.model == "gemini-2.5-pro"
assert make_runner("gemini").model == "gemini-2.5-flash"


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
Loading