Skip to content

Commit adcc7ee

Browse files
author
AgentPatterns
committed
feat(examples/python): add runnable example
1 parent 4e47a58 commit adcc7ee

7 files changed

Lines changed: 898 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Supervisor Agent - Python Implementation
2+
3+
Runnable implementation of a supervisor-controlled support flow where a worker
4+
proposes actions, a supervisor enforces policy, and only approved actions execute.
5+
6+
---
7+
8+
## Quick start
9+
10+
```bash
11+
# (optional) create venv
12+
python -m venv .venv && source .venv/bin/activate
13+
14+
# install dependencies
15+
pip install -r requirements.txt
16+
17+
# set API key
18+
export OPENAI_API_KEY="sk-..."
19+
20+
# run the agent
21+
python main.py
22+
```
23+
24+
## Full walkthrough
25+
26+
Read the complete implementation guide:
27+
https://agentpatterns.tech/en/agent-patterns/supervisor-agent
28+
29+
## What's inside
30+
31+
- Worker action loop (`tool` / `final`) with strict JSON action validation
32+
- Supervisor policy decisions (`approve`, `revise`, `block`, `escalate`)
33+
- Human-approval simulation for high-risk refunds
34+
- Execution boundary with allowlist + budget + loop guards
35+
- Trace and history for auditability
36+
37+
## Project layout
38+
39+
```text
40+
examples/
41+
agent-patterns/
42+
supervisor-agent/
43+
python/
44+
README.md
45+
main.py
46+
llm.py
47+
supervisor.py
48+
gateway.py
49+
tools.py
50+
requirements.txt
51+
```
52+
53+
## Notes
54+
55+
- Code and README are English-only by design.
56+
- The website provides multilingual explanations and theory.
57+
58+
## License
59+
60+
MIT
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import json
5+
from dataclasses import dataclass
6+
from typing import Any, Callable
7+
8+
9+
class StopRun(Exception):
10+
def __init__(self, reason: str):
11+
super().__init__(reason)
12+
self.reason = reason
13+
14+
15+
@dataclass(frozen=True)
16+
class Budget:
17+
max_steps: int = 8
18+
max_tool_calls: int = 5
19+
max_seconds: int = 30
20+
21+
22+
TOOL_ARG_TYPES: dict[str, dict[str, str]] = {
23+
"get_refund_context": {"user_id": "int"},
24+
"issue_refund": {"user_id": "int", "amount_usd": "number", "reason": "str?"},
25+
"send_refund_email": {"user_id": "int", "amount_usd": "number", "message": "str"},
26+
}
27+
28+
29+
def _stable_json(value: Any) -> str:
30+
if value is None or isinstance(value, (bool, int, float, str)):
31+
return json.dumps(value, ensure_ascii=True, sort_keys=True)
32+
if isinstance(value, list):
33+
return "[" + ",".join(_stable_json(v) for v in value) + "]"
34+
if isinstance(value, dict):
35+
parts = []
36+
for key in sorted(value):
37+
parts.append(json.dumps(str(key), ensure_ascii=True) + ":" + _stable_json(value[key]))
38+
return "{" + ",".join(parts) + "}"
39+
return json.dumps(str(value), ensure_ascii=True)
40+
41+
42+
def _normalize_for_hash(value: Any) -> Any:
43+
if isinstance(value, str):
44+
return " ".join(value.strip().split())
45+
if isinstance(value, list):
46+
return [_normalize_for_hash(item) for item in value]
47+
if isinstance(value, dict):
48+
return {str(key): _normalize_for_hash(value[key]) for key in sorted(value)}
49+
return value
50+
51+
52+
def args_hash(args: dict[str, Any]) -> str:
53+
normalized = _normalize_for_hash(args or {})
54+
raw = _stable_json(normalized)
55+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
56+
57+
58+
def _is_number(value: Any) -> bool:
59+
return isinstance(value, (int, float)) and not isinstance(value, bool)
60+
61+
62+
def _validate_tool_args(name: str, args: dict[str, Any]) -> dict[str, Any]:
63+
spec = TOOL_ARG_TYPES.get(name)
64+
if spec is None:
65+
raise StopRun(f"invalid_action:unknown_tool:{name}")
66+
67+
extra = set(args.keys()) - set(spec.keys())
68+
if extra:
69+
raise StopRun(f"invalid_action:extra_tool_args:{name}")
70+
71+
normalized: dict[str, Any] = {}
72+
for arg_name, expected in spec.items():
73+
is_optional = expected.endswith("?")
74+
expected_base = expected[:-1] if is_optional else expected
75+
76+
if arg_name not in args:
77+
if is_optional:
78+
continue
79+
raise StopRun(f"invalid_action:missing_required_arg:{name}:{arg_name}")
80+
value = args[arg_name]
81+
82+
if expected_base == "int":
83+
if not isinstance(value, int) or isinstance(value, bool):
84+
raise StopRun(f"invalid_action:bad_arg_type:{name}:{arg_name}")
85+
normalized[arg_name] = value
86+
continue
87+
88+
if expected_base == "number":
89+
if not _is_number(value):
90+
raise StopRun(f"invalid_action:bad_arg_type:{name}:{arg_name}")
91+
normalized[arg_name] = float(value)
92+
continue
93+
94+
if expected_base == "str":
95+
if not isinstance(value, str) or not value.strip():
96+
raise StopRun(f"invalid_action:bad_arg_type:{name}:{arg_name}")
97+
normalized[arg_name] = value.strip()
98+
continue
99+
100+
raise StopRun(f"invalid_action:unknown_arg_spec:{name}:{arg_name}")
101+
102+
return normalized
103+
104+
105+
def validate_worker_action(action: Any) -> dict[str, Any]:
106+
if not isinstance(action, dict):
107+
raise StopRun("invalid_action:not_object")
108+
109+
kind = action.get("kind")
110+
if kind == "invalid":
111+
raise StopRun("invalid_action:non_json")
112+
113+
if kind == "final":
114+
allowed_keys = {"kind", "answer"}
115+
if set(action.keys()) - allowed_keys:
116+
raise StopRun("invalid_action:extra_keys_final")
117+
answer = action.get("answer")
118+
if not isinstance(answer, str) or not answer.strip():
119+
raise StopRun("invalid_action:bad_final_answer")
120+
return {"kind": "final", "answer": answer.strip()}
121+
122+
if kind == "tool":
123+
allowed_keys = {"kind", "name", "args"}
124+
if set(action.keys()) - allowed_keys:
125+
raise StopRun("invalid_action:extra_keys_tool")
126+
127+
name = action.get("name")
128+
if not isinstance(name, str) or not name.strip():
129+
raise StopRun("invalid_action:bad_tool_name")
130+
131+
args = action.get("args", {})
132+
if args is None:
133+
args = {}
134+
if not isinstance(args, dict):
135+
raise StopRun("invalid_action:bad_tool_args")
136+
137+
normalized_args = _validate_tool_args(name.strip(), args)
138+
return {"kind": "tool", "name": name.strip(), "args": normalized_args}
139+
140+
raise StopRun("invalid_action:bad_kind")
141+
142+
143+
class ToolGateway:
144+
def __init__(
145+
self,
146+
*,
147+
allow: set[str],
148+
registry: dict[str, Callable[..., dict[str, Any]]],
149+
budget: Budget,
150+
):
151+
self.allow = set(allow)
152+
self.registry = registry
153+
self.budget = budget
154+
self.tool_calls = 0
155+
self.seen_call_counts: dict[str, int] = {}
156+
self.per_tool_counts: dict[str, int] = {}
157+
self.read_only_repeat_limit: dict[str, int] = {
158+
"get_refund_context": 2,
159+
}
160+
self.per_tool_limit: dict[str, int] = {
161+
"get_refund_context": 3,
162+
"issue_refund": 2,
163+
"send_refund_email": 2,
164+
}
165+
166+
def call(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
167+
self.tool_calls += 1
168+
if self.tool_calls > self.budget.max_tool_calls:
169+
raise StopRun("max_tool_calls")
170+
171+
if name not in self.allow:
172+
raise StopRun(f"tool_denied:{name}")
173+
174+
fn = self.registry.get(name)
175+
if fn is None:
176+
raise StopRun(f"tool_missing:{name}")
177+
178+
count_for_tool = self.per_tool_counts.get(name, 0) + 1
179+
if count_for_tool > self.per_tool_limit.get(name, 2):
180+
raise StopRun("loop_detected:per_tool_limit")
181+
self.per_tool_counts[name] = count_for_tool
182+
183+
signature = f"{name}:{args_hash(args)}"
184+
seen = self.seen_call_counts.get(signature, 0) + 1
185+
allowed_repeats = self.read_only_repeat_limit.get(name, 1)
186+
if seen > allowed_repeats:
187+
raise StopRun("loop_detected:signature_repeat")
188+
self.seen_call_counts[signature] = seen
189+
190+
try:
191+
out = fn(**args)
192+
except TypeError as exc:
193+
raise StopRun(f"tool_bad_args:{name}") from exc
194+
except Exception as exc:
195+
raise StopRun(f"tool_error:{name}") from exc
196+
197+
if not isinstance(out, dict):
198+
raise StopRun(f"tool_bad_result:{name}")
199+
return out

0 commit comments

Comments
 (0)