Skip to content

Commit 8f34496

Browse files
author
AgentPatterns
committed
feat(examples/python): add runnable example
1 parent 35e7e7a commit 8f34496

6 files changed

Lines changed: 434 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
📖 Based on article:
2+
3+
EN:
4+
https://agentpatterns.tech/en/agent-patterns/react-agent
5+
6+
DE:
7+
https://agentpatterns.tech/de/agent-patterns/react-agent
8+
9+
FR:
10+
https://agentpatterns.tech/fr/agent-patterns/react-agent
11+
12+
ES:
13+
https://agentpatterns.tech/es/agent-patterns/react-agent
14+
15+
UK:
16+
https://agentpatterns.tech/uk/agent-patterns/react-agent
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 = 6
19+
max_seconds: int = 20
20+
21+
22+
def _stable_json(value: Any) -> str:
23+
if value is None or isinstance(value, (bool, int, float, str)):
24+
return json.dumps(value, ensure_ascii=True, sort_keys=True)
25+
if isinstance(value, list):
26+
return "[" + ",".join(_stable_json(item) for item in value) + "]"
27+
if isinstance(value, dict):
28+
parts = []
29+
for key in sorted(value):
30+
parts.append(
31+
json.dumps(str(key), ensure_ascii=True) + ":" + _stable_json(value[key])
32+
)
33+
return "{" + ",".join(parts) + "}"
34+
return json.dumps(str(value), ensure_ascii=True)
35+
36+
37+
def args_hash(args: dict[str, Any]) -> str:
38+
raw = _stable_json(args or {})
39+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
40+
41+
42+
def validate_action(action: Any) -> dict[str, Any]:
43+
if not isinstance(action, dict):
44+
raise StopRun("invalid_action:not_object")
45+
46+
kind = action.get("kind")
47+
if kind == "invalid":
48+
raise StopRun("invalid_action:bad_json")
49+
if kind not in {"tool", "final"}:
50+
raise StopRun("invalid_action:bad_kind")
51+
52+
if kind == "final":
53+
allowed = {"kind", "answer"}
54+
extra = set(action.keys()) - allowed
55+
if extra:
56+
raise StopRun("invalid_action:extra_keys")
57+
answer = action.get("answer")
58+
if not isinstance(answer, str) or not answer.strip():
59+
raise StopRun("invalid_action:missing_answer")
60+
return {"kind": "final", "answer": answer.strip()}
61+
62+
allowed = {"kind", "name", "args"}
63+
extra = set(action.keys()) - allowed
64+
if extra:
65+
raise StopRun("invalid_action:extra_keys")
66+
67+
name = action.get("name")
68+
if not isinstance(name, str) or not name:
69+
raise StopRun("invalid_action:missing_tool_name")
70+
71+
args = action.get("args", {})
72+
if args is None:
73+
args = {}
74+
if not isinstance(args, dict):
75+
raise StopRun("invalid_action:bad_args")
76+
77+
return {"kind": "tool", "name": name, "args": args}
78+
79+
80+
class ToolGateway:
81+
def __init__(
82+
self,
83+
*,
84+
allow: set[str],
85+
registry: dict[str, Callable[..., dict[str, Any]]],
86+
budget: Budget,
87+
):
88+
self.allow = set(allow)
89+
self.registry = registry
90+
self.budget = budget
91+
self.tool_calls = 0
92+
self.seen_calls: set[str] = set()
93+
94+
def call(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
95+
self.tool_calls += 1
96+
if self.tool_calls > self.budget.max_tool_calls:
97+
raise StopRun("max_tool_calls")
98+
99+
if name not in self.allow:
100+
raise StopRun(f"tool_denied:{name}")
101+
102+
tool = self.registry.get(name)
103+
if tool is None:
104+
raise StopRun(f"tool_missing:{name}")
105+
106+
signature = f"{name}:{args_hash(args)}"
107+
if signature in self.seen_calls:
108+
raise StopRun("loop_detected")
109+
self.seen_calls.add(signature)
110+
111+
try:
112+
return tool(**args)
113+
except TypeError as exc:
114+
raise StopRun(f"tool_bad_args:{name}") from exc
115+
except Exception as exc:
116+
raise StopRun(f"tool_error:{name}") from exc

examples/react-agent/python/llm.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from typing import Any
6+
7+
from openai import APIConnectionError, APITimeoutError, OpenAI
8+
9+
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
10+
LLM_TIMEOUT_SECONDS = float(os.getenv("OPENAI_TIMEOUT_SECONDS", "60"))
11+
12+
13+
class LLMTimeout(Exception):
14+
pass
15+
16+
SYSTEM_PROMPT = """
17+
You are a ReAct decision engine.
18+
Return only one JSON object with one of these shapes:
19+
1) {"kind":"tool","name":"<tool_name>","args":{...}}
20+
2) {"kind":"final","answer":"<short final answer>"}
21+
22+
Rules:
23+
- Use tools when you do not have enough facts.
24+
- Do not invent tool outputs.
25+
- Prefer the smallest next step.
26+
- When evidence is sufficient, return "final".
27+
- Never output markdown or extra keys.
28+
""".strip()
29+
30+
TOOL_CATALOG = [
31+
{
32+
"name": "get_user_profile",
33+
"description": "Get user profile by user_id",
34+
"args": {"user_id": "integer"},
35+
},
36+
{
37+
"name": "get_user_billing",
38+
"description": "Get billing info by user_id",
39+
"args": {"user_id": "integer"},
40+
},
41+
{
42+
"name": "search_policy",
43+
"description": "Search refund and billing policy snippets",
44+
"args": {"query": "string"},
45+
},
46+
]
47+
48+
49+
def _get_client() -> OpenAI:
50+
api_key = os.getenv("OPENAI_API_KEY")
51+
if not api_key:
52+
raise EnvironmentError(
53+
"OPENAI_API_KEY is not set. Run: export OPENAI_API_KEY='sk-...'"
54+
)
55+
return OpenAI(api_key=api_key)
56+
57+
58+
def _build_state_summary(history: list[dict[str, Any]]) -> dict[str, Any]:
59+
tools_used = [
60+
step.get("action", {}).get("name")
61+
for step in history
62+
if isinstance(step, dict)
63+
and isinstance(step.get("action"), dict)
64+
and step.get("action", {}).get("kind") == "tool"
65+
]
66+
last_observation = history[-1].get("observation") if history else None
67+
return {
68+
"steps_completed": len(history),
69+
"tools_used": tools_used,
70+
"last_observation": last_observation,
71+
}
72+
73+
74+
def decide_next_action(goal: str, history: list[dict[str, Any]]) -> dict[str, Any]:
75+
# Keep full history in memory, but send summary + last N steps
76+
# so the prompt remains stable as runs get longer.
77+
recent_history = history[-3:]
78+
payload = {
79+
"goal": goal,
80+
"state_summary": _build_state_summary(history),
81+
"recent_history": recent_history,
82+
"available_tools": TOOL_CATALOG,
83+
}
84+
85+
client = _get_client()
86+
try:
87+
completion = client.chat.completions.create(
88+
model=MODEL,
89+
temperature=0,
90+
timeout=LLM_TIMEOUT_SECONDS,
91+
response_format={"type": "json_object"},
92+
messages=[
93+
{"role": "system", "content": SYSTEM_PROMPT},
94+
{"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
95+
],
96+
)
97+
except (APITimeoutError, APIConnectionError) as exc:
98+
raise LLMTimeout("llm_timeout") from exc
99+
100+
text = completion.choices[0].message.content or "{}"
101+
try:
102+
return json.loads(text)
103+
except json.JSONDecodeError:
104+
return {"kind": "invalid", "raw": text}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import time
5+
from typing import Any
6+
7+
from gateway import Budget, StopRun, ToolGateway, args_hash, validate_action
8+
from llm import LLMTimeout, decide_next_action
9+
from tools import get_user_billing, get_user_profile, search_policy
10+
11+
GOAL = (
12+
"User 42 asked: Can I get a refund now? "
13+
"Use tools to verify profile, billing state, and policy. "
14+
"Return a short final answer in English with USD amount and reason."
15+
)
16+
17+
BUDGET = Budget(max_steps=8, max_tool_calls=5, max_seconds=20)
18+
19+
TOOL_REGISTRY = {
20+
"get_user_profile": get_user_profile,
21+
"get_user_billing": get_user_billing,
22+
"search_policy": search_policy,
23+
}
24+
25+
ALLOWED_TOOLS = {"get_user_profile", "get_user_billing", "search_policy"}
26+
27+
28+
def run_react(goal: str) -> dict[str, Any]:
29+
started = time.monotonic()
30+
history: list[dict[str, Any]] = []
31+
trace: list[dict[str, Any]] = []
32+
33+
gateway = ToolGateway(allow=ALLOWED_TOOLS, registry=TOOL_REGISTRY, budget=BUDGET)
34+
35+
for step in range(1, BUDGET.max_steps + 1):
36+
elapsed = time.monotonic() - started
37+
if elapsed > BUDGET.max_seconds:
38+
return {
39+
"status": "stopped",
40+
"stop_reason": "max_seconds",
41+
"trace": trace,
42+
"history": history,
43+
}
44+
45+
try:
46+
raw_action = decide_next_action(goal=goal, history=history)
47+
except LLMTimeout:
48+
return {
49+
"status": "stopped",
50+
"stop_reason": "llm_timeout",
51+
"trace": trace,
52+
"history": history,
53+
}
54+
55+
try:
56+
action = validate_action(raw_action)
57+
except StopRun as exc:
58+
return {
59+
"status": "stopped",
60+
"stop_reason": exc.reason,
61+
"raw_action": raw_action,
62+
"trace": trace,
63+
"history": history,
64+
}
65+
66+
if action["kind"] == "final":
67+
return {
68+
"status": "ok",
69+
"stop_reason": "success",
70+
"answer": action["answer"],
71+
"trace": trace,
72+
"history": history,
73+
}
74+
75+
tool_name = action["name"]
76+
tool_args = action["args"]
77+
78+
try:
79+
observation = gateway.call(tool_name, tool_args)
80+
trace.append(
81+
{
82+
"step": step,
83+
"tool": tool_name,
84+
"args_hash": args_hash(tool_args),
85+
"ok": True,
86+
}
87+
)
88+
except StopRun as exc:
89+
trace.append(
90+
{
91+
"step": step,
92+
"tool": tool_name,
93+
"args_hash": args_hash(tool_args),
94+
"ok": False,
95+
"stop_reason": exc.reason,
96+
}
97+
)
98+
return {
99+
"status": "stopped",
100+
"stop_reason": exc.reason,
101+
"trace": trace,
102+
"history": history,
103+
}
104+
105+
history.append(
106+
{
107+
"step": step,
108+
"action": action,
109+
"observation": observation,
110+
}
111+
)
112+
113+
return {
114+
"status": "stopped",
115+
"stop_reason": "max_steps",
116+
"trace": trace,
117+
"history": history,
118+
}
119+
120+
121+
def main() -> None:
122+
result = run_react(GOAL)
123+
print(json.dumps(result, indent=2, ensure_ascii=False))
124+
125+
126+
if __name__ == "__main__":
127+
main()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
openai==2.21.0

0 commit comments

Comments
 (0)