Skip to content

Commit 9883e63

Browse files
author
AgentPatterns
committed
Add production-style support-agent Python example
1 parent 0d8271d commit 9883e63

File tree

7 files changed

+776
-0
lines changed

7 files changed

+776
-0
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Support Agent - Python Implementation
2+
3+
Runnable support-agent example with safe-by-default behavior:
4+
- risk triage before drafting
5+
- no auto-send tool in allowlist
6+
- citation and policy checks
7+
- artifacts + audit trail
8+
- explicit human approval flag
9+
10+
---
11+
12+
## Quick start
13+
14+
```bash
15+
python -m venv .venv && source .venv/bin/activate
16+
pip install -r requirements.txt
17+
18+
# optional for low-risk draft flow
19+
export OPENAI_API_KEY="sk-..."
20+
export OPENAI_MODEL="gpt-4.1-mini"
21+
export OPENAI_TIMEOUT_SECONDS="60"
22+
23+
# default ticket is high-risk (handoff path)
24+
python main.py
25+
26+
# run low-risk drafting path
27+
TICKET_ID=T-1002 python main.py
28+
```
29+
30+
## What this example demonstrates
31+
32+
- Conservative triage (`billing_refund`, `security`, `legal`, `outage`)
33+
- Manual handoff for high-risk tickets
34+
- LLM draft only for non-high-risk tickets
35+
- Draft validation:
36+
- no hard commitments
37+
- required citations for policy-like claims
38+
- No write-side customer send action
39+
40+
## Project layout
41+
42+
```text
43+
python/
44+
README.md
45+
main.py
46+
llm.py
47+
gateway.py
48+
policy.py
49+
tools.py
50+
requirements.txt
51+
```
52+
53+
## Notes
54+
55+
- This example uses in-memory stores for tickets/artifacts/audit.
56+
- Replace in-memory stores with real services in production.
57+
- Human approval is modeled via `requires_human_approval=true` output.
58+
59+
## License
60+
61+
MIT
62+
63+
## Result schema
64+
65+
- status is technical run state: success, blocked, stopped.
66+
- outcome is business result when status=success: handoff or draft_ready.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import json
5+
import time
6+
from dataclasses import dataclass
7+
from typing import Any, Callable
8+
9+
10+
class StopRun(Exception):
11+
def __init__(self, reason: str):
12+
super().__init__(reason)
13+
self.reason = reason
14+
15+
16+
@dataclass(frozen=True)
17+
class Budget:
18+
max_tool_calls: int = 12
19+
max_seconds: int = 30
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+
payload = _stable_json(args or {})
39+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
40+
41+
42+
class ToolGateway:
43+
def __init__(
44+
self,
45+
*,
46+
allow: set[str],
47+
registry: dict[str, Callable[..., dict[str, Any]]],
48+
budget: Budget,
49+
):
50+
self.allow = set(allow)
51+
self.registry = registry
52+
self.budget = budget
53+
self.tool_calls = 0
54+
self.started = time.monotonic()
55+
self.seen_signatures: dict[str, int] = {}
56+
57+
def call(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
58+
elapsed = time.monotonic() - self.started
59+
if elapsed > self.budget.max_seconds:
60+
raise StopRun("max_seconds")
61+
62+
self.tool_calls += 1
63+
if self.tool_calls > self.budget.max_tool_calls:
64+
raise StopRun("max_tool_calls")
65+
66+
if name not in self.allow:
67+
raise StopRun(f"tool_denied:{name}")
68+
69+
signature = f"{name}:{args_hash(args)}"
70+
seen = self.seen_signatures.get(signature, 0) + 1
71+
self.seen_signatures[signature] = seen
72+
if seen > 2:
73+
raise StopRun("loop_detected")
74+
75+
tool = self.registry.get(name)
76+
if tool is None:
77+
raise StopRun(f"tool_missing:{name}")
78+
79+
try:
80+
result = tool(**args)
81+
except TypeError as exc:
82+
raise StopRun(f"tool_bad_args:{name}") from exc
83+
except Exception as exc:
84+
raise StopRun(f"tool_error:{name}") from exc
85+
86+
if isinstance(result, dict) and "error" in result:
87+
raise StopRun(f"tool_result_error:{name}")
88+
89+
return result
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
17+
SYSTEM_PROMPT = """
18+
You are a support drafting assistant.
19+
Return only one JSON object with exactly these keys:
20+
- customer_reply: string
21+
- internal_note: string
22+
- claims: array of {"kind": string, "text": string, "citation_id": string}
23+
- citations: array of {"id": string, "title": string}
24+
25+
Rules:
26+
- Keep customer_reply concise and professional.
27+
- Do not promise refunds, credits, or guaranteed timelines.
28+
- If any policy claim is made, add citation_id.
29+
- Use only citation IDs present in provided policy/kb context.
30+
- Never include secrets.
31+
""".strip()
32+
33+
34+
def _get_client() -> OpenAI:
35+
api_key = os.getenv("OPENAI_API_KEY")
36+
if not api_key:
37+
raise EnvironmentError(
38+
"OPENAI_API_KEY is not set. Run: export OPENAI_API_KEY='sk-...'"
39+
)
40+
return OpenAI(api_key=api_key)
41+
42+
43+
def _as_claims(value: Any) -> list[dict[str, str]]:
44+
claims: list[dict[str, str]] = []
45+
if not isinstance(value, list):
46+
return claims
47+
for item in value:
48+
if not isinstance(item, dict):
49+
continue
50+
claim = {
51+
"kind": str(item.get("kind", "")).strip().lower(),
52+
"text": str(item.get("text", "")).strip(),
53+
"citation_id": str(item.get("citation_id", "")).strip(),
54+
}
55+
claims.append(claim)
56+
return claims
57+
58+
59+
def _as_citations(value: Any) -> list[dict[str, str]]:
60+
citations: list[dict[str, str]] = []
61+
if not isinstance(value, list):
62+
return citations
63+
for item in value:
64+
if not isinstance(item, dict):
65+
continue
66+
citation = {
67+
"id": str(item.get("id", "")).strip(),
68+
"title": str(item.get("title", "")).strip(),
69+
}
70+
if citation["id"]:
71+
citations.append(citation)
72+
return citations
73+
74+
75+
def generate_support_draft(
76+
*,
77+
ticket: dict[str, Any],
78+
customer: dict[str, Any],
79+
kb_matches: list[dict[str, Any]],
80+
policy_matches: list[dict[str, Any]],
81+
) -> dict[str, Any]:
82+
payload = {
83+
"ticket": ticket,
84+
"customer": customer,
85+
"kb_matches": kb_matches,
86+
"policy_matches": policy_matches,
87+
"output_language": ticket.get("language", "en"),
88+
}
89+
90+
client = _get_client()
91+
try:
92+
completion = client.chat.completions.create(
93+
model=MODEL,
94+
temperature=0,
95+
timeout=LLM_TIMEOUT_SECONDS,
96+
response_format={"type": "json_object"},
97+
messages=[
98+
{"role": "system", "content": SYSTEM_PROMPT},
99+
{"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
100+
],
101+
)
102+
except (APITimeoutError, APIConnectionError) as exc:
103+
raise LLMTimeout("llm_timeout") from exc
104+
105+
text = completion.choices[0].message.content or "{}"
106+
try:
107+
data = json.loads(text)
108+
except json.JSONDecodeError:
109+
data = {}
110+
111+
customer_reply = str(data.get("customer_reply", "")).strip()
112+
internal_note = str(data.get("internal_note", "")).strip()
113+
claims = _as_claims(data.get("claims", []))
114+
citations = _as_citations(data.get("citations", []))
115+
116+
if not customer_reply:
117+
customer_reply = (
118+
"Thanks for your message. We are reviewing your request with the support team "
119+
"and will follow up shortly."
120+
)
121+
if not internal_note:
122+
internal_note = "Draft created. Please verify policy claims before approval."
123+
124+
return {
125+
"customer_reply": customer_reply,
126+
"internal_note": internal_note,
127+
"claims": claims,
128+
"citations": citations,
129+
}

0 commit comments

Comments
 (0)