Skip to content

Commit 86105df

Browse files
feat(kh-plugin): sbo3l-autogen-keeperhub — AutoGen policy-guarded KH executor (R22-B) (#481)
Ships the AutoGen flavor of the SBO3L → KeeperHub policy gate, mirroring the langchain-keeperhub pattern landed in #425. Drops directly into a ConversableAgent (legacy pyautogen<0.3) or AssistantAgent (autogen-agentchat>=0.4) — both supported by duck-typing the agent surface rather than importing either class, so the wrapper carries no hard AutoGen import. Core pieces: - integrations/autogen-keeperhub-py/sbo3l_autogen_keeperhub/tool.py: framework-agnostic `sbo3l_autogen_keeperhub_tool(client=...)` factory returning an SBO3LKeeperHubToolDescriptor (name + description + sync callable). Same envelope shape as the langchain version (kh_workflow_id_advisory honest naming, async-coroutine-close defensive code, idempotency_key callback param). - integrations/autogen-keeperhub-py/sbo3l_autogen_keeperhub/autogen_tool.py: `register_sbo3l_keeperhub_tool(agent, *, client, ...)` helper that dispatches against either ConversableAgent.register_function (legacy) or AssistantAgent._tools (modern). asyncio.to_thread shim wraps the blocking SBO3L HTTP call when invoked from inside an event loop. `aprp_function_signature()` exports the OpenAI-function-calling JSON schema for the legacy `register_for_llm` decorator pattern. - 6 pytest cases mirror the langchain version: allow surfaces kh_execution_ref, deny doesn't, workflow_id override, invalid JSON, array input, importable. ALL pass under the autogen-agentchat 0.7.x modern runtime. - Demo: examples/autogen-keeperhub-demo/agent.py shows a 2-agent (planner + executor) AutoGen-shaped conversation where each turn spawns a KH execution gated by SBO3L. Audit log dumps the cross-system trace (turn -> SBO3L audit_event_id -> KH execution_ref). Runs end-to-end without an OpenAI API key — uses a duck-typed _MockAgent that mirrors the legacy ConversableAgent.register_function surface. - CI matrix: integrations-publish.yml gets autogen-keeperhub-py axis + autogen-keeperhub-py-v* tag prefix. install-smoke.yml gets sbo3l-autogen-keeperhub with allow_failure: true (mirrors the langchain-keeperhub PyPI Trusted Publisher gating story). Composable with Devendra's `langchain-keeperhub` (his wraps execution; ours gates upstream). Wire path matches the TS / langchain packages exactly so kh_execution_ref is comparable across SDK languages and frameworks. Test plan executed locally: - pytest -q -> 6/6 passed - ruff check sbo3l_autogen_keeperhub/ -> All checks passed - ruff format --check sbo3l_autogen_keeperhub/ -> all formatted - mypy --strict sbo3l_autogen_keeperhub/ -> no issues - python -m build -> wheel + sdist OK Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 945ceae commit 86105df

13 files changed

Lines changed: 1152 additions & 1 deletion

File tree

.github/workflows/install-smoke.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ jobs:
265265
# When Daniel completes the PyPI Trusted Publisher registration
266266
# + the publish job lands, drop this `allow_failure` line.
267267
allow_failure: true
268+
- package: sbo3l-autogen-keeperhub
269+
# Pending first PyPI publish — Trusted Publisher registration
270+
# required (same gating story as sbo3l-langchain-keeperhub
271+
# above; once the publish workflow lands a v1.2.0 the entry
272+
# flips to a hard requirement). Tracking the same
273+
# docs/dev2/pypi-langchain-keeperhub-publish-blocker.md note.
274+
allow_failure: true
268275
- package: sbo3l-crewai
269276
- package: sbo3l-llamaindex
270277
- package: sbo3l-langgraph

.github/workflows/integrations-publish.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ name: Integrations publish
1414
# vercel-ai-v* → @sbo3l/vercel-ai (npm)
1515
# langchain-py-v* → sbo3l-langchain (PyPI)
1616
# langchain-keeperhub-py-v* → sbo3l-langchain-keeperhub (PyPI)
17+
# autogen-keeperhub-py-v* → sbo3l-autogen-keeperhub (PyPI)
1718
# crewai-py-v* → sbo3l-crewai (PyPI)
1819
# llamaindex-py-v* → sbo3l-llamaindex (PyPI)
1920
# langgraph-py-v* → sbo3l-langgraph (PyPI)
@@ -33,6 +34,7 @@ on:
3334
- "vercel-ai-v*"
3435
- "langchain-py-v*"
3536
- "langchain-keeperhub-py-v*"
37+
- "autogen-keeperhub-py-v*"
3638
- "crewai-py-v*"
3739
- "llamaindex-py-v*"
3840
- "langgraph-py-v*"
@@ -139,7 +141,7 @@ jobs:
139141
# falls back to system Python). Listing both axes explicitly keeps
140142
# all 12 combos visible in the run summary.
141143
matrix:
142-
id: [langchain-py, langchain-keeperhub-py, crewai-py, llamaindex-py, langgraph-py]
144+
id: [langchain-py, langchain-keeperhub-py, autogen-keeperhub-py, crewai-py, llamaindex-py, langgraph-py]
143145
python: ["3.10", "3.11", "3.12"]
144146
include:
145147
- id: langchain-py
@@ -150,6 +152,10 @@ jobs:
150152
dir: integrations/langchain-keeperhub-py
151153
pkg: sbo3l-langchain-keeperhub
152154
module: sbo3l_langchain_keeperhub
155+
- id: autogen-keeperhub-py
156+
dir: integrations/autogen-keeperhub-py
157+
pkg: sbo3l-autogen-keeperhub
158+
module: sbo3l_autogen_keeperhub
153159
- id: crewai-py
154160
dir: integrations/crewai
155161
pkg: sbo3l-crewai
@@ -297,6 +303,7 @@ jobs:
297303
if: |
298304
startsWith(github.ref, 'refs/tags/langchain-py-v') ||
299305
startsWith(github.ref, 'refs/tags/langchain-keeperhub-py-v') ||
306+
startsWith(github.ref, 'refs/tags/autogen-keeperhub-py-v') ||
300307
startsWith(github.ref, 'refs/tags/crewai-py-v') ||
301308
startsWith(github.ref, 'refs/tags/llamaindex-py-v') ||
302309
startsWith(github.ref, 'refs/tags/langgraph-py-v')
@@ -315,6 +322,10 @@ jobs:
315322
tag_prefix: langchain-keeperhub-py-v
316323
dir: integrations/langchain-keeperhub-py
317324
pkg: sbo3l-langchain-keeperhub
325+
- id: autogen-keeperhub-py
326+
tag_prefix: autogen-keeperhub-py-v
327+
dir: integrations/autogen-keeperhub-py
328+
pkg: sbo3l-autogen-keeperhub
318329
- id: crewai-py
319330
tag_prefix: crewai-py-v
320331
dir: integrations/crewai
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# `examples/autogen-keeperhub-demo`
2+
3+
End-to-end runnable demo of the **SBO3L → KeeperHub policy gate inside an AutoGen 2-agent conversation**.
4+
5+
| File | Pattern | LOC | What it proves |
6+
|---|---|---|---|
7+
| [`agent.py`](agent.py) | Planner -> Executor with tool dispatch | ~140 | Each conversation turn spawns a KH execution gated by SBO3L; audit log links turn -> SBO3L `audit_event_id` -> KH `execution_ref`. |
8+
9+
## What it shows
10+
11+
A 2-agent AutoGen-style conversation:
12+
13+
- **Planner** produces a 3-step research plan (`search` -> `rerank` -> `summarize`).
14+
- **Executor** holds the SBO3L → KeeperHub tool. Each tool call:
15+
1. Fires `sbo3l_keeperhub_payment_request(aprp_json=...)` against the SBO3L daemon
16+
2. SBO3L decides allow / deny / requires_human against the loaded policy
17+
3. On allow, daemon-side KH adapter executes the payment + returns `kh_execution_ref`
18+
4. Executor reports the receipt + execution ref back to the planner
19+
20+
The audit log printed at the end shows each conversation turn linked to its SBO3L `audit_event_id` and KH `execution_ref` — the cross-system trace a judge wants to see.
21+
22+
## Why no LLM in the demo
23+
24+
The planner's "reasoning" is hardcoded so the wire path stays visible without an OpenAI API key. The executor uses a duck-typed `_MockAgent` that mirrors AutoGen's legacy `ConversableAgent.register_function(function_map=...)` surface exactly — swap it for the real class + an `AssistantAgent` planner with a real `model_client` and the same code shape works unchanged.
25+
26+
## Prereqs
27+
28+
```bash
29+
# 1. Run the SBO3L daemon in mock mode (one-time per session)
30+
SBO3L_ALLOW_UNAUTHENTICATED=1 \
31+
SBO3L_SIGNER_BACKEND=dev SBO3L_DEV_ONLY_SIGNER=1 \
32+
cargo run --bin sbo3l-server &
33+
34+
# 2. Install demo deps
35+
cd examples/autogen-keeperhub-demo
36+
python3 -m venv .venv
37+
.venv/bin/pip install \
38+
-e ../../sdks/python \
39+
-e ../../integrations/autogen-keeperhub-py
40+
```
41+
42+
## Run
43+
44+
```bash
45+
.venv/bin/python agent.py
46+
```
47+
48+
Expected:
49+
50+
```
51+
daemon: http://localhost:8730
52+
53+
planner: 3-step research plan: ['search', 'rerank', 'summarize']
54+
55+
--- conversation turn 1: planner -> executor: run 'search' ---
56+
decision: allow
57+
kh_execution_ref: kh-...
58+
audit_event_id: evt-...
59+
[...]
60+
61+
=== audit log: turn -> SBO3L decision -> KH execution_ref ===
62+
turn=1 task=search decision=allow audit=evt-... kh=kh-...
63+
turn=2 task=rerank decision=allow audit=evt-... kh=kh-...
64+
turn=3 task=summarize decision=allow audit=evt-... kh=kh-...
65+
66+
all 3 turns allowed + executed via KH
67+
```
68+
69+
## Live KeeperHub mode
70+
71+
Add daemon-side env vars before starting `sbo3l-server`:
72+
73+
```bash
74+
SBO3L_KEEPERHUB_WEBHOOK_URL=https://app.keeperhub.com/api/workflows/<id>/webhook \
75+
SBO3L_KEEPERHUB_TOKEN=wfb_<token> \
76+
SBO3L_ALLOW_UNAUTHENTICATED=1 \
77+
SBO3L_SIGNER_BACKEND=dev SBO3L_DEV_ONLY_SIGNER=1 \
78+
cargo run --bin sbo3l-server &
79+
```
80+
81+
Without these, the daemon's KH adapter falls back to `local_mock` and returns `kh-<ULID>` refs with `mock=true` evidence — the wire path stays visible end-to-end.
82+
83+
## Wiring with the real `autogen-agentchat` package
84+
85+
```python
86+
from autogen_agentchat.agents import AssistantAgent
87+
from sbo3l_sdk import SBO3LClientSync
88+
from sbo3l_autogen_keeperhub import sbo3l_autogen_keeperhub_tool
89+
90+
with SBO3LClientSync("http://localhost:8730") as client:
91+
descriptor = sbo3l_autogen_keeperhub_tool(client=client)
92+
executor = AssistantAgent(
93+
name="executor",
94+
model_client=..., # OpenAIChatCompletionClient(...)
95+
tools=[descriptor.func], # SBO3L tool drops in here
96+
)
97+
```
98+
99+
For the legacy `pyautogen<0.3` `ConversableAgent`, use `register_sbo3l_keeperhub_tool(executor, client=client)` — it duck-types against either surface.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""autogen-keeperhub-demo — 2-agent AutoGen conversation gated by SBO3L.
2+
3+
Demonstrates the AutoGen-specific composition shape:
4+
5+
1. **Planner** decides what work needs doing (3 sequential research tasks).
6+
2. **Executor** holds the SBO3L → KeeperHub tool and runs each task
7+
by calling `sbo3l_keeperhub_payment_request(aprp_json=...)` against
8+
the SBO3L daemon. Each call returns either a signed PolicyReceipt +
9+
KH execution_ref (allow) or a deny envelope with branch-on code.
10+
11+
The conversation is hardcoded (no OpenAI API key required) — every
12+
"reasoning" turn is a plain Python statement so the wire path
13+
(planner-message → executor-tool-call → SBO3L decide → KH execute)
14+
stays visible without an LLM in the loop. The same code shape works
15+
unchanged when you swap the planner for a real `AssistantAgent` with
16+
`model_client=OpenAIChatCompletionClient(...)`.
17+
18+
Run:
19+
python agent.py
20+
21+
Expected output:
22+
- 3 ALLOW envelopes with kh_execution_ref populated
23+
- One audit log dump linking each conversation turn → SBO3L
24+
audit_event_id → KH execution_ref
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import json
30+
import os
31+
import sys
32+
import uuid
33+
from dataclasses import dataclass, field
34+
from datetime import datetime, timedelta, timezone
35+
from typing import Any
36+
37+
from sbo3l_autogen_keeperhub import sbo3l_autogen_keeperhub_tool
38+
from sbo3l_sdk import SBO3LClientSync
39+
40+
41+
@dataclass
42+
class _MockAgent:
43+
"""Minimal duck-typed stand-in for autogen.ConversableAgent.
44+
45+
AutoGen 0.2.x's `ConversableAgent.register_function(function_map=...)`
46+
surface — the only piece we need to demo the SBO3L registration
47+
pattern without requiring the real package (which carries an
48+
OpenAI/Anthropic API key requirement once a real LLM client is
49+
wired up). The mock dispatches `function_call` exactly the way the
50+
real ConversableAgent does: looks up by name, calls with kwargs.
51+
"""
52+
53+
name: str
54+
function_map: dict[str, Any] = field(default_factory=dict)
55+
56+
def register_function(self, function_map: dict[str, Any]) -> None:
57+
self.function_map.update(function_map)
58+
59+
def call_tool(self, tool_name: str, **kwargs: Any) -> str:
60+
if tool_name not in self.function_map:
61+
raise KeyError(f"agent {self.name!r} has no tool {tool_name!r}")
62+
# The function_map callable on the legacy ConversableAgent path
63+
# is registered with a single `aprp_json: str` positional arg.
64+
# AutoGen marshals the LLM's tool-call JSON into kwargs; we
65+
# mirror that here.
66+
if "aprp_json" in kwargs:
67+
return str(self.function_map[tool_name](kwargs["aprp_json"]))
68+
# Convenience: if caller passed a dict APRP, JSON-stringify it.
69+
if "aprp" in kwargs:
70+
return str(self.function_map[tool_name](json.dumps(kwargs["aprp"])))
71+
raise TypeError(
72+
f"call_tool({tool_name!r}) expected 'aprp_json' or 'aprp' kwarg, "
73+
f"got {sorted(kwargs)}"
74+
)
75+
76+
77+
def _aprp(task: str) -> dict[str, Any]:
78+
return {
79+
# research-agent-01 is the only agent_id registered in the bundled
80+
# reference policy. Demos that hardcode a different id are denied
81+
# before policy evaluation (auth.agent_not_found). Use SBO3L_POLICY
82+
# to load a custom policy if you want a different label here.
83+
"agent_id": "research-agent-01",
84+
"task_id": f"autogen-{task}-{uuid.uuid4().hex[:8]}",
85+
"intent": "purchase_api_call",
86+
"amount": {"value": "0.05", "currency": "USD"},
87+
"token": "USDC",
88+
"destination": {
89+
"type": "x402_endpoint",
90+
"url": f"https://api.example.com/v1/{task}",
91+
"method": "POST",
92+
"expected_recipient": "0x1111111111111111111111111111111111111111",
93+
},
94+
"payment_protocol": "x402",
95+
"chain": "base",
96+
"provider_url": "https://api.example.com",
97+
"expiry": (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
98+
"nonce": str(uuid.uuid4()),
99+
"risk_class": "low",
100+
}
101+
102+
103+
def main() -> int:
104+
endpoint = os.environ.get("SBO3L_ENDPOINT", "http://localhost:8730")
105+
print(f"daemon: {endpoint}")
106+
107+
# Real AutoGen would be:
108+
# from autogen import ConversableAgent
109+
# executor = ConversableAgent(name="executor", llm_config=False)
110+
# We use the duck-typed _MockAgent so the demo runs end-to-end
111+
# without an LLM API key. The SBO3L registration step + tool
112+
# dispatch shape is identical between mock + real.
113+
executor = _MockAgent(name="executor")
114+
115+
with SBO3LClientSync(endpoint) as client:
116+
descriptor = sbo3l_autogen_keeperhub_tool(client=client)
117+
executor.register_function(function_map={descriptor.name: descriptor.func})
118+
119+
# The "planner" — in a real demo this is an AssistantAgent
120+
# producing tool-calls from natural-language reasoning. Here
121+
# it's a hardcoded plan so the audit log captures the same
122+
# event shape without an LLM.
123+
plan = ["search", "rerank", "summarize"]
124+
print(f"\nplanner: 3-step research plan: {plan}")
125+
126+
audit_log: list[dict[str, Any]] = []
127+
for step, task in enumerate(plan, 1):
128+
print(f"\n--- conversation turn {step}: planner -> executor: run {task!r} ---")
129+
envelope_str = executor.call_tool(
130+
descriptor.name,
131+
aprp_json=json.dumps(_aprp(task)),
132+
)
133+
envelope = json.loads(envelope_str)
134+
print(f" decision: {envelope.get('decision')}")
135+
print(f" kh_execution_ref: {envelope.get('kh_execution_ref')}")
136+
print(f" audit_event_id: {envelope.get('audit_event_id')}")
137+
if envelope.get("deny_code"):
138+
print(f" deny_code: {envelope['deny_code']}")
139+
140+
audit_log.append(
141+
{
142+
"turn": step,
143+
"task": task,
144+
"decision": envelope.get("decision"),
145+
"audit_event_id": envelope.get("audit_event_id"),
146+
"kh_execution_ref": envelope.get("kh_execution_ref"),
147+
}
148+
)
149+
150+
print("\n=== audit log: turn -> SBO3L decision -> KH execution_ref ===")
151+
for row in audit_log:
152+
print(
153+
f" turn={row['turn']:>1} task={row['task']:<10} "
154+
f"decision={row['decision']:<6} audit={row['audit_event_id']} "
155+
f"kh={row['kh_execution_ref']}"
156+
)
157+
158+
failures = [r for r in audit_log if r["decision"] != "allow"]
159+
if failures:
160+
print(f"\nfailed turns: {len(failures)}/{len(audit_log)}")
161+
return 1
162+
print(f"\nall {len(audit_log)} turns allowed + executed via KH")
163+
return 0
164+
165+
166+
if __name__ == "__main__":
167+
sys.exit(main())
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[build-system]
2+
requires = ["hatchling>=1.21"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "sbo3l-example-autogen-keeperhub-demo"
7+
version = "0.0.1"
8+
description = "Runnable SBO3L → KeeperHub demo inside an AutoGen 2-agent conversation."
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = { text = "MIT" }
12+
authors = [{ name = "Daniel Babjak", email = "babjak_daniel@hotmail.com" }]
13+
dependencies = [
14+
# `file:` deps point at the in-repo SDK + adapter so the demo always
15+
# tracks the local source, not whatever's published on PyPI. For
16+
# editable installs in dev / CI prefer:
17+
# pip install -e ../../sdks/python \
18+
# -e ../../integrations/autogen-keeperhub-py \
19+
# -e .
20+
"sbo3l-sdk>=1.0.0,<2",
21+
"sbo3l-autogen-keeperhub>=1.2.0,<2",
22+
]
23+
24+
[project.optional-dependencies]
25+
autogen = [
26+
# Pull in the modern AutoGen distribution if a dev wants to swap
27+
# the duck-typed _MockAgent in agent.py for the real
28+
# AssistantAgent + a model_client. Demo runs without this extra.
29+
"autogen-agentchat>=0.4,<1",
30+
]
31+
32+
[project.scripts]
33+
sbo3l-autogen-keeperhub-demo = "agent:main"
34+
35+
[tool.hatch.build.targets.wheel]
36+
# Single-file script — ship `agent.py` at the wheel root rather than
37+
# wrapping it in a package directory (mirrors how the langchain
38+
# demos directory ships individual one-file demos with no package
39+
# structure).
40+
include = ["agent.py", "README.md"]
41+
sources = ["."]
42+
43+
[tool.hatch.build.targets.sdist]
44+
include = ["agent.py", "README.md", "pyproject.toml"]

0 commit comments

Comments
 (0)