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
24 changes: 23 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- run: python -m pytest -v tests/test_core.py

gemini:
# The ADK runner tests stub the model, so no GOOGLE_API_KEY is needed.
# The ADK runner tests use a scripted model, so no GOOGLE_API_KEY is needed.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -31,6 +31,28 @@ jobs:
- run: pip install ".[gemini,dev]"
- run: python -m pytest -v tests/test_adk.py

claude:
# The SDK runner tests stub the client, so no ANTHROPIC_API_KEY or CLI is needed.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ".[claude,dev]"
- run: python -m pytest -v tests/test_sdk.py

aws:
# The Strands runner tests use a scripted model, so no AWS credentials are needed.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ".[aws,dev]"
- run: python -m pytest -v tests/test_strands.py

format:
runs-on: ubuntu-latest
steps:
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ from behalf import run_task, make_runner
outcome = await run_task(MyTask(), make_runner("aws"))
```

### Manifests

`manifest_schema()` is sent to the model during setup (see `render_setup_prompt`),
and `finalize_setup` only accepts a manifest that has every schema field. `run_task`
raises `ValueError` if a manifest, conversed or passed in, is missing a field.

### Results

Runners return `None` from `run_agent`; results come from tool side effects.
`record_tool(sink, fields)` returns a tool that appends each call's arguments to `sink`:

```python
from behalf import record_tool

class MyTask(Task):
def build_tools(self, manifest):
self.results = []
return [record_tool(self.results, {"job": str, "site": str})]
```


## Install

Expand Down
8 changes: 8 additions & 0 deletions behalf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
RunOutcome,
Task,
ToolSpec,
accept_manifest,
default_confirm,
missing_manifest_fields,
record_tool,
render_setup_prompt,
run_task,
)

Expand All @@ -21,7 +25,11 @@
"RunOutcome",
"Task",
"ToolSpec",
"accept_manifest",
"default_confirm",
"missing_manifest_fields",
"record_tool",
"render_setup_prompt",
"run_task",
"make_runner",
]
Expand Down
60 changes: 57 additions & 3 deletions behalf/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class AgentRunner(Protocol):
fake. This seam is what lets the whole flow be tested without a key."""

async def converse(self, task: "Task") -> dict:
"""Run the seeded setup conversation; return the manifest."""
"""Run the setup conversation from render_setup_prompt(task); return the manifest."""
...

async def run_agent(
Expand All @@ -100,6 +100,56 @@ async def run_agent(
...


def render_setup_prompt(task: "Task") -> str:
"""Return the task's setup prompt with its manifest schema and finalize instruction appended."""
fields = "\n".join(
f"- {name}: {getattr(typ, '__name__', str(typ))}"
for name, typ in task.manifest_schema().items()
)
return (
f"{task.setup_system_prompt()}\n\n"
f"The manifest is a JSON object with these fields:\n{fields}\n\n"
"When every field is known, call finalize_setup with the manifest."
)


def missing_manifest_fields(task: "Task", manifest: Any) -> list[str]:
"""Return schema fields absent from the manifest (all fields if it is not a dict)."""
schema = task.manifest_schema()
if not isinstance(manifest, dict):
return list(schema)
return [name for name in schema if name not in manifest]


def accept_manifest(task: "Task", manifest: Any, captured: dict) -> str:
"""Store the manifest in captured if complete; return the message for the model."""
missing = missing_manifest_fields(task, manifest)
if missing:
return (
f"manifest is missing fields: {', '.join(missing)}. "
"Ask the user for them, then call finalize_setup again."
)
captured["manifest"] = manifest
return "setup finalized"


def record_tool(
sink: list,
fields: dict,
name: str = "record_result",
description: str = "Record one result.",
) -> ToolSpec:
"""Return a tool that appends each call's arguments to sink."""

async def _record(args: dict) -> dict:
sink.append(dict(args))
return {
"content": [{"type": "text", "text": json.dumps({"recorded": len(sink)})}]
}

return ToolSpec(name, description, fields, _record, kind="action", confirm=False)


@dataclass
class RunOutcome:
manifest: dict
Expand All @@ -122,10 +172,14 @@ async def run_task(
approve_fn: Optional[Callable[[dict], bool]] = None,
) -> RunOutcome:
"""The one entrypoint every task flows through. manifest given -> skip the
conversation (reproducible/batch); None -> converse to produce it. Then
optional setup approval, then execute; action tools gate on confirm_fn."""
conversation (reproducible/batch); None -> converse to produce it. The
manifest must contain every manifest_schema() field. Then optional setup
approval, then execute; action tools gate on confirm_fn."""
if manifest is None:
manifest = await runner.converse(task)
missing = missing_manifest_fields(task, manifest)
if missing:
raise ValueError(f"{task.name}: manifest is missing fields: {missing}")

approved = True
if task.requires_setup_approval:
Expand Down
47 changes: 20 additions & 27 deletions behalf/runner/adk.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
"""A Gemini backend via Google's Agent Development Kit (ADK), as an alternative
AgentRunner to SDKRunner.

STATUS: scaffolded, NOT yet run. Google ADK isn't a base dependency and isn't
installed in the dev image, so this module is untested end to end. The shape
follows ADK's documented API (Agent + FunctionTool + Runner + session service),
but the spots marked TODO are where the ADK specifics must be confirmed against
the installed version before relying on it. Install with `pip install google-adk`.

It exists because the framework already abstracts the model behind AgentRunner,
so adding Gemini is a contained job: convert our ToolSpecs to ADK function tools
(carrying the same read/action confirmation gate) and drive ADK's runner. None
of ProfileTask / tools.py / the container code changes.
Converts ToolSpecs to ADK function tools (carrying the same read/action
confirmation gate) and drives ADK's Runner. Tested end to end with a scripted
BaseLlm in place of Gemini (tests/test_adk.py). Install with `pip install
behalf[gemini]`.
"""

from __future__ import annotations
Expand All @@ -29,7 +23,14 @@
from google.adk.tools import FunctionTool
from google.genai import types

from ..core import AgentRunner, ConfirmFn, Task, ToolSpec
from ..core import (
AgentRunner,
ConfirmFn,
Task,
ToolSpec,
accept_manifest,
render_setup_prompt,
)

_PY_TYPE = {str: str, int: int, float: float, bool: bool, list: list, dict: dict}

Expand Down Expand Up @@ -77,23 +78,19 @@ async def _invoke(**kwargs) -> Any:
]
_invoke.__name__ = ts.name
_invoke.__doc__ = ts.description
_invoke.__signature__ = inspect.Signature(
params
) # TODO: confirm ADK reads __signature__ (vs real params)
_invoke.__signature__ = inspect.Signature(params)
_invoke.__annotations__ = {
n: _PY_TYPE.get(t, str) for n, t in ts.input_schema.items()
}
return FunctionTool(
_invoke
) # TODO: confirm FunctionTool(func) is the current constructor
return FunctionTool(_invoke)


class ADKRunner(AgentRunner):
"""AgentRunner backed by Google ADK / Gemini. Same interface as SDKRunner."""

def __init__(
self,
model: str = "gemini-2.5-flash",
model: Any = "gemini-2.5-flash",
app_name: str = "behalf",
verbose: bool = True,
):
Expand All @@ -117,7 +114,6 @@ async def _run_once(

msg = types.Content(role="user", parts=[types.Part(text=user_prompt)])
texts: list[str] = []
# TODO: confirm run_async signature + how final text is surfaced on events
async for event in runner.run_async(
user_id="local", session_id=session.id, new_message=msg
):
Expand All @@ -139,26 +135,23 @@ async def run_agent(
) -> Any:
adk_tools = [_to_adk_tool(t, confirm_fn) for t in tools]
await self._run_once(system_prompt, user_prompt, adk_tools)
# results are collected via the tools' side effects (e.g. record_artifact
# writing into ProfileTask's sink), same as SDKRunner.
# Results are collected through tool side effects (e.g. a record_tool
# sink), same as SDKRunner.
return None

async def converse(self, task: Task) -> dict:
"""Interactive setup: give the agent a finalize tool and loop until it's
called. Mirrors SDKRunner.converse. TODO: verify ADK multi-turn session
reuse (re-running the runner with the same session_id to continue)."""
"""Interactive setup over one ADK session; loops until finalize_setup accepts a manifest."""
captured: dict = {}

async def finalize_setup(manifest: dict) -> str:
"""Finalize the setup manifest (matching the task's schema) and end setup."""
captured["manifest"] = manifest
return "setup finalized"
return accept_manifest(task, manifest, captured)

finalize = FunctionTool(finalize_setup)
agent = Agent(
name="setup",
model=self.model,
instruction=task.setup_system_prompt(),
instruction=render_setup_prompt(task),
tools=[finalize],
)
session_service = InMemorySessionService()
Expand Down
40 changes: 26 additions & 14 deletions behalf/runner/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@
tool,
)

from ..core import AgentRunner, ConfirmFn, Task, ToolSpec
from ..core import (
AgentRunner,
ConfirmFn,
Task,
ToolSpec,
accept_manifest,
render_setup_prompt,
)


def _say(text: str) -> dict:
Expand All @@ -43,6 +50,20 @@ async def _sdk_tool(args):
return _sdk_tool


def _finalize_tool(task: Task, captured: dict):
"""SDK tool that stores a complete manifest in captured, or reports missing fields."""

@tool(
"finalize_setup",
"Finalize the setup manifest (matching the task's schema) and end setup.",
{"manifest": dict},
)
async def finalize(args):
return _say(accept_manifest(task, args.get("manifest", args), captured))

return finalize


class SDKRunner(AgentRunner):
def __init__(self, model: str | None = None, verbose: bool = True):
self.model = model
Expand Down Expand Up @@ -83,25 +104,16 @@ async def run_agent(

async for message in query(prompt=user_prompt, options=options):
self._echo(message)
# Results are collected through the tools' side effects (e.g.
# record_artifact writing into the task's sink), not the return value.
# Results are collected through tool side effects (e.g. a record_tool
# sink), not the return value.
return None

async def converse(self, task: Task) -> dict:
captured: dict = {}

@tool(
"finalize_setup",
"Finalize the setup manifest (matching the task's schema) and end setup.",
{"manifest": dict},
)
async def finalize(args):
captured["manifest"] = args.get("manifest", args)
return _say("setup finalized")

finalize = _finalize_tool(task, captured)
server = create_sdk_mcp_server(name="setup", version="0.1.0", tools=[finalize])
options = self._options(
"setup", server, ["mcp__setup__finalize_setup"], task.setup_system_prompt()
"setup", server, ["mcp__setup__finalize_setup"], render_setup_prompt(task)
)

# The model asks the user for what it needs and drives the structure; we
Expand Down
Loading
Loading