From fa79904cd67684413b752c613732485b5571a000 Mon Sep 17 00:00:00 2001 From: vsoch Date: Tue, 6 Oct 2026 22:40:22 -0700 Subject: [PATCH 1/2] bug: fix adk and remove pycache Signed-off-by: vsoch --- behalf/__pycache__/__init__.cpython-312.pyc | Bin 2236 -> 0 bytes behalf/__pycache__/core.cpython-312.pyc | Bin 7141 -> 0 bytes behalf/runner/adk.py | 17 ++++++++++++----- 3 files changed, 12 insertions(+), 5 deletions(-) delete mode 100644 behalf/__pycache__/__init__.cpython-312.pyc delete mode 100644 behalf/__pycache__/core.cpython-312.pyc diff --git a/behalf/__pycache__/__init__.cpython-312.pyc b/behalf/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5be4ac4d98fd797a506965e1f75435d081b4024a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2236 zcmbtVU2GIp6ux(6c6VlX+r?S}ipZsa?U;5q0Wq-{2`xV%7}ORN7G$07y}LW?{A}*b zvfa8h#F!Y~j4^?Pn4s|if-lDSV0>zQ^kqVMFcW;y7kz7i1XEr-cV@SgNYuoe%>9}9 zzVDuM?>T3F>g!7*Xy1(+=YLNk^qW}p8myys?{ye1BZ`WMVk%kKmWq-s7iC*1Dt4ln zu#?54ohqhmwWwlLMO6M07d5KV1WVH-Jjz@Op^8FNA0>*JwxVmz9T=e*Rx_*$`Ss#w z#2}t+SQe>p!)7g)*Ga|Y#4EcEMu|~nj_>8u=_BR^Mhqu@0h}3bvdSn-D&{QUJmwR( zLVVY?JTk(WK}QLw7-ipdow1hb*T%}OQ!%-1h&TFZ+HgRfG^}Q|YRp)S*e+!jnK8#IG4X=~^wQocT@ecp$-<*R=e6`b3S9vdbAUJwjOnRG3q4k5xEh z`-H)Kiy#EFh^O%0INz}r$d!DosthAUF9<9az%6r(TS_( za7T!%qSK%YGqzD@CE$Q1$HP?D_An70Qdt5>uL-sgy!R=Xdl_{gOzI;bvspt_qOyf- zJTGC?!CaM4N9y34!YydiNUU$h3s?a&6)~TQn5qTgHLpZW4UkF)f1Q4et31Zdtm9@a z2+8@x8aMGbZsHNw7p_N*_IJ=#iRM0+d;5HdcOYM!RMd7NJ~%J0!z&&6Dx!(?c$%D- zKag9vg=Xc`sP)f$?SJz7{++LHm|K`)o@sQ18fK%X-Oc4QG3{M4vWoTNVKR0~>~UaikNs=PnP^k8W-aX#jFSAp3jN z*myoDt|G}ZFht(PdYDD%=&L76gNPnNR`(QYP^JnO42OB1Q8>uFy6-j$lTdRD97>>H zh)ZjsRDyEomrD6Yn~T3w)NO`|_Y~BPi)cBE)Z8Z{w^Gk8OS0zTU$SE>NwibH)V{KD zDYq?9U%6r4ti$kgs(%f}y08}u0BhdP?F!WW-yHq+7z_fo>sD%$P&*5-y`Yxgb7}tC z-rG4nP>+1y_d^bbJL$n-)A04wwfBSFZw4FRT1rm^%G96tx1kMN?jj6^+!=T-*j5Y7 z^Fgg1)U9Bvy)@tiS?A%hEIvQHpY1|f3_TZ=-V4qdfiV+oEiVnwAWI`CTL(QGLr(^8 zzZ0B16`VR9Y%MMgoC&gLBIsGX>K%ro`8at%M_(l;G_)WkCv>!+YZFO%VRvo<%ZoSx z^F>XYctKtq$W3gQ7q=(GT-PSy{qEc(mVd+vnCtkso()r_5_QX^QmBYu*?;*}00UAR z7_#T{=(=v8dtepfTcBH2Uxj~0(_*jjAy|sq?~OwTr-Jc{l)%b^eicg(5Ogb|yE`an fq>HJQ>}EWTuROhs#CUD``b%FF??-dK9q4}n##wR_ diff --git a/behalf/__pycache__/core.cpython-312.pyc b/behalf/__pycache__/core.cpython-312.pyc deleted file mode 100644 index 4a00d9ae90cfe4d5c9d63ef568eed34fd16a0cfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7141 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 h(9UjLWE0A^scuqaQ_8lNWS4G6i48v-pi= 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. @@ -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 @@ -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 From 431e31cb4fad856e55e135ccec17583a2e66ec1a Mon Sep 17 00:00:00 2001 From: vsoch Date: Wed, 7 Oct 2026 18:56:06 -0700 Subject: [PATCH 2/2] ci: add testing and automated ci Signed-off-by: vsoch --- .github/workflows/test.yaml | 42 ++++++ .gitignore | 9 +- pyproject.toml | 2 +- tests/test_adk.py | 264 ++++++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 tests/test_adk.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..dac9573 --- /dev/null +++ b/.github/workflows/test.yaml @@ -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 . diff --git a/.gitignore b/.gitignore index 29b5601..d0900c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ -dist -build -behalf.egg-info/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/pyproject.toml b/pyproject.toml index d5a48b8..cf00083 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_adk.py b/tests/test_adk.py new file mode 100644 index 0000000..d7dd68e --- /dev/null +++ b/tests/test_adk.py @@ -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"]))