behalf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
behalf/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """behalf — a small framework for scoped agents.
2
+
3
+ Give an agent a brief, a fixed set of tools (read, or action-with-confirmation),
4
+ and a pluggable model backend; it works within that behalf and returns a result.
5
+ The core is dependency-free; a backend extra (claude/gemini/aws) adds a runner.
6
+ """
7
+
8
+ from .core import (
9
+ AgentRunner,
10
+ ConfirmFn,
11
+ RunOutcome,
12
+ Task,
13
+ ToolSpec,
14
+ default_confirm,
15
+ run_task,
16
+ )
17
+
18
+ __all__ = [
19
+ "AgentRunner", "ConfirmFn", "RunOutcome", "Task", "ToolSpec",
20
+ "default_confirm", "run_task", "make_runner",
21
+ ]
22
+
23
+
24
+ def make_runner(backend: str, model: str | None = None):
25
+ """Resolve a runner by name, importing only that backend's SDK.
26
+
27
+ backend: "claude" | "gemini" | "aws". Raises SystemExit with an install hint
28
+ if the backend's package isn't installed.
29
+ """
30
+ if backend == "claude":
31
+ try:
32
+ from .runner.sdk import SDKRunner
33
+ except ImportError as e:
34
+ raise SystemExit(f"backend 'claude' needs the Claude Agent SDK: pip install behalf[claude] ({e})")
35
+ return SDKRunner(model) if model else SDKRunner()
36
+ if backend == "gemini":
37
+ try:
38
+ from .runner.adk import ADKRunner
39
+ except ImportError as e:
40
+ raise SystemExit(f"backend 'gemini' needs Google ADK: pip install behalf[gemini] ({e})")
41
+ return ADKRunner(model=model) if model else ADKRunner()
42
+ if backend == "aws":
43
+ try:
44
+ from .runner.strands import StrandsRunner
45
+ except ImportError as e:
46
+ raise SystemExit(f"backend 'aws' needs Strands: pip install behalf[aws] ({e})")
47
+ return StrandsRunner(model=model) if model else StrandsRunner()
48
+ raise SystemExit(f"unknown backend {backend!r} (choose: claude, gemini, aws)")
behalf/console.py ADDED
@@ -0,0 +1,52 @@
1
+ """Small terminal-output helper: a bit of color and structure for the live
2
+ trace. Colors switch off automatically when stdout isn't a TTY (piped, captured
3
+ in tests) or when NO_COLOR is set, so output stays clean everywhere."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import sys
9
+
10
+ _ON = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
11
+
12
+
13
+ def _wrap(code: str):
14
+ if not _ON:
15
+ return lambda s: str(s)
16
+ return lambda s: f"\033[{code}m{s}\033[0m"
17
+
18
+
19
+ bold = _wrap("1")
20
+ dim = _wrap("2")
21
+ red = _wrap("31")
22
+ green = _wrap("32")
23
+ yellow = _wrap("33")
24
+ cyan = _wrap("36")
25
+
26
+
27
+ def header(text: str, index: int | None = None, total: int | None = None) -> None:
28
+ tag = dim(f" [{index}/{total}]") if index and total else ""
29
+ print(f"\n{cyan('▸')} {bold(text)}{tag}", flush=True)
30
+
31
+
32
+ def phase(text: str) -> None:
33
+ print(f" {dim('·')} {dim(text)}", flush=True)
34
+
35
+
36
+ def op(name: str, detail: str = "") -> None:
37
+ tail = f" {dim(detail)}" if detail else ""
38
+ print(f" {green('→')} {bold(name)}{tail}", flush=True)
39
+
40
+
41
+ def ok(text: str) -> None:
42
+ print(f" {green('✓')} {text}", flush=True)
43
+
44
+
45
+ def warn(text: str) -> None:
46
+ print(f" {yellow('⚠')} {text}", flush=True)
47
+
48
+
49
+ def think(chunk: str) -> None:
50
+ """Streamed assistant text, dimmed so the operation lines stand out."""
51
+ sys.stdout.write(dim(chunk))
52
+ sys.stdout.flush()
behalf/core.py ADDED
@@ -0,0 +1,133 @@
1
+ """A tiny framework for a recurring pattern: give an agent a SCOPED task that
2
+ first gathers intent from the user, freezes it into a reproducible manifest,
3
+ then executes toward one specific outcome with a fixed, typed toolset.
4
+
5
+ elicit (adaptive conversation, seeded) -> manifest (frozen, reviewable)
6
+ -> execute (agent + fixed tools)
7
+
8
+ The core knows nothing about any particular domain. A Task supplies the three
9
+ things that vary: what to ask the user, which tools the agent may use, and what
10
+ a valid result is. Read-only tasks, tasks with an action tool that gates on user
11
+ confirmation, and pipelines of tasks are all just Tasks.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass
19
+ from typing import Any, Awaitable, Callable, Optional, Protocol
20
+
21
+
22
+ @dataclass
23
+ class ToolSpec:
24
+ """A tool the agent can call. `kind` distinguishes read (call freely) from
25
+ action (mutates something -> may require confirming the exact arguments)."""
26
+
27
+ name: str
28
+ description: str
29
+ input_schema: dict
30
+ handler: Callable[[dict], Awaitable[dict]]
31
+ kind: str = "read" # "read" | "action"
32
+ confirm: bool = False # if action: pause for approval before running
33
+
34
+
35
+ # confirm_fn(tool_name, args) -> bool. Supplied by the runtime (CLI prompt, test
36
+ # stub, auto-approve). Only ever called for action tools with confirm=True.
37
+ ConfirmFn = Callable[[str, dict], bool]
38
+
39
+
40
+ class Task(ABC):
41
+ """A scoped capability. Subclasses fill in the three varying pieces."""
42
+
43
+ name: str = "task"
44
+
45
+ # Whether the frozen setup manifest must be approved before execution.
46
+ # (Action tools have their OWN per-call confirmation regardless.)
47
+ requires_setup_approval: bool = False
48
+
49
+ def setup_system_prompt(self) -> str:
50
+ """Seeds elicitation: what the agent already knows (schemas, fixed
51
+ metadata) and what it must learn from the user."""
52
+ return "Gather what you need from the user, then finalize a setup manifest."
53
+
54
+ @abstractmethod
55
+ def manifest_schema(self) -> dict:
56
+ """The schema the elicitation must produce and freeze."""
57
+
58
+ def build_tools(self, manifest: dict) -> list[ToolSpec]:
59
+ """Fixed toolset for the default single-run execute(). Iterating tasks
60
+ override execute() and build tools per item, leaving this as []."""
61
+ return []
62
+
63
+ @abstractmethod
64
+ def execute_system_prompt(self, manifest: dict) -> str:
65
+ """Instruction for the execution agent."""
66
+
67
+ async def execute(self, runner: "AgentRunner", manifest: dict, confirm_fn: "ConfirmFn") -> Any:
68
+ """Run the task. Default: a single agent run over the built tools."""
69
+ tools = self.build_tools(manifest)
70
+ return await runner.run_agent(
71
+ system_prompt=self.execute_system_prompt(manifest),
72
+ user_prompt=manifest.get("goal", "Complete the task."),
73
+ tools=tools,
74
+ confirm_fn=confirm_fn,
75
+ )
76
+
77
+ def validate_result(self, result: Any) -> None:
78
+ """Raise if the produced result is unacceptable. Override per task."""
79
+ return None
80
+
81
+
82
+ class AgentRunner(Protocol):
83
+ """How the framework talks to a model. Real ones wrap an SDK; tests inject a
84
+ fake. This seam is what lets the whole flow be tested without a key."""
85
+
86
+ async def converse(self, task: "Task") -> dict:
87
+ """Run the seeded setup conversation; return the manifest."""
88
+ ...
89
+
90
+ async def run_agent(
91
+ self, system_prompt: str, user_prompt: str, tools: list[ToolSpec], confirm_fn: "ConfirmFn"
92
+ ) -> Any:
93
+ """One agent run with a fixed toolset; action tools gate on confirm_fn."""
94
+ ...
95
+
96
+
97
+ @dataclass
98
+ class RunOutcome:
99
+ manifest: dict
100
+ result: Any
101
+ approved: bool = True
102
+
103
+
104
+ def default_confirm(tool_name: str, args: dict) -> bool:
105
+ """CLI confirmation: show the exact call and ask. Safe default for actions."""
106
+ print(f"\n[confirm] about to run action '{tool_name}' with:")
107
+ print(json.dumps(args, indent=2))
108
+ return input("proceed? [y/N] ").strip().lower() in ("y", "yes")
109
+
110
+
111
+ async def run_task(
112
+ task: Task,
113
+ runner: AgentRunner,
114
+ manifest: Optional[dict] = None,
115
+ confirm_fn: ConfirmFn = default_confirm,
116
+ approve_fn: Optional[Callable[[dict], bool]] = None,
117
+ ) -> RunOutcome:
118
+ """The one entrypoint every task flows through. manifest given -> skip the
119
+ conversation (reproducible/batch); None -> converse to produce it. Then
120
+ optional setup approval, then execute; action tools gate on confirm_fn."""
121
+ if manifest is None:
122
+ manifest = await runner.converse(task)
123
+
124
+ approved = True
125
+ if task.requires_setup_approval:
126
+ approver = approve_fn or (lambda m: default_confirm("finalize-setup", m))
127
+ approved = approver(manifest)
128
+ if not approved:
129
+ return RunOutcome(manifest=manifest, result=None, approved=False)
130
+
131
+ result = await task.execute(runner, manifest, confirm_fn)
132
+ task.validate_result(result)
133
+ return RunOutcome(manifest=manifest, result=result, approved=approved)
@@ -0,0 +1,26 @@
1
+ """Model backends. Each submodule imports its own SDK at module load, so nothing
2
+ here is imported until you ask for a specific runner — installing one backend's
3
+ extra doesn't drag in the others.
4
+
5
+ `from behalf.runner import SDKRunner` resolves lazily; importing the submodule
6
+ directly (`behalf.runner.sdk`) is equivalent.
7
+ """
8
+
9
+ _RUNNERS = {
10
+ "SDKRunner": ".sdk", # Claude, via claude-agent-sdk
11
+ "ADKRunner": ".adk", # Gemini, via google-adk
12
+ "StrandsRunner": ".strands", # AWS Bedrock, via strands-agents
13
+ }
14
+
15
+
16
+ def __getattr__(name):
17
+ module = _RUNNERS.get(name)
18
+ if module is None:
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
20
+ from importlib import import_module
21
+
22
+ return getattr(import_module(module, __name__), name)
23
+
24
+
25
+ def __dir__():
26
+ return sorted(_RUNNERS)
behalf/runner/adk.py ADDED
@@ -0,0 +1,176 @@
1
+ """A Gemini backend via Google's Agent Development Kit (ADK), as an alternative
2
+ AgentRunner to SDKRunner.
3
+
4
+ STATUS: scaffolded, NOT yet run. Google ADK isn't a base dependency and isn't
5
+ installed in the dev image, so this module is untested end to end. The shape
6
+ follows ADK's documented API (Agent + FunctionTool + Runner + session service),
7
+ but the spots marked TODO are where the ADK specifics must be confirmed against
8
+ the installed version before relying on it. Install with `pip install google-adk`.
9
+
10
+ It exists because the framework already abstracts the model behind AgentRunner,
11
+ so adding Gemini is a contained job: convert our ToolSpecs to ADK function tools
12
+ (carrying the same read/action confirmation gate) and drive ADK's runner. None
13
+ of ProfileTask / tools.py / the container code changes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import json
20
+ from typing import Any
21
+
22
+ # Top-level imports so a missing ADK fails here, at import, not mid-run. This
23
+ # module is only imported when you explicitly want the Gemini path
24
+ # (`from behalf.runner.adk import ADKRunner`), so it never burdens the
25
+ # Claude/SDK path or `import behalf`.
26
+ from google.adk.agents import Agent
27
+ from google.adk.runners import Runner
28
+ from google.adk.sessions import InMemorySessionService
29
+ from google.adk.tools import FunctionTool
30
+ from google.genai import types
31
+
32
+ from ..core import AgentRunner, ConfirmFn, Task, ToolSpec
33
+
34
+ _PY_TYPE = {str: str, int: int, float: float, bool: bool, list: list, dict: dict}
35
+
36
+
37
+ def _to_adk_tool(ts: ToolSpec, confirm_fn: ConfirmFn) -> FunctionTool:
38
+ """Wrap one ToolSpec as an ADK FunctionTool.
39
+
40
+ ADK builds a tool's schema from the wrapped function's SIGNATURE (typed
41
+ params + docstring), whereas our ToolSpec carries an input_schema dict. So we
42
+ synthesize a function with matching parameters, then let ADK introspect it.
43
+
44
+ The wrapper also applies the read/action confirmation gate (same semantics as
45
+ SDKRunner) and adapts our handler's Anthropic-style {"content":[...]} return
46
+ into the plain value ADK expects a tool to return.
47
+ """
48
+
49
+ async def _invoke(**kwargs) -> Any:
50
+ if ts.kind == "action" and ts.confirm and not confirm_fn(ts.name, kwargs):
51
+ return "cancelled by user"
52
+ result = await ts.handler(kwargs)
53
+ # our handlers return {"content": [{"type": "text", "text": "..."}]}
54
+ try:
55
+ text = result["content"][0]["text"]
56
+ except (KeyError, IndexError, TypeError):
57
+ return result
58
+ try:
59
+ return json.loads(text) # most of our tools emit JSON
60
+ except (ValueError, TypeError):
61
+ return text
62
+
63
+ # give ADK a real signature + docstring to introspect
64
+ params = [
65
+ inspect.Parameter(
66
+ name, inspect.Parameter.KEYWORD_ONLY, annotation=_PY_TYPE.get(typ, str)
67
+ )
68
+ for name, typ in ts.input_schema.items()
69
+ ]
70
+ _invoke.__name__ = ts.name
71
+ _invoke.__doc__ = ts.description
72
+ _invoke.__signature__ = inspect.Signature(
73
+ params
74
+ ) # TODO: confirm ADK reads __signature__ (vs real params)
75
+ _invoke.__annotations__ = {
76
+ n: _PY_TYPE.get(t, str) for n, t in ts.input_schema.items()
77
+ }
78
+ return FunctionTool(
79
+ _invoke
80
+ ) # TODO: confirm FunctionTool(func) is the current constructor
81
+
82
+
83
+ class ADKRunner(AgentRunner):
84
+ """AgentRunner backed by Google ADK / Gemini. Same interface as SDKRunner."""
85
+
86
+ def __init__(
87
+ self,
88
+ model: str = "gemini-2.5-flash",
89
+ app_name: str = "behalf",
90
+ verbose: bool = True,
91
+ ):
92
+ self.model = model
93
+ self.app_name = app_name
94
+ self.verbose = verbose
95
+
96
+ async def _run_once(
97
+ self, instruction: str, user_prompt: str, adk_tools: list
98
+ ) -> list[str]:
99
+ agent = Agent(
100
+ name="behalf", model=self.model, instruction=instruction, tools=adk_tools
101
+ )
102
+ session_service = InMemorySessionService()
103
+ # TODO: confirm create_session is sync vs async in the installed ADK
104
+ session = session_service.create_session(
105
+ app_name=self.app_name, user_id="local", session_id="s1"
106
+ )
107
+ runner = Runner(
108
+ agent=agent, app_name=self.app_name, session_service=session_service
109
+ )
110
+
111
+ msg = types.Content(role="user", parts=[types.Part(text=user_prompt)])
112
+ texts: list[str] = []
113
+ # TODO: confirm run_async signature + how final text is surfaced on events
114
+ async for event in runner.run_async(
115
+ user_id="local", session_id=session.id, new_message=msg
116
+ ):
117
+ if self.verbose:
118
+ print(event)
119
+ content = getattr(event, "content", None)
120
+ if content and getattr(content, "parts", None):
121
+ for part in content.parts:
122
+ if getattr(part, "text", None):
123
+ texts.append(part.text)
124
+ return texts
125
+
126
+ async def run_agent(
127
+ self,
128
+ system_prompt: str,
129
+ user_prompt: str,
130
+ tools: list[ToolSpec],
131
+ confirm_fn: ConfirmFn,
132
+ ) -> Any:
133
+ adk_tools = [_to_adk_tool(t, confirm_fn) for t in tools]
134
+ await self._run_once(system_prompt, user_prompt, adk_tools)
135
+ # results are collected via the tools' side effects (e.g. record_artifact
136
+ # writing into ProfileTask's sink), same as SDKRunner.
137
+ return None
138
+
139
+ async def converse(self, task: Task) -> dict:
140
+ """Interactive setup: give the agent a finalize tool and loop until it's
141
+ called. Mirrors SDKRunner.converse. TODO: verify ADK multi-turn session
142
+ reuse (re-running the runner with the same session_id to continue)."""
143
+ captured: dict = {}
144
+
145
+ async def finalize_setup(manifest: dict) -> str:
146
+ """Finalize the setup manifest (matching the task's schema) and end setup."""
147
+ captured["manifest"] = manifest
148
+ return "setup finalized"
149
+
150
+ finalize = FunctionTool(finalize_setup)
151
+ agent = Agent(
152
+ name="setup",
153
+ model=self.model,
154
+ instruction=task.setup_system_prompt(),
155
+ tools=[finalize],
156
+ )
157
+ session_service = InMemorySessionService()
158
+ session = session_service.create_session(
159
+ app_name=self.app_name, user_id="local", session_id="setup"
160
+ )
161
+ runner = Runner(
162
+ agent=agent, app_name=self.app_name, session_service=session_service
163
+ )
164
+
165
+ prompt = "Let's set up this task. Ask me what you need."
166
+ while "manifest" not in captured:
167
+ msg = types.Content(role="user", parts=[types.Part(text=prompt)])
168
+ async for event in runner.run_async(
169
+ user_id="local", session_id=session.id, new_message=msg
170
+ ):
171
+ if self.verbose:
172
+ print(event)
173
+ if "manifest" in captured:
174
+ break
175
+ prompt = input("\nyou> ")
176
+ return captured["manifest"]
behalf/runner/sdk.py ADDED
@@ -0,0 +1,116 @@
1
+ """Claude backend: an AgentRunner backed by the Claude Agent SDK.
2
+
3
+ The SDK provides the agent loop, tool dispatch, and permissions. This module
4
+ adapts our ToolSpecs into SDK tools (adding the action-confirmation gate) and
5
+ runs them two ways:
6
+
7
+ run_agent() one execution pass over a fixed toolset
8
+ converse() interactive setup that ends when the model calls finalize_setup
9
+
10
+ Live use needs ANTHROPIC_API_KEY and the Claude Code CLI on PATH.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from claude_agent_sdk import (
18
+ ClaudeAgentOptions,
19
+ ClaudeSDKClient,
20
+ create_sdk_mcp_server,
21
+ query,
22
+ tool,
23
+ )
24
+
25
+ from ..core import AgentRunner, ConfirmFn, Task, ToolSpec
26
+
27
+
28
+ def _say(text: str) -> dict:
29
+ """An SDK tool result carrying a single text block."""
30
+ return {"content": [{"type": "text", "text": text}]}
31
+
32
+
33
+ def _to_sdk_tool(spec: ToolSpec, confirm_fn: ConfirmFn):
34
+ """Adapt one ToolSpec into an SDK tool. Action tools ask for confirmation of
35
+ their exact arguments before the handler runs; read tools run directly."""
36
+
37
+ @tool(spec.name, spec.description, spec.input_schema)
38
+ async def _sdk_tool(args):
39
+ if spec.kind == "action" and spec.confirm and not confirm_fn(spec.name, args):
40
+ return _say("cancelled by user")
41
+ return await spec.handler(args)
42
+
43
+ return _sdk_tool
44
+
45
+
46
+ class SDKRunner(AgentRunner):
47
+ def __init__(self, model: str | None = None, verbose: bool = True):
48
+ self.model = model
49
+ self.verbose = verbose
50
+
51
+ def _echo(self, message: Any) -> None:
52
+ if self.verbose:
53
+ print(message)
54
+
55
+ def _options(
56
+ self, server_name: str, server, allowed: list[str], system_prompt: str
57
+ ) -> ClaudeAgentOptions:
58
+ # Our tools are read-only or already gated by confirm_fn, so the SDK's
59
+ # own permission prompts are redundant here.
60
+ return ClaudeAgentOptions(
61
+ mcp_servers={server_name: server},
62
+ allowed_tools=allowed,
63
+ system_prompt=system_prompt,
64
+ permission_mode="bypassPermissions",
65
+ model=self.model,
66
+ max_turns=40,
67
+ )
68
+
69
+ async def run_agent(
70
+ self,
71
+ system_prompt: str,
72
+ user_prompt: str,
73
+ tools: list[ToolSpec],
74
+ confirm_fn: ConfirmFn,
75
+ ) -> Any:
76
+ server = create_sdk_mcp_server(
77
+ name="behalf",
78
+ version="0.1.0",
79
+ tools=[_to_sdk_tool(t, confirm_fn) for t in tools],
80
+ )
81
+ allowed = [f"mcp__behalf__{t.name}" for t in tools]
82
+ options = self._options("behalf", server, allowed, system_prompt)
83
+
84
+ async for message in query(prompt=user_prompt, options=options):
85
+ self._echo(message)
86
+ # Results are collected through the tools' side effects (e.g.
87
+ # record_artifact writing into the task's sink), not the return value.
88
+ return None
89
+
90
+ async def converse(self, task: Task) -> dict:
91
+ captured: dict = {}
92
+
93
+ @tool(
94
+ "finalize_setup",
95
+ "Finalize the setup manifest (matching the task's schema) and end setup.",
96
+ {"manifest": dict},
97
+ )
98
+ async def finalize(args):
99
+ captured["manifest"] = args.get("manifest", args)
100
+ return _say("setup finalized")
101
+
102
+ server = create_sdk_mcp_server(name="setup", version="0.1.0", tools=[finalize])
103
+ options = self._options(
104
+ "setup", server, ["mcp__setup__finalize_setup"], task.setup_system_prompt()
105
+ )
106
+
107
+ # The model asks the user for what it needs and drives the structure; we
108
+ # relay each of the user's replies until it calls finalize_setup.
109
+ async with ClaudeSDKClient(options=options) as client:
110
+ await client.query("Let's set up this task. Ask me what you need.")
111
+ while "manifest" not in captured:
112
+ async for message in client.receive_response():
113
+ self._echo(message)
114
+ if "manifest" not in captured:
115
+ await client.query(input("\nyou> "))
116
+ return captured["manifest"]
@@ -0,0 +1,179 @@
1
+ """An AWS backend via the Strands Agents SDK, running against Amazon Bedrock.
2
+
3
+ Handy when Bedrock (IAM/AWS credentials) is easier to reach than an Anthropic or
4
+ Google key. Authenticates with your normal AWS credential chain (profile / env /
5
+ role) and a region; the model is a Bedrock model id, which can still be Claude
6
+ (a Bedrock Anthropic inference profile), just reached through Bedrock.
7
+
8
+ STATUS: scaffolded, NOT yet run here (needs `pip install strands-agents` and AWS
9
+ creds). Shape follows the documented Strands API; spots to confirm against the
10
+ installed version are marked TODO. Strands tools are plain Python functions and
11
+ it exposes tool calls, so our ToolSpec -> function conversion (with the
12
+ read/action confirm gate) maps over directly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import inspect
19
+ import json
20
+ from typing import Any
21
+
22
+ from strands import Agent
23
+ from strands.models import (
24
+ BedrockModel,
25
+ ) # TODO: confirm class name in the installed version
26
+
27
+ from ..core import AgentRunner, ConfirmFn, Task, ToolSpec
28
+
29
+ _PY_TYPE = {str: str, int: int, float: float, bool: bool, list: list, dict: dict}
30
+
31
+
32
+ def _op_detail(name: str, kwargs: dict) -> str:
33
+ """A short, domain-agnostic one-liner for the trace: the scalar arguments,
34
+ space-joined. (A caller that wants prettier per-tool formatting can wrap the
35
+ runner; the library stays generic.)"""
36
+ scalars = [str(v) for v in kwargs.values()
37
+ if isinstance(v, (str, int, float, bool)) and str(v) != ""]
38
+ detail = " ".join(scalars) if scalars else json.dumps(kwargs, default=str)
39
+ return detail[:97] + "..." if len(detail) > 100 else detail
40
+
41
+
42
+ def _to_strands_tool(ts: ToolSpec, confirm_fn: ConfirmFn, on_call=None):
43
+ """ToolSpec -> Strands tool. Strands reads the function signature + docstring
44
+ for the schema, so we synthesize typed params. Our async handler is bridged
45
+ to sync, the confirm gate is applied for action tools, and the
46
+ {"content":[...]} return is adapted to a plain value. on_call, if given, is
47
+ invoked with (name, kwargs) when the tool actually runs — that's where the
48
+ real-time trace line comes from, since the arguments are complete by then."""
49
+ from strands import tool
50
+
51
+ def _invoke(**kwargs) -> Any:
52
+ if on_call is not None:
53
+ on_call(ts.name, kwargs)
54
+ if ts.kind == "action" and ts.confirm and not confirm_fn(ts.name, kwargs):
55
+ return "cancelled by user"
56
+ result = asyncio.run(ts.handler(kwargs))
57
+ try:
58
+ text = result["content"][0]["text"]
59
+ except (KeyError, IndexError, TypeError):
60
+ return result
61
+ try:
62
+ return json.loads(text)
63
+ except (ValueError, TypeError):
64
+ return text
65
+
66
+ _invoke.__name__ = ts.name
67
+ _invoke.__doc__ = ts.description
68
+ _invoke.__signature__ = inspect.Signature(
69
+ [
70
+ inspect.Parameter(
71
+ n, inspect.Parameter.KEYWORD_ONLY, annotation=_PY_TYPE.get(t, str)
72
+ )
73
+ for n, t in ts.input_schema.items()
74
+ ]
75
+ )
76
+ _invoke.__annotations__ = {
77
+ n: _PY_TYPE.get(t, str) for n, t in ts.input_schema.items()
78
+ }
79
+ return tool(_invoke) # TODO: confirm strands.tool(func) call form
80
+
81
+
82
+ class StrandsRunner(AgentRunner):
83
+ """AgentRunner backed by Strands + Bedrock. Same interface as SDKRunner."""
84
+
85
+ def __init__(
86
+ self, model: str | None = None, region: str = "us-east-1", verbose: bool = True
87
+ ):
88
+ self.model = model # None -> Strands' default (an Anthropic model on Bedrock)
89
+ self.region = region
90
+ self.verbose = verbose
91
+
92
+ def _model(self):
93
+ if self.model is None:
94
+ return None
95
+ return BedrockModel(
96
+ model_id=self.model, region_name=self.region
97
+ ) # TODO: confirm kwargs
98
+
99
+ def _render(self, event) -> None:
100
+ """Stream assistant text (dimmed). Tool lines print from the wrapper."""
101
+ if self.verbose and isinstance(event, dict) and event.get("data"):
102
+ from .. import console
103
+
104
+ console.think(event["data"])
105
+ self._mid_line = not event["data"].endswith("\n")
106
+
107
+ def _trace(self, name: str, kwargs: dict) -> None:
108
+ if not self.verbose:
109
+ return
110
+ from .. import console
111
+
112
+ if getattr(self, "_mid_line", False):
113
+ print() # close a partial line of streamed text before the op line
114
+ self._mid_line = False
115
+ console.op(name, _op_detail(name, kwargs))
116
+
117
+ async def run_agent(
118
+ self,
119
+ system_prompt: str,
120
+ user_prompt: str,
121
+ tools: list[ToolSpec],
122
+ confirm_fn: ConfirmFn,
123
+ ) -> Any:
124
+ self._mid_line = False
125
+ kwargs = {
126
+ "system_prompt": system_prompt,
127
+ "tools": [
128
+ _to_strands_tool(t, confirm_fn, on_call=self._trace) for t in tools
129
+ ],
130
+ # silence Strands' own stdout printer so it doesn't double with ours
131
+ "callback_handler": None,
132
+ }
133
+ model = self._model()
134
+ if model is not None:
135
+ kwargs["model"] = model
136
+ agent = Agent(**kwargs)
137
+
138
+ stream = getattr(agent, "stream_async", None)
139
+ if stream is None:
140
+ result = await asyncio.to_thread(
141
+ agent, user_prompt
142
+ ) # TODO: prefer invoke_async
143
+ if self.verbose:
144
+ print(result)
145
+ else:
146
+ async for event in stream(user_prompt):
147
+ self._render(event)
148
+ if self.verbose and self._mid_line:
149
+ print() # newline after trailing streamed text
150
+ return None # results collected via tool side effects (record_artifact -> sink)
151
+
152
+ async def converse(self, task: Task) -> dict:
153
+ from strands import tool
154
+
155
+ captured: dict = {}
156
+
157
+ def finalize_setup(manifest: dict) -> str:
158
+ """Finalize the setup manifest (matching the task's schema) and end setup."""
159
+ captured["manifest"] = manifest
160
+ return "setup finalized"
161
+
162
+ kwargs = {
163
+ "system_prompt": task.setup_system_prompt(),
164
+ "tools": [tool(finalize_setup)],
165
+ }
166
+ model = self._model()
167
+ if model is not None:
168
+ kwargs["model"] = model
169
+ agent = Agent(**kwargs)
170
+
171
+ prompt = "Let's set up this task. Ask me what you need."
172
+ while "manifest" not in captured:
173
+ out = await asyncio.to_thread(agent, prompt)
174
+ if self.verbose:
175
+ print(out)
176
+ if "manifest" in captured:
177
+ break
178
+ prompt = input("\nyou> ")
179
+ return captured["manifest"]
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: behalf
3
+ Version: 0.1.0
4
+ Summary: A small framework for scoped agents: a brief, a typed toolset (read/action), a pluggable model backend.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Provides-Extra: claude
11
+ Requires-Dist: claude-agent-sdk>=0.2; extra == "claude"
12
+ Provides-Extra: gemini
13
+ Requires-Dist: google-adk>=1.0; extra == "gemini"
14
+ Provides-Extra: aws
15
+ Requires-Dist: strands-agents>=0.1; extra == "aws"
16
+ Requires-Dist: boto3>=1.34; extra == "aws"
17
+ Provides-Extra: all
18
+ Requires-Dist: claude-agent-sdk>=0.2; extra == "all"
19
+ Requires-Dist: google-adk>=1.0; extra == "all"
20
+ Requires-Dist: strands-agents>=0.1; extra == "all"
21
+ Requires-Dist: boto3>=1.34; extra == "all"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # behalf
27
+
28
+ A small framework for the recurring pattern of a **scoped agent**: hand it a
29
+ brief, a fixed set of tools, and bounded authority, and it does one job.
30
+
31
+
32
+ ## Abstractions
33
+
34
+ - **Task** what to elicit from the user, which tools the agent gets, and what a valid result is.
35
+ - **ToolSpec** can be a single tool or action.
36
+ - **runner backend** is the model like the Claude Agent SDK, Google ADK, or Strands on Bedrock.
37
+
38
+
39
+ ```python
40
+ from behalf import run_task, make_runner
41
+ outcome = await run_task(MyTask(), make_runner("aws"))
42
+ ```
43
+
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install behalf # core only (no backend)
49
+ pip install behalf[claude] # + Claude Agent SDK
50
+ pip install behalf[aws] # + Strands / Bedrock
51
+ pip install behalf[gemini] # + Google ADK
52
+ ```
53
+
54
+ ## License
55
+
56
+ HPCIC DevTools is distributed under the terms of the MIT license.
57
+ All new contributions must be made under this license.
58
+
59
+ See [LICENSE](https://github.com/converged-computing/cloud-select/blob/main/LICENSE),
60
+ [COPYRIGHT](https://github.com/converged-computing/cloud-select/blob/main/COPYRIGHT), and
61
+ [NOTICE](https://github.com/converged-computing/cloud-select/blob/main/NOTICE) for details.
62
+
63
+ SPDX-License-Identifier: (MIT)
64
+
65
+ LLNL-CODE- 842614
@@ -0,0 +1,13 @@
1
+ behalf/__init__.py,sha256=vuUyk4YWtnYXYczwUPFOv3TJZ7pn5080Zo7kj-vShBA,1768
2
+ behalf/console.py,sha256=c77STgrmzq1zrSXFYz3x8OFLSkh_RdayzNl1u-o8D5g,1361
3
+ behalf/core.py,sha256=akrlYi6ekGIh3sE4LnPn1vwaTtBBkHWOkC_2yUpPGYc,5096
4
+ behalf/runner/__init__.py,sha256=ck54cntAUFu_HLoFC8DIWcYrwhL_-fkuVDGoMgQNRKw,832
5
+ behalf/runner/adk.py,sha256=0ofUAGUfOFz3t6Md-klbIbT_bT12GvMeBPSPrJndXIg,6968
6
+ behalf/runner/sdk.py,sha256=E1KmGUXD9tAeDwzFlv9OsPxT8cn232Gf601HEQxQ7Co,4089
7
+ behalf/runner/strands.py,sha256=XNdk4QCy9dRuSwRcVoeUde8J2_0SSXkS3Wat2lbVbR4,6661
8
+ behalf-0.1.0.dist-info/licenses/LICENSE,sha256=AlyLB1m_z0CENCx1ob0PedLTTohtH2VLZhs2kfygrfc,1108
9
+ behalf-0.1.0.dist-info/licenses/NOTICE,sha256=9CR93geVKl_4ZrJORbXN0fzkEM2y4DglWhY1hn9ZwQw,1167
10
+ behalf-0.1.0.dist-info/METADATA,sha256=6Qq0OjqJzDxRyOHCLNpQ7jzp5M99kPQHvMywAexyJKU,2061
11
+ behalf-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ behalf-0.1.0.dist-info/top_level.txt,sha256=8tZaLJc5ZkQscR9dyCnXL9XgXupfr8DtB2Irt_C7QLs,7
13
+ behalf-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2023 LLNS, LLC and other HPCIC DevTools Developers.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ This work was produced under the auspices of the U.S. Department of
2
+ Energy by Lawrence Livermore National Laboratory under Contract
3
+ DE-AC52-07NA27344.
4
+
5
+ This work was prepared as an account of work sponsored by an agency of
6
+ the United States Government. Neither the United States Government nor
7
+ Lawrence Livermore National Security, LLC, nor any of their employees
8
+ makes any warranty, expressed or implied, or assumes any legal liability
9
+ or responsibility for the accuracy, completeness, or usefulness of any
10
+ information, apparatus, product, or process disclosed, or represents that
11
+ its use would not infringe privately owned rights.
12
+
13
+ Reference herein to any specific commercial product, process, or service
14
+ by trade name, trademark, manufacturer, or otherwise does not necessarily
15
+ constitute or imply its endorsement, recommendation, or favoring by the
16
+ United States Government or Lawrence Livermore National Security, LLC.
17
+
18
+ The views and opinions of authors expressed herein do not necessarily
19
+ state or reflect those of the United States Government or Lawrence
20
+ Livermore National Security, LLC, and shall not be used for advertising
21
+ or product endorsement purposes.
@@ -0,0 +1 @@
1
+ behalf