agenthandler 0.92.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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agenthandler
|
|
3
|
+
Version: 0.92.0
|
|
4
|
+
Summary: A way of controlling deployed agents to keep your (and their) sanity.
|
|
5
|
+
Project-URL: Homepage, https://github.com/arunvenkatadri/AgentHandler
|
|
6
|
+
Project-URL: Repository, https://github.com/arunvenkatadri/AgentHandler
|
|
7
|
+
Project-URL: Issues, https://github.com/arunvenkatadri/AgentHandler/issues
|
|
8
|
+
Author: Arun Venkatadri
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,audit,budget,circuit-breaker,supervision
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
28
|
+
Provides-Extra: kafka
|
|
29
|
+
Requires-Dist: aiokafka>=0.10; extra == 'kafka'
|
|
30
|
+
Provides-Extra: kinesis
|
|
31
|
+
Requires-Dist: aioboto3>=12.0; extra == 'kinesis'
|
|
32
|
+
Provides-Extra: redis
|
|
33
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
34
|
+
Provides-Extra: server
|
|
35
|
+
Requires-Dist: aiosqlite>=0.19; extra == 'server'
|
|
36
|
+
Requires-Dist: fastapi>=0.100; extra == 'server'
|
|
37
|
+
Requires-Dist: uvicorn[standard]>=0.20; extra == 'server'
|
|
38
|
+
Provides-Extra: streams
|
|
39
|
+
Requires-Dist: aioboto3>=12.0; extra == 'streams'
|
|
40
|
+
Requires-Dist: aiokafka>=0.10; extra == 'streams'
|
|
41
|
+
Requires-Dist: redis>=5.0; extra == 'streams'
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# AgentHandler
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
<img width="1536" height="1024" alt="image" src="https://github.com/user-attachments/assets/162a4e5e-ccdd-4152-b7e4-4addd2ef5743" />
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
[](https://github.com/arunvenkatadri/AgentHandler/actions/workflows/ci.yml)
|
|
54
|
+
[](https://www.python.org/downloads/)
|
|
55
|
+
[](LICENSE)
|
|
56
|
+
[]()
|
|
57
|
+
[]()
|
|
58
|
+
|
|
59
|
+
**Stop your AI agents from going rogue and set your long acting agents up for success.** AgentHandler wraps tool calls with timeouts, budgets, circuit breakers, and audit logging so one bad tool call doesn't drain your wallet or loop forever.
|
|
60
|
+
|
|
61
|
+
Zero dependencies. Works with **any agent framework** — LangChain, CrewAI, AutoGen, OpenClaw, your own custom loop, whatever. Just wrap your tool calls. Includes durable sessions that survive restarts, a REST control plane, and a [dashboard](#dashboard) for managing everything in one place.
|
|
62
|
+
|
|
63
|
+
> **AgentHandler is built for long-duration agents** — agents that run for hours or days, not minutes. Read [the manifesto](docs/manifesto.md) for the full vision.
|
|
64
|
+
|
|
65
|
+
## Why
|
|
66
|
+
|
|
67
|
+
You deploy an agent. It calls a flaky API in a loop. 47 times. At $0.03 per call. At 3am. Nobody's watching.
|
|
68
|
+
|
|
69
|
+
Or: your agent decides to "keep researching" and burns through your entire token budget in one conversation. Or: a tool hangs for 90 seconds and your user stares at a spinner.
|
|
70
|
+
|
|
71
|
+
AgentHandler is the guardrail layer between your agent and its tools. Every tool call goes through supervision — timeouts, budgets, circuit breakers — so you can deploy agents without white-knuckling it.
|
|
72
|
+
|
|
73
|
+
<p align="center">
|
|
74
|
+
<img src="docs/architecture.svg" alt="AgentHandler architecture — agent → supervision layer → tools" width="900">
|
|
75
|
+
</p>
|
|
76
|
+
|
|
77
|
+
### No arbitrary code downloads
|
|
78
|
+
|
|
79
|
+
Most agent platforms want you to install skills from a community marketplace — arbitrary code that runs unsandboxed in your agent's process. One bad plugin and your agent has full access to your filesystem, credentials, and network.
|
|
80
|
+
|
|
81
|
+
AgentHandler takes a different approach. **You define skills and agents declaratively** — what tools are available, what parameters they accept, what supervision limits apply. No downloading stranger code. No hoping someone reviewed that community plugin before you installed it. You control exactly what your agents can do, and every tool call goes through supervision whether you built it or someone else did.
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install agenthandler
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import asyncio
|
|
93
|
+
from agenthandler import Supervisor, Policy
|
|
94
|
+
|
|
95
|
+
# Define limits
|
|
96
|
+
policy = Policy(
|
|
97
|
+
max_iterations=5, # max tool call rounds
|
|
98
|
+
tool_timeout=15.0, # seconds per tool call
|
|
99
|
+
token_budget=10_000, # total token cap
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
supervisor = Supervisor(policy)
|
|
103
|
+
|
|
104
|
+
# Your tool function (any async callable)
|
|
105
|
+
async def web_search(query: str) -> str:
|
|
106
|
+
# ... your implementation ...
|
|
107
|
+
return f"Results for: {query}"
|
|
108
|
+
|
|
109
|
+
async def main():
|
|
110
|
+
# Supervise a tool call
|
|
111
|
+
result = await supervisor.call("web_search", web_search, query="python async")
|
|
112
|
+
|
|
113
|
+
if result.succeeded:
|
|
114
|
+
print(result.output)
|
|
115
|
+
else:
|
|
116
|
+
print(f"Failed: {result.error.user_message()}")
|
|
117
|
+
|
|
118
|
+
# Track token usage from your LLM calls
|
|
119
|
+
supervisor.record_tokens(1500)
|
|
120
|
+
|
|
121
|
+
# Finish and get final stats
|
|
122
|
+
snapshot = supervisor.finish()
|
|
123
|
+
print(f"Used {snapshot.tokens_used} tokens in {snapshot.iterations} iterations")
|
|
124
|
+
|
|
125
|
+
asyncio.run(main())
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Dashboard
|
|
129
|
+
|
|
130
|
+
Open `dashboard.html` in a browser for a full management UI:
|
|
131
|
+
|
|
132
|
+
- **Agents** — create, edit, deploy, and manage agents. Each agent has a system prompt, task, assigned skills, policies, budget overrides, and an optional POML pipeline. Agents persist to localStorage across refreshes.
|
|
133
|
+
- **LLM-powered agent builder** — describe what you want in plain English. Claude generates a system prompt, picks the right skills and policies, sets a schedule, suggests budgets, and creates a multi-step pipeline — all auto-applied. Hit "Refine" to iterate on the prompt.
|
|
134
|
+
- **Live chat** — open a conversation with any agent directly from the dashboard. Messages go through a real LLM with full agenthandler supervision (budgets, iterations, timeouts, circuit breakers). Tool calls are visible in the chat.
|
|
135
|
+
- **Deploy** — click Deploy on an agent card to create a supervised session and auto-run the agent's task. The chat panel opens and shows the agent working.
|
|
136
|
+
- **Skills** — define reusable capabilities (tool collections) and assign them to agents
|
|
137
|
+
- **Pipeline editor** — visual step builder with drag-to-reorder, or describe the pipeline in natural language and let Claude generate the POML. Toggle between visual and raw POML views.
|
|
138
|
+
- **Budget overrides** — set token budget, dollar budget, max iterations, and tool timeout directly on each agent. Values override assigned policies. Live cost estimates update as you type.
|
|
139
|
+
- **Sessions** — live view of running agent sessions from the REST API, with pause/resume/stop controls, budget usage, and audit logs. Updates in real time via WebSocket.
|
|
140
|
+
- **Approvals** — pending tool approvals show as yellow cards with Approve/Deny buttons
|
|
141
|
+
- **Costs** — track spend, set budgets with hard stops, view usage over time
|
|
142
|
+
- **Policies** — see all active supervision rules at a glance
|
|
143
|
+
|
|
144
|
+
The Sessions tab connects to the REST control plane (`uvicorn agenthandler.server:app`) and shows real-time session data. Agent cards show live status and controls work against the real API. The LLM features (prompt generation, chat, pipeline generation) require a backend with an LLM API key — use [Artemis](https://github.com/arunvenkatadri/artemis) or provide your own `/chat` and `/generate-prompt` endpoints.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
<img width="1383" height="1270" alt="Sdd" src="https://github.com/user-attachments/assets/b4fec9fa-59d4-4476-93ac-19684c835ab0" />
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
## What it does
|
|
152
|
+
|
|
153
|
+
| Feature | What it prevents |
|
|
154
|
+
|---------|-----------------|
|
|
155
|
+
| **Tool timeout** | A single tool call hanging forever |
|
|
156
|
+
| **Token budget** | Runaway LLM costs blowing through your budget |
|
|
157
|
+
| **Iteration limit** | Agent loops that never converge |
|
|
158
|
+
| **Circuit breaker** | Hammering a tool that keeps failing |
|
|
159
|
+
| **Dead man's switch** | Agent going silent (no activity for N seconds) |
|
|
160
|
+
| **Confirmation gates** | Dangerous tools running without human approval |
|
|
161
|
+
| **Audit log** | Not knowing what your agent did |
|
|
162
|
+
| **Privacy shielding** | PII leaking through tool calls to LLMs or APIs |
|
|
163
|
+
| **Observability + cost attribution** | No visibility into tool latency, error rates, or real dollar cost per agent/session/model |
|
|
164
|
+
| **Context compression** | Agent forgetting its instructions mid-conversation |
|
|
165
|
+
| **Tool scoping** | Agents calling tools with dangerous arguments |
|
|
166
|
+
| **Durable sessions** | Agent dies mid-task, loses all progress |
|
|
167
|
+
| **Crash loop protection** | Agent keeps crashing and restarting forever |
|
|
168
|
+
| **16 guardrails** | No layered safety — rule-based + LLM-backed checks for every tool call |
|
|
169
|
+
| **Reflection loops** | Agents looping without thinking about whether they're making progress |
|
|
170
|
+
| **Session replay** | No way to reconstruct what an agent did after the fact |
|
|
171
|
+
| **Streaming inputs** | No way to react to real-time events (Kafka/Kinesis/Redis) |
|
|
172
|
+
| **Triggers & scheduling** | No way to run agents on schedule or on data events |
|
|
173
|
+
| **Pipeline orchestration** | No structured way to chain tool calls |
|
|
174
|
+
| **REST control plane** | No way to pause/resume/stop agents remotely |
|
|
175
|
+
|
|
176
|
+
## Works with any agent framework
|
|
177
|
+
|
|
178
|
+
AgentHandler doesn't care what framework you use. It supervises tool calls — any async or sync callable. If your agent calls tools, AgentHandler can wrap them.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# LangChain? Wrap your tools.
|
|
182
|
+
# CrewAI? Wrap your tools.
|
|
183
|
+
# AutoGen? Wrap your tools.
|
|
184
|
+
# Custom loop? Wrap your tools.
|
|
185
|
+
# OpenClaw? There's a built-in bridge (see below).
|
|
186
|
+
|
|
187
|
+
result = await supervisor.call("my_tool", my_tool_fn, **kwargs)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Tool scoping
|
|
191
|
+
|
|
192
|
+
Scopes are policies that validate tool arguments before execution. Instead of just "can this agent call write_file?" you control "can this agent call write_file *with this path*?"
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
policy = Policy.from_dict({
|
|
196
|
+
"tool_scopes": [
|
|
197
|
+
{
|
|
198
|
+
"tool_name": "write_file",
|
|
199
|
+
"rules": [
|
|
200
|
+
{"param": "path", "constraint": "allow", "values": ["/tmp/*", "/home/user/output/*"]},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"tool_name": "send_email",
|
|
205
|
+
"rules": [
|
|
206
|
+
{"param": "recipient", "constraint": "allow", "values": ["*@mycompany.com"]},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"tool_name": "web_search",
|
|
211
|
+
"rules": [
|
|
212
|
+
{"param": "query", "constraint": "block", "values": ["internal", "confidential"]},
|
|
213
|
+
],
|
|
214
|
+
"max_calls_per_minute": 10,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Scopes are just another type of policy. Assign them to agents the same way — a scope-only policy has zero supervision fields and one or more tool scope rules. Stack them with budget and rate-limit policies on the same agent.
|
|
221
|
+
|
|
222
|
+
Constraint types:
|
|
223
|
+
- **allow** — parameter must match at least one pattern (glob with `*` and `?`)
|
|
224
|
+
- **block** — parameter must NOT match any pattern
|
|
225
|
+
- **match** — parameter must match at least one regex
|
|
226
|
+
|
|
227
|
+
## Context compression
|
|
228
|
+
|
|
229
|
+
Long-running agents drift. They forget their original instructions, blow past constraints, and hallucinate prior context. AgentHandler solves this with **supervision-anchored compression** — a novel approach that only works because you have a supervision layer.
|
|
230
|
+
|
|
231
|
+
The key insight: supervised agents can compress more aggressively than unsupervised ones. Safety-critical state (policies, budgets, circuit breakers) is enforced by the supervisor, not by the LLM's memory. So you never need to keep that in context — it's reconstructed fresh every turn.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from agenthandler import Supervisor, Policy
|
|
235
|
+
from agenthandler.context import ContextWindow
|
|
236
|
+
|
|
237
|
+
supervisor = Supervisor(Policy(max_iterations=10, token_budget=10000))
|
|
238
|
+
ctx = ContextWindow(supervisor, max_recent_turns=10, skill_name="research")
|
|
239
|
+
|
|
240
|
+
# Add turns as the conversation progresses
|
|
241
|
+
ctx.add_turn("user", "Search for quantum computing breakthroughs")
|
|
242
|
+
ctx.add_turn("assistant", "Searching...", tool_calls=[...])
|
|
243
|
+
|
|
244
|
+
# Get the full context for your LLM prompt
|
|
245
|
+
prompt = ctx.to_prompt()
|
|
246
|
+
|
|
247
|
+
# When context gets long, compress older turns
|
|
248
|
+
result = await ctx.compress()
|
|
249
|
+
prompt = result.to_prompt()
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The context has three zones:
|
|
253
|
+
|
|
254
|
+
| Zone | What it contains | Fidelity |
|
|
255
|
+
|------|-----------------|----------|
|
|
256
|
+
| **Anchored state** | Budget, circuit breakers, policies, confirmed tools | Always fresh from supervisor |
|
|
257
|
+
| **Compressed history** | Older turns summarized by tool calls and snippets | Lossy but safe |
|
|
258
|
+
| **Recent turns** | Last N turns | Full fidelity |
|
|
259
|
+
|
|
260
|
+
The anchored state is never compressed — it's rebuilt from the supervisor's live state every turn. Even if the LLM "forgets" its budget limit, the supervisor still enforces it. Bring your own LLM summarizer for richer compression, or use the built-in audit-based extraction.
|
|
261
|
+
|
|
262
|
+
## Pipeline orchestration
|
|
263
|
+
|
|
264
|
+
Chain tool calls into supervised sequential pipelines. Output flows from one step to the next — every step goes through full AgentHandler supervision (timeouts, budgets, circuit breakers, PII redaction, approvals).
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
from agenthandler import Pipeline, SessionManager, MemoryStore
|
|
268
|
+
|
|
269
|
+
store = MemoryStore()
|
|
270
|
+
mgr = SessionManager(store)
|
|
271
|
+
|
|
272
|
+
result = await (
|
|
273
|
+
Pipeline(mgr, "researcher", policy_dict={"max_iterations": 10, "token_budget": 50000})
|
|
274
|
+
.add_step("web_search", search_fn, query="quantum computing")
|
|
275
|
+
.add_step("summarize", summarize_fn)
|
|
276
|
+
.add_step("write_report", write_fn, title="Research Report")
|
|
277
|
+
.run()
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
print(result.completed) # True
|
|
281
|
+
print(result.final_output) # The report
|
|
282
|
+
print(result.total_duration_ms)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Pipelines stop early if a step fails, the budget is exceeded, or a tool needs approval. Each step is recorded in the observer, audit trail, and session payload.
|
|
286
|
+
|
|
287
|
+
### Build pipelines from natural language
|
|
288
|
+
|
|
289
|
+
Describe what you want in plain English. The LLM generates POML, which gets parsed into an executable pipeline.
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from agenthandler import PipelineBuilder
|
|
293
|
+
|
|
294
|
+
# Auto-discover schema so the LLM writes correct SQL
|
|
295
|
+
schema = await sql.discover_schema()
|
|
296
|
+
builder = PipelineBuilder(my_llm, available_tools, mgr, db_schema=schema)
|
|
297
|
+
|
|
298
|
+
pipeline = await builder.create(
|
|
299
|
+
"Check the alerts table. If there are more than 10 critical alerts, "
|
|
300
|
+
"escalate to the on-call team. Otherwise log that everything is fine."
|
|
301
|
+
)
|
|
302
|
+
result = await pipeline.run()
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The intermediate POML is human-readable, editable, and version-controllable. Condition expressions use an AST-based evaluator (no `eval()`) — safe against injection even with untrusted POML inputs:
|
|
306
|
+
|
|
307
|
+
```xml
|
|
308
|
+
<pipeline>
|
|
309
|
+
<step tool="sql.query">
|
|
310
|
+
SELECT count(*) as cnt FROM alerts WHERE severity='critical'
|
|
311
|
+
</step>
|
|
312
|
+
<threshold key="rows.0.cnt" value="10">
|
|
313
|
+
<above tool="escalate">Notify on-call team</above>
|
|
314
|
+
<below tool="log_ok">Log all clear</below>
|
|
315
|
+
</threshold>
|
|
316
|
+
</pipeline>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
You can also generate just the POML for review, then parse and run it:
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
poml = await builder.create_poml("Check alerts and escalate if critical")
|
|
323
|
+
# Review/edit the POML...
|
|
324
|
+
pipeline = parse_pipeline_poml(poml, tools, mgr, "agent-1", policy)
|
|
325
|
+
result = await pipeline.run()
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Refine with feedback:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
updated_poml = await builder.refine(poml, "Add an email notification step at the end")
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Conditional routing
|
|
335
|
+
|
|
336
|
+
Add branching logic — route to different steps based on the previous output:
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
result = await (
|
|
340
|
+
Pipeline(mgr, "monitor", policy)
|
|
341
|
+
.add_step("check_alerts", sql.query, sql="SELECT count(*) as cnt FROM alerts")
|
|
342
|
+
.add_condition(
|
|
343
|
+
lambda output: output["rows"][0]["cnt"] > 10,
|
|
344
|
+
then_step=("escalate", escalate_fn),
|
|
345
|
+
else_step=("log_ok", log_fn),
|
|
346
|
+
)
|
|
347
|
+
.run()
|
|
348
|
+
)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Or use the threshold shorthand:
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
pipeline.add_threshold(
|
|
355
|
+
key="rows.0.cnt", # dot-notation path into previous output
|
|
356
|
+
threshold=10,
|
|
357
|
+
above_step=("escalate", escalate_fn),
|
|
358
|
+
below_step=("log_ok", log_fn), # or None to skip
|
|
359
|
+
)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Database connectors
|
|
363
|
+
|
|
364
|
+
Query SQL and NoSQL databases as supervised tool calls. Results flow through PII redaction, audit logging, and observability like any other tool.
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
from agenthandler.connectors import SqlConnector
|
|
368
|
+
|
|
369
|
+
sql = SqlConnector("sqlite:///data.db") # also: postgresql://, mysql://
|
|
370
|
+
|
|
371
|
+
# In a pipeline
|
|
372
|
+
result = await (
|
|
373
|
+
Pipeline(mgr, "analyst", policy)
|
|
374
|
+
.add_step("get_orders", sql.query, sql="SELECT * FROM orders")
|
|
375
|
+
.add_step("analyze", analyze_fn)
|
|
376
|
+
.add_step("report", write_fn)
|
|
377
|
+
.run()
|
|
378
|
+
)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Read-only by default — `SqlConnector("...", allow_write=True)` to enable writes. Supports parameterized queries and configurable `max_rows`.
|
|
382
|
+
|
|
383
|
+
Also ships `MongoConnector` for MongoDB (`find`, `insert`).
|
|
384
|
+
|
|
385
|
+
### Schema auto-discovery
|
|
386
|
+
|
|
387
|
+
Connectors introspect the database and expose the schema to the LLM so it writes correct SQL:
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
sql = SqlConnector("sqlite:///data.db")
|
|
391
|
+
schema = await sql.discover_schema()
|
|
392
|
+
# {"tables": [{"name": "orders", "columns": [{"name": "id", "type": "INTEGER", "pk": true}, ...], "row_count": 1500}]}
|
|
393
|
+
|
|
394
|
+
# Human-readable format for prompts
|
|
395
|
+
print(sql.schema_to_text(schema))
|
|
396
|
+
# Table: orders (1500 rows)
|
|
397
|
+
# - id INTEGER (PK)
|
|
398
|
+
# - product TEXT
|
|
399
|
+
# - amount REAL
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
When using `PipelineBuilder`, pass the schema and the LLM sees the actual tables:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
schema = await sql.discover_schema()
|
|
406
|
+
builder = PipelineBuilder(my_llm, tools, mgr, db_schema=schema)
|
|
407
|
+
pipeline = await builder.create("Show me total revenue by region")
|
|
408
|
+
# LLM writes: SELECT region, sum(amount) FROM orders GROUP BY region
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The REST endpoint `GET /pipelines/schema` returns the schema for all registered connectors. The dashboard shows it in the pipeline editor.
|
|
412
|
+
|
|
413
|
+
## Reflection loops (agents that actually think)
|
|
414
|
+
|
|
415
|
+
Long-duration agents don't just loop `run()` forever. They think → act → observe → reflect → think again. Each phase is its own LLM call, each phase is audited, and the reflection output feeds into the next think phase.
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
from agenthandler import ReflectionLoop, SessionManager, MemoryStore
|
|
419
|
+
|
|
420
|
+
mgr = SessionManager(MemoryStore())
|
|
421
|
+
|
|
422
|
+
loop = ReflectionLoop(
|
|
423
|
+
manager=mgr,
|
|
424
|
+
agent_id="research-agent",
|
|
425
|
+
goal="Write a report on quantum computing in 2026",
|
|
426
|
+
llm=my_llm,
|
|
427
|
+
tools={"search": search_fn, "take_notes": notes_fn, "write": write_fn},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
result = await loop.run(max_cycles=20)
|
|
431
|
+
print(result.final_answer)
|
|
432
|
+
print(result.cycles_used)
|
|
433
|
+
for c in result.cycles:
|
|
434
|
+
print(f" {c.thought} → {c.tool_called} → {c.reflection}")
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Each cycle is fully supervised. Guardrails apply. Crashes are recoverable. Every reflection is in the audit log. This is what distinguishes "agent ran for 72 hours doing useful work" from "agent ran in circles for 72 hours."
|
|
438
|
+
|
|
439
|
+
See `examples/long_running_agent.py` for a full reference implementation.
|
|
440
|
+
|
|
441
|
+
## Session replay
|
|
442
|
+
|
|
443
|
+
Every session's audit log is a complete record of what the agent did. `SessionReplay` reconstructs the timeline step by step — every tool call, every decision, every guardrail check, every state change.
|
|
444
|
+
|
|
445
|
+
```python
|
|
446
|
+
from agenthandler import SessionReplay
|
|
447
|
+
|
|
448
|
+
replay = SessionReplay(mgr, session_id)
|
|
449
|
+
summary = replay.summary()
|
|
450
|
+
print(f"{summary.total_tool_calls} calls over {summary.duration_ms}ms")
|
|
451
|
+
print(f"Unique tools: {summary.unique_tools}")
|
|
452
|
+
|
|
453
|
+
# Walk through the timeline
|
|
454
|
+
for frame in replay.frames():
|
|
455
|
+
print(frame.summary)
|
|
456
|
+
|
|
457
|
+
# Jump to a specific point
|
|
458
|
+
state_at_cycle_10 = replay.state_at(frame_index=10)
|
|
459
|
+
|
|
460
|
+
# Filter to just tool calls
|
|
461
|
+
tool_frames = replay.filter(phase="tool_call", outcome="allowed")
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Available via REST at `GET /sessions/{id}/replay`. No new storage — it's built from the audit log you already have.
|
|
465
|
+
|
|
466
|
+
## LLM decisions (agents that actually decide)
|
|
467
|
+
|
|
468
|
+
Rule-based threshold routing is fine for known conditions. For agents that need to *interpret* input and decide what to do, use `add_llm_decision()` — a pipeline step that calls your LLM with a prompt template, parses structured JSON output, and feeds it to the next step for routing.
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
pipeline = (
|
|
472
|
+
Pipeline(mgr, "fraud-detector", policy)
|
|
473
|
+
.add_step("enrich", enrich_fn)
|
|
474
|
+
.add_llm_decision(
|
|
475
|
+
my_llm,
|
|
476
|
+
prompt_template="""
|
|
477
|
+
Transaction: {input}
|
|
478
|
+
Historical context: {context}
|
|
479
|
+
|
|
480
|
+
Decide the action. Return JSON:
|
|
481
|
+
{"action": "block|approve|escalate", "reason": "..."}
|
|
482
|
+
""",
|
|
483
|
+
include_context=True, # passes session payload as {context}
|
|
484
|
+
)
|
|
485
|
+
.add_condition(
|
|
486
|
+
lambda out: out["action"] == "block",
|
|
487
|
+
then_step=("block", block_fn),
|
|
488
|
+
else_step=("approve", approve_fn),
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
await pipeline.run(initial_input=stream_message)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
The LLM sees the previous step's output as `{input}` and (optionally) the session payload as `{context}`. It returns structured JSON, which the pipeline routes on. Bring your own LLM — same pattern as `SkillBuilder`.
|
|
496
|
+
|
|
497
|
+
## Stateful context with with_context()
|
|
498
|
+
|
|
499
|
+
Stream agents that need to see history — the last N messages, running averages, user state — can reuse a single session across multiple pipeline runs. The session payload accumulates automatically.
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
# Start a long-lived session
|
|
503
|
+
sid = mgr.start("stream-agent", policy)
|
|
504
|
+
|
|
505
|
+
# Each stream message runs through the pipeline with shared context
|
|
506
|
+
async def on_message(msg):
|
|
507
|
+
return await (
|
|
508
|
+
Pipeline(mgr, "stream-agent", policy)
|
|
509
|
+
.with_context(sid) # reuse session, don't auto-stop
|
|
510
|
+
.add_step("enrich", enrich_fn)
|
|
511
|
+
.add_llm_decision(my_llm, prompt, include_context=True)
|
|
512
|
+
.add_condition(...)
|
|
513
|
+
.run(initial_input=msg) # stream message flows into first step
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
kafka = KafkaStreamConnector(..., on_message=on_message)
|
|
517
|
+
await kafka.start()
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
The pipeline keeps a rolling history (last 20 runs) in `session.payload["history"]`, visible to LLM decision steps via `{context}`. Each message's decision builds on the accumulated state.
|
|
521
|
+
|
|
522
|
+
## Streaming inputs
|
|
523
|
+
|
|
524
|
+
Subscribe agents to real-time event streams. Each message fires the agent pipeline with the message payload as input.
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
from agenthandler import KafkaStreamConnector
|
|
528
|
+
|
|
529
|
+
async def on_message(payload):
|
|
530
|
+
# Run pipeline with the message as input
|
|
531
|
+
return await pipeline.run()
|
|
532
|
+
|
|
533
|
+
kafka = KafkaStreamConnector(
|
|
534
|
+
bootstrap_servers="localhost:9092",
|
|
535
|
+
topic="agent-events",
|
|
536
|
+
group_id="my-agent",
|
|
537
|
+
on_message=on_message,
|
|
538
|
+
)
|
|
539
|
+
await kafka.start()
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Three streaming backends — install only what you need:
|
|
543
|
+
|
|
544
|
+
| Connector | Install | Use for |
|
|
545
|
+
|-----------|---------|---------|
|
|
546
|
+
| `KafkaStreamConnector` | `pip install agenthandler[kafka]` | Self-hosted, enterprise, replay, consumer groups |
|
|
547
|
+
| `KinesisStreamConnector` | `pip install agenthandler[kinesis]` | AWS-native event streams |
|
|
548
|
+
| `RedisStreamConnector` | `pip install agenthandler[redis]` | Simpler deployments, already-have-Redis |
|
|
549
|
+
|
|
550
|
+
Or install all three: `pip install agenthandler[streams]`
|
|
551
|
+
|
|
552
|
+
All three use at-least-once delivery — messages are acknowledged only after the handler completes successfully. If the agent crashes mid-processing, the message is redelivered.
|
|
553
|
+
|
|
554
|
+
## Triggers and scheduling
|
|
555
|
+
|
|
556
|
+
Run pipelines on a schedule, on webhook, or when data changes.
|
|
557
|
+
|
|
558
|
+
```python
|
|
559
|
+
from agenthandler import Scheduler, WebhookTrigger
|
|
560
|
+
|
|
561
|
+
scheduler = Scheduler()
|
|
562
|
+
|
|
563
|
+
# Every 15 minutes
|
|
564
|
+
scheduler.add_interval("check-alerts", pipeline.run, minutes=15)
|
|
565
|
+
|
|
566
|
+
# Cron schedule (every day at 9am)
|
|
567
|
+
scheduler.add_cron("morning-report", pipeline.run, cron="0 9 * * *")
|
|
568
|
+
|
|
569
|
+
# When a database condition fires
|
|
570
|
+
scheduler.add_db_watch(
|
|
571
|
+
"new-orders",
|
|
572
|
+
pipeline.run,
|
|
573
|
+
connector=sql,
|
|
574
|
+
query="SELECT count(*) as cnt FROM orders WHERE processed=false",
|
|
575
|
+
condition=lambda r: r["rows"][0]["cnt"] > 0,
|
|
576
|
+
poll_seconds=60,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
scheduler.start() # runs in background thread
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Webhooks fire when an HTTP endpoint is called:
|
|
583
|
+
|
|
584
|
+
```bash
|
|
585
|
+
curl -X POST http://localhost:8000/triggers/deploy-notify/fire
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
REST endpoints:
|
|
589
|
+
|
|
590
|
+
| Method | Path | Description |
|
|
591
|
+
|--------|------|-------------|
|
|
592
|
+
| GET | `/triggers` | List all triggers |
|
|
593
|
+
| GET | `/triggers/history` | Trigger firing history |
|
|
594
|
+
| POST | `/triggers/{name}/fire` | Fire a webhook trigger |
|
|
595
|
+
| POST | `/triggers/{name}/enable` | Enable a trigger |
|
|
596
|
+
| POST | `/triggers/{name}/disable` | Disable a trigger |
|
|
597
|
+
| DELETE | `/triggers/{name}` | Remove a trigger |
|
|
598
|
+
|
|
599
|
+
The dashboard shows trigger mode selection in the agent creation flow — on-demand, interval, cron, webhook, or DB watch.
|
|
600
|
+
|
|
601
|
+
## Durable sessions
|
|
602
|
+
|
|
603
|
+
Long-running agents survive process restarts. AgentHandler checkpoints supervisor state (iterations, token usage, circuit breaker states) to a pluggable store after every operation.
|
|
604
|
+
|
|
605
|
+
```python
|
|
606
|
+
from agenthandler import SessionManager, SqliteStore
|
|
607
|
+
|
|
608
|
+
store = SqliteStore("sessions.db") # or MemoryStore() for testing
|
|
609
|
+
mgr = SessionManager(store)
|
|
610
|
+
|
|
611
|
+
# Start a session
|
|
612
|
+
session_id = mgr.start("my-agent", {
|
|
613
|
+
"max_iterations": 20,
|
|
614
|
+
"tool_timeout": 30,
|
|
615
|
+
"token_budget": 50000,
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
# Get the supervisor and use it in your agent loop
|
|
619
|
+
sv = mgr.get_supervisor(session_id)
|
|
620
|
+
result = await sv.call("web_search", search_fn, query="python async")
|
|
621
|
+
sv.record_tokens(1500)
|
|
622
|
+
|
|
623
|
+
# Pause — the supervisor raises AgentPaused on next call()
|
|
624
|
+
mgr.pause(session_id)
|
|
625
|
+
|
|
626
|
+
# Resume later (even after a crash / restart)
|
|
627
|
+
sv = mgr.resume(session_id) # budget, iterations, circuit breakers all restored
|
|
628
|
+
result = await sv.call("web_search", search_fn, query="continue research")
|
|
629
|
+
|
|
630
|
+
# Stop when done
|
|
631
|
+
mgr.stop(session_id)
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### Pluggable storage
|
|
635
|
+
|
|
636
|
+
Implement the `StateStore` protocol for your own backend:
|
|
637
|
+
|
|
638
|
+
```python
|
|
639
|
+
from agenthandler import StateStore, Checkpoint
|
|
640
|
+
|
|
641
|
+
class RedisStore:
|
|
642
|
+
def save_checkpoint(self, checkpoint: Checkpoint) -> None: ...
|
|
643
|
+
def load_checkpoint(self, session_id: str) -> Checkpoint | None: ...
|
|
644
|
+
def list_sessions(self) -> list[Checkpoint]: ...
|
|
645
|
+
def delete_session(self, session_id: str) -> bool: ...
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Ships with `SqliteStore` (production default, stdlib sqlite3) and `MemoryStore` (testing).
|
|
649
|
+
|
|
650
|
+
### Crash loop protection
|
|
651
|
+
|
|
652
|
+
If an agent keeps crashing and being resumed, `max_resumes` stops it from looping forever. Default is 3 — after that, the session is marked as `failed` with a reason.
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
session_id = mgr.start("my-agent", {
|
|
656
|
+
"max_iterations": 10,
|
|
657
|
+
"max_resumes": 5, # allow up to 5 crash recoveries (default: 3)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
# After 5 crashes and resumes, the next resume() raises:
|
|
661
|
+
# AgentHandlerError("max_resumes_exceeded", "Crash loop: resumed 5 times (limit: 5)")
|
|
662
|
+
# Session is automatically marked as FAILED.
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Failed sessions can be restarted fresh (same policy, new session):
|
|
666
|
+
|
|
667
|
+
```python
|
|
668
|
+
new_session_id = mgr.restart(session_id) # or POST /sessions/{id}/restart
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
The dashboard shows the failure reason on failed/stopped session cards and offers a Restart button.
|
|
672
|
+
|
|
673
|
+
### Stateless sessions
|
|
674
|
+
|
|
675
|
+
Not every agent needs crash recovery. Pass `stateless=True` to skip auto-checkpointing — you still get supervision, audit logging, and pause/stop controls, but no disk writes on each tool call and no crash recovery.
|
|
676
|
+
|
|
677
|
+
```python
|
|
678
|
+
# In-memory only — no checkpoints, no crash recovery
|
|
679
|
+
session_id = mgr.start("quick-agent", policy_dict, stateless=True)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Via the REST API:
|
|
683
|
+
|
|
684
|
+
```bash
|
|
685
|
+
curl -X POST http://localhost:8000/sessions \
|
|
686
|
+
-H "Content-Type: application/json" \
|
|
687
|
+
-d '{"agent_id": "quick-agent", "policy": {...}, "stateless": true}'
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Stateless sessions can be paused and stopped normally. They cannot be resumed after a process restart — if the process dies, the session is gone.
|
|
691
|
+
|
|
692
|
+
## REST control plane
|
|
693
|
+
|
|
694
|
+
Manage agent sessions remotely over HTTP. Optional dependency — install with:
|
|
695
|
+
|
|
696
|
+
```bash
|
|
697
|
+
pip install agenthandler[server]
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Start the server:
|
|
701
|
+
|
|
702
|
+
```bash
|
|
703
|
+
uvicorn agenthandler.server:app
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
With API key auth:
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
# Pick any string as your secret — there's no signup or external service
|
|
710
|
+
AGENTHANDLER_API_KEY=my-secret-key uvicorn agenthandler.server:app
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Clients pass the key as a Bearer token:
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
curl -H "Authorization: Bearer my-secret-key" http://localhost:8000/sessions
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
WebSocket connections pass it as a query param: `ws://localhost:8000/sessions/{id}/events?token=my-secret-key`
|
|
720
|
+
|
|
721
|
+
The default `uvicorn agenthandler.server:app` rejects all requests unless `AGENTHANDLER_API_KEY` or OAuth is configured. For local dev without auth, create the app in code: `create_app()` (no `require_auth`).
|
|
722
|
+
|
|
723
|
+
### OAuth2 (GitHub, Google)
|
|
724
|
+
|
|
725
|
+
For production deployments, configure OAuth2 via environment variables:
|
|
726
|
+
|
|
727
|
+
```bash
|
|
728
|
+
AGENTHANDLER_OAUTH_PROVIDER=github \
|
|
729
|
+
AGENTHANDLER_OAUTH_CLIENT_ID=your-client-id \
|
|
730
|
+
AGENTHANDLER_OAUTH_CLIENT_SECRET=your-client-secret \
|
|
731
|
+
uvicorn agenthandler.server:app
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
This adds `/auth/login` (get the OAuth redirect URL), `/auth/callback` (exchange code for session token), and `/auth/me` (current user info). Session tokens are Bearer tokens — use them the same way as API keys. Supported providers: `github`, `google`.
|
|
735
|
+
|
|
736
|
+
Endpoints:
|
|
737
|
+
|
|
738
|
+
| Method | Path | Description |
|
|
739
|
+
|--------|------|-------------|
|
|
740
|
+
| POST | `/sessions` | Create a new agent session |
|
|
741
|
+
| GET | `/sessions` | List all sessions with status |
|
|
742
|
+
| GET | `/sessions/{id}` | Session detail (budget, checkpoint time, policy) |
|
|
743
|
+
| POST | `/sessions/{id}/pause` | Pause an agent |
|
|
744
|
+
| POST | `/sessions/{id}/resume` | Resume a paused agent |
|
|
745
|
+
| POST | `/sessions/{id}/stop` | Stop an agent |
|
|
746
|
+
| POST | `/sessions/{id}/restart` | Restart a stopped/failed agent (new session, same policy) |
|
|
747
|
+
| GET | `/sessions/{id}/audit` | Audit log entries for this session |
|
|
748
|
+
| GET | `/sessions/{id}/approvals` | List pending/resolved approval requests |
|
|
749
|
+
| POST | `/sessions/{id}/approvals/{aid}/approve` | Approve a pending tool call |
|
|
750
|
+
| POST | `/sessions/{id}/approvals/{aid}/deny` | Deny a pending tool call |
|
|
751
|
+
| GET | `/metrics/tools` | Aggregated metrics for all tools |
|
|
752
|
+
| GET | `/metrics/sessions/{id}` | Metrics for a specific session |
|
|
753
|
+
| GET | `/metrics/recent` | Recent tool call log |
|
|
754
|
+
| WS | `/sessions/{id}/events` | Stream status changes, audit, and approvals |
|
|
755
|
+
| GET | `/pipelines/tools` | List available tools for pipeline building |
|
|
756
|
+
| GET | `/pipelines/schema` | Auto-discovered database schema |
|
|
757
|
+
| POST | `/pipelines/generate` | Natural language → POML (requires LLM) |
|
|
758
|
+
| POST | `/pipelines/validate` | Validate POML and return step structure |
|
|
759
|
+
| POST | `/pipelines/run` | Parse POML and run pipeline immediately |
|
|
760
|
+
| GET | `/auth/login` | OAuth2 login redirect URL |
|
|
761
|
+
| GET | `/auth/callback` | OAuth2 callback (exchange code for token) |
|
|
762
|
+
| GET | `/auth/me` | Current authenticated user info |
|
|
763
|
+
|
|
764
|
+
```bash
|
|
765
|
+
# Create a session
|
|
766
|
+
curl -X POST http://localhost:8000/sessions \
|
|
767
|
+
-H "Content-Type: application/json" \
|
|
768
|
+
-d '{"agent_id": "researcher", "policy": {"max_iterations": 10, "token_budget": 50000}}'
|
|
769
|
+
|
|
770
|
+
# Pause it
|
|
771
|
+
curl -X POST http://localhost:8000/sessions/{id}/pause
|
|
772
|
+
|
|
773
|
+
# Check status
|
|
774
|
+
curl http://localhost:8000/sessions/{id}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
## Security model
|
|
778
|
+
|
|
779
|
+
AgentHandler is designed to supervise untrusted agent behavior. The stateful session layer enforces several invariants:
|
|
780
|
+
|
|
781
|
+
**Policy is immutable and integrity-checked.** The supervision policy is frozen at `start()` and cannot be changed by the agent. An HMAC checksum is stored alongside the policy — on `resume()`, the checksum is verified and the session is rejected if it doesn't match. Even if someone edits the SQLite file directly, they can't downgrade supervision without the HMAC key (configurable via `AGENTHANDLER_POLICY_KEY`).
|
|
782
|
+
|
|
783
|
+
**Payload is untrusted.** The `payload` field is agent-writable storage for intermediate work. It is validated for size (1 MB limit) and serializability, but its *contents* should be treated like user input. If your agent reads from payload after a resume, sanitize it.
|
|
784
|
+
|
|
785
|
+
**Session IDs are cryptographic.** 128-bit random IDs via `secrets.token_hex` — not guessable or enumerable.
|
|
786
|
+
|
|
787
|
+
**The REST API supports API key and OAuth2 auth.** Set `AGENTHANDLER_API_KEY` for simple Bearer token auth, or configure OAuth2 (GitHub/Google) for production. The default `uvicorn agenthandler.server:app` rejects all requests unless auth is configured. CORS is restricted to localhost by default.
|
|
788
|
+
|
|
789
|
+
**SQLite files are owner-only.** The default `SqliteStore` creates database files with `0600` permissions.
|
|
790
|
+
|
|
791
|
+
**Audit logs survive crashes.** Entries are persisted to the checkpoint store on `pause()` and `stop()`, so you don't lose the trail if the process dies.
|
|
792
|
+
|
|
793
|
+
**Sessions can expire.** Call `SqliteStore.delete_expired(max_age_seconds)` to clean up old sessions.
|
|
794
|
+
|
|
795
|
+
## OpenClaw integration
|
|
796
|
+
|
|
797
|
+
AgentHandler includes a built-in bridge for [OpenClaw](https://github.com/openclaw/openclaw). Expose your supervised tools to OpenClaw over HTTP — all supervision (timeouts, budgets, circuit breakers) applies automatically.
|
|
798
|
+
|
|
799
|
+
```python
|
|
800
|
+
from agenthandler import OpenClawBridge, Skill, ToolDefinition, ToolParameter
|
|
801
|
+
|
|
802
|
+
# Define your skill with tools and supervision limits
|
|
803
|
+
skill = Skill(
|
|
804
|
+
name="web_research",
|
|
805
|
+
description="Research topics on the web",
|
|
806
|
+
tools=[
|
|
807
|
+
ToolDefinition(
|
|
808
|
+
name="web_search",
|
|
809
|
+
description="Search the web",
|
|
810
|
+
parameters=[
|
|
811
|
+
ToolParameter(name="query", type="string",
|
|
812
|
+
description="Search query", required=True),
|
|
813
|
+
],
|
|
814
|
+
),
|
|
815
|
+
],
|
|
816
|
+
supervision={"tool_timeout": 15, "max_iterations": 5, "token_budget": 10000},
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# Start the bridge
|
|
820
|
+
bridge = OpenClawBridge(port=9229)
|
|
821
|
+
bridge.register_skill(skill, {"web_search": my_search_fn})
|
|
822
|
+
bridge.serve() # GET /tools, POST /execute/{name}
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Then install the TypeScript plugin from `openclaw-plugin/` into OpenClaw. The plugin auto-discovers tools from the bridge and registers them. See `examples/openclaw_bridge.py` for a full working example.
|
|
826
|
+
|
|
827
|
+
You can also convert schemas without running a bridge:
|
|
828
|
+
|
|
829
|
+
```python
|
|
830
|
+
from agenthandler import to_openclaw_tool_schema, to_openclaw_manifest
|
|
831
|
+
|
|
832
|
+
schema = to_openclaw_tool_schema(tool_def) # OpenClaw JSON Schema format
|
|
833
|
+
manifest = to_openclaw_manifest(skill) # openclaw.plugin.json content
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
## Policy from config
|
|
837
|
+
|
|
838
|
+
Load policy from a dictionary (YAML, JSON, database — whatever you use):
|
|
839
|
+
|
|
840
|
+
```python
|
|
841
|
+
policy = Policy.from_dict({
|
|
842
|
+
"max_iterations": 10,
|
|
843
|
+
"tool_timeout": 30,
|
|
844
|
+
"token_budget": 50000,
|
|
845
|
+
"on_timeout": "return_error",
|
|
846
|
+
"on_budget_exceeded": "respond_with_best_effort",
|
|
847
|
+
"require_confirm": ["delete_file", "send_email"],
|
|
848
|
+
})
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
## Sync support
|
|
852
|
+
|
|
853
|
+
No event loop? No problem:
|
|
854
|
+
|
|
855
|
+
```python
|
|
856
|
+
result = supervisor.call_sync("calculator", my_sync_fn, x=42)
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
## Audit logging
|
|
860
|
+
|
|
861
|
+
Every supervised action is recorded. Write to JSONL, stdout, or implement your own sink:
|
|
862
|
+
|
|
863
|
+
```python
|
|
864
|
+
from agenthandler import Supervisor, Policy, AuditLog, JsonlAuditSink
|
|
865
|
+
|
|
866
|
+
# Log to file
|
|
867
|
+
sink = JsonlAuditSink.file("audit.jsonl")
|
|
868
|
+
audit = AuditLog("request-123", [sink])
|
|
869
|
+
supervisor = Supervisor(policy, audit)
|
|
870
|
+
|
|
871
|
+
# Or log to stdout
|
|
872
|
+
sink = JsonlAuditSink.stdout()
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
Custom sink — implement the `AuditSink` interface:
|
|
876
|
+
|
|
877
|
+
```python
|
|
878
|
+
from agenthandler import AuditSink, AuditEntry
|
|
879
|
+
|
|
880
|
+
class MyDatabaseSink(AuditSink):
|
|
881
|
+
def write(self, entry: AuditEntry) -> None:
|
|
882
|
+
db.insert(entry.to_dict())
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
## Guardrails
|
|
886
|
+
|
|
887
|
+
AgentHandler ships **16 guardrails** that hook into every supervised tool call. Eight are deterministic (rule-based, fast, zero-overhead when disabled). Eight are LLM-backed (bring-your-own-LLM, opt-in).
|
|
888
|
+
|
|
889
|
+
```python
|
|
890
|
+
from agenthandler import (
|
|
891
|
+
SessionManager, UrlGuard, SchemaValidator, RecursionDetector,
|
|
892
|
+
PromptInjectionDetector, IntentDriftDetector,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
mgr = SessionManager(
|
|
896
|
+
store,
|
|
897
|
+
pre_guardrails=[
|
|
898
|
+
UrlGuard(allowlist=["*.mycompany.com"]),
|
|
899
|
+
RecursionDetector(max_repeats=3),
|
|
900
|
+
PromptInjectionDetector(llm=my_llm),
|
|
901
|
+
IntentDriftDetector(llm=my_llm),
|
|
902
|
+
],
|
|
903
|
+
post_guardrails=[
|
|
904
|
+
SchemaValidator({"search": {"type": "object", "required": ["rows"]}}),
|
|
905
|
+
],
|
|
906
|
+
)
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
### Deterministic (rule-based)
|
|
910
|
+
|
|
911
|
+
| Guardrail | What it does |
|
|
912
|
+
|-----------|-------------|
|
|
913
|
+
| `SchemaValidator` | Enforce JSON schema on tool outputs — catches broken tools, corrupted data, hallucinations |
|
|
914
|
+
| `CategoryRateLimit` | Rate limit across tool categories (e.g., all "network" tools combined ≤ 100/min) |
|
|
915
|
+
| `RecursionDetector` | Detect tool call loops — block if same (tool, args) repeats within a window |
|
|
916
|
+
| `IdempotencyGuard` | Cache results of mutating tool calls (send_email, charge_card) to dedupe retries |
|
|
917
|
+
| `ResourceQuota` | CPU time and memory caps per session |
|
|
918
|
+
| `OutputLengthLimit` | Block outputs exceeding size limit — prevents context blowup attacks |
|
|
919
|
+
| `UrlGuard` | Allowlist / blocklist URLs and domains for web tools |
|
|
920
|
+
| `ActiveHours` | Time-of-day and day-of-week restrictions (compliance) |
|
|
921
|
+
|
|
922
|
+
### LLM-backed (bring-your-own-LLM)
|
|
923
|
+
|
|
924
|
+
| Guardrail | What it does |
|
|
925
|
+
|-----------|-------------|
|
|
926
|
+
| `PromptInjectionDetector` | Classifier for jailbreak attempts, instruction overrides, role hijacks |
|
|
927
|
+
| `SafetyClassifier` | Scan outputs for toxic content, hate, harassment, bias, illegal activities |
|
|
928
|
+
| `IntentDriftDetector` | Compare action to original task — flag agents that wandered off |
|
|
929
|
+
| `SemanticPiiRedactor` | NLP-based PII detection (spaCy NER) — catches names, locations, orgs that regex misses |
|
|
930
|
+
| `AnomalyScorer` | Score tool calls against session history — flag unusual behavior |
|
|
931
|
+
| `GoalVerifier` | Before risky actions, verify alignment with stated goal |
|
|
932
|
+
| `ExplanationRequired` | Force the agent to generate a written justification for risky calls (audit log) |
|
|
933
|
+
| `EnsembleDecision` | Require multiple LLMs to agree before allowing critical actions |
|
|
934
|
+
|
|
935
|
+
### How guardrails work
|
|
936
|
+
|
|
937
|
+
- **Pre-call guardrails** run before the tool executes. Can block the call or replace the result (e.g., `IdempotencyGuard` returns cached output).
|
|
938
|
+
- **Post-call guardrails** run after the tool returns. Can block the output from reaching the agent, or modify it (e.g., `SemanticPiiRedactor` replaces with redacted text).
|
|
939
|
+
- All guardrails return a `GuardrailResult(allowed, reason, replacement_output)` — uniform interface.
|
|
940
|
+
- LLM-backed guardrails use the same bring-your-own-LLM pattern as `SkillBuilder` and `PipelineBuilder`.
|
|
941
|
+
- Everything is opt-in. No guardrails configured = zero overhead.
|
|
942
|
+
|
|
943
|
+
## Privacy shielding (PII redaction)
|
|
944
|
+
|
|
945
|
+
Automatically detect and mask sensitive data flowing through tool calls. Configure which categories to redact in the policy:
|
|
946
|
+
|
|
947
|
+
```python
|
|
948
|
+
policy = Policy(
|
|
949
|
+
redact=["email", "phone", "ssn", "api_key", "credit_card", "ip_address"],
|
|
950
|
+
redact_direction="both", # "inbound", "outbound", or "both"
|
|
951
|
+
)
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
**Outbound:** Before a tool executes, args are scanned and PII is replaced with placeholders (`[EMAIL]`, `[PHONE]`, etc.) so sensitive data never reaches external services.
|
|
955
|
+
|
|
956
|
+
**Inbound:** After a tool returns, output is scanned before the agent sees it — preventing PII from web scrapes or API responses from leaking into the LLM context.
|
|
957
|
+
|
|
958
|
+
```python
|
|
959
|
+
# Standalone usage
|
|
960
|
+
from agenthandler import Redactor
|
|
961
|
+
|
|
962
|
+
r = Redactor(categories=["email", "phone"])
|
|
963
|
+
result = r.redact("Contact bob@example.com or call 555-123-4567")
|
|
964
|
+
print(result.text) # "Contact [EMAIL] or call [PHONE]"
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
Categories: `email`, `phone`, `ssn`, `credit_card`, `api_key`, `ip_address`, `national_id`, `iban`, `passport`. Pass `redact=None` to enable all.
|
|
968
|
+
|
|
969
|
+
Includes international patterns — UK/EU phone numbers, UK National Insurance Numbers, German Tax IDs, IBANs, and passport numbers.
|
|
970
|
+
|
|
971
|
+
For context-dependent PII (names, addresses, organizations), enable optional NLP augmentation:
|
|
972
|
+
|
|
973
|
+
```python
|
|
974
|
+
r = Redactor(use_nlp=True) # requires: pip install spacy && python -m spacy download en_core_web_sm
|
|
975
|
+
result = r.redact("John Smith lives in London")
|
|
976
|
+
# "John Smith" → [PERSON], "London" → [LOCATION]
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
Regex is always-on (fast, zero deps). NLP is opt-in (slower, needs spaCy).
|
|
980
|
+
|
|
981
|
+
## Confirmation gates (human-in-the-loop approval)
|
|
982
|
+
|
|
983
|
+
Mark tools as requiring human approval before execution. When an agent tries to call a confirmed tool, the call is queued instead of blocked — a human can review and approve or deny it via the dashboard, REST API, or any WebSocket client.
|
|
984
|
+
|
|
985
|
+
```python
|
|
986
|
+
policy = Policy(
|
|
987
|
+
require_confirm=["delete_file", "send_email"],
|
|
988
|
+
)
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
When the agent calls `delete_file`, instead of executing:
|
|
992
|
+
|
|
993
|
+
1. The Supervisor returns `SupervisedResult(error=AgentHandlerError("approval_pending"))` with an `approval_id`
|
|
994
|
+
2. The approval appears in the dashboard (yellow notification) and on the WebSocket events stream
|
|
995
|
+
3. A human reviews and clicks Approve or Deny (or calls the REST endpoint)
|
|
996
|
+
4. The agent calls `sv.execute_approved(approval_id, fn)` to run the approved tool
|
|
997
|
+
|
|
998
|
+
```python
|
|
999
|
+
# Agent calls a dangerous tool
|
|
1000
|
+
result = await sv.call("delete_file", delete_fn, path="/data")
|
|
1001
|
+
if result.error and result.error.kind == "approval_pending":
|
|
1002
|
+
approval_id = result.error.details["approval_id"]
|
|
1003
|
+
# ... wait for human to approve via dashboard/API ...
|
|
1004
|
+
result = await sv.execute_approved(approval_id, delete_fn)
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
REST endpoints:
|
|
1008
|
+
|
|
1009
|
+
| Method | Path | Description |
|
|
1010
|
+
|--------|------|-------------|
|
|
1011
|
+
| GET | `/sessions/{id}/approvals` | List all approvals for a session |
|
|
1012
|
+
| POST | `/sessions/{id}/approvals/{aid}/approve` | Approve a pending tool call |
|
|
1013
|
+
| POST | `/sessions/{id}/approvals/{aid}/deny` | Deny (with optional reason) |
|
|
1014
|
+
|
|
1015
|
+
WebSocket clients receive `{"type": "approval_required", "data": {...}}` events for real-time notifications. This is the hook for Slack/email integrations — listen on the WebSocket and forward to your messaging tool.
|
|
1016
|
+
|
|
1017
|
+
## Circuit breaker
|
|
1018
|
+
|
|
1019
|
+
Per-tool circuit breakers stop your agent from hammering a broken tool:
|
|
1020
|
+
|
|
1021
|
+
```
|
|
1022
|
+
CLOSED -> failures < threshold, calls pass through
|
|
1023
|
+
OPEN -> failures >= threshold, calls blocked
|
|
1024
|
+
HALF_OPEN -> after reset period, allow one test call
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
```python
|
|
1028
|
+
policy = Policy(
|
|
1029
|
+
circuit_breaker_threshold=3, # open after 3 consecutive failures
|
|
1030
|
+
circuit_breaker_reset=60.0, # try again after 60 seconds
|
|
1031
|
+
)
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
## Failure handlers
|
|
1035
|
+
|
|
1036
|
+
Control what happens when limits are hit:
|
|
1037
|
+
|
|
1038
|
+
```python
|
|
1039
|
+
from agenthandler import Policy, OnFailure, Action
|
|
1040
|
+
|
|
1041
|
+
policy = Policy(
|
|
1042
|
+
on_timeout=OnFailure(Action.RETURN_ERROR),
|
|
1043
|
+
on_budget_exceeded=OnFailure(Action.RESPOND_WITH_BEST_EFFORT),
|
|
1044
|
+
on_max_iterations=OnFailure(Action.RETURN_ERROR, retries=2),
|
|
1045
|
+
)
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Actions:
|
|
1049
|
+
- `RETURN_ERROR` — stop and return the error
|
|
1050
|
+
- `RESPOND_WITH_BEST_EFFORT` — return what you have so far
|
|
1051
|
+
- `KILL` — hard stop, no graceful handling
|
|
1052
|
+
|
|
1053
|
+
## Skill Builder
|
|
1054
|
+
|
|
1055
|
+
Create skills from natural language. Bring your own LLM — pass any async function that takes a prompt and returns text.
|
|
1056
|
+
|
|
1057
|
+
```python
|
|
1058
|
+
from agenthandler import SkillBuilder, SkillStore
|
|
1059
|
+
|
|
1060
|
+
# Bring your own LLM (OpenAI, Anthropic, local, whatever)
|
|
1061
|
+
async def my_llm(prompt: str) -> str:
|
|
1062
|
+
response = await openai.chat.completions.create(
|
|
1063
|
+
model="gpt-4", messages=[{"role": "user", "content": prompt}]
|
|
1064
|
+
)
|
|
1065
|
+
return response.choices[0].message.content
|
|
1066
|
+
|
|
1067
|
+
builder = SkillBuilder(my_llm)
|
|
1068
|
+
|
|
1069
|
+
# Describe what you want in plain English
|
|
1070
|
+
skill = await builder.create(
|
|
1071
|
+
"A skill that researches topics on the web, limited to 5 searches, "
|
|
1072
|
+
"with a 30 second timeout, and asks before deleting anything"
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
# Inspect what was generated
|
|
1076
|
+
print(skill.name) # "web_research"
|
|
1077
|
+
print(skill.supervision) # {"max_iterations": 5, "tool_timeout": 30, ...}
|
|
1078
|
+
print(skill.instructions) # ["Always cite sources", ...]
|
|
1079
|
+
|
|
1080
|
+
# Refine it with feedback
|
|
1081
|
+
skill = await builder.refine(skill, "Add a rule about preferring recent sources")
|
|
1082
|
+
|
|
1083
|
+
# Save it
|
|
1084
|
+
store = SkillStore("~/.agenthandler/skills")
|
|
1085
|
+
store.save(skill)
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
### Managing skills
|
|
1089
|
+
|
|
1090
|
+
```python
|
|
1091
|
+
store = SkillStore("~/.agenthandler/skills")
|
|
1092
|
+
|
|
1093
|
+
# List all skills
|
|
1094
|
+
for s in store.list():
|
|
1095
|
+
print(f"{s['name']}: {s['description']}")
|
|
1096
|
+
|
|
1097
|
+
# Load a full skill
|
|
1098
|
+
skill = store.get("web_research")
|
|
1099
|
+
|
|
1100
|
+
# Delete
|
|
1101
|
+
store.delete("web_research")
|
|
1102
|
+
|
|
1103
|
+
# Export to POML format (for frameworks that use it)
|
|
1104
|
+
poml_text = store.export_poml("web_research")
|
|
1105
|
+
|
|
1106
|
+
# Export all skills as .poml files
|
|
1107
|
+
store.export_all_poml("./poml_output/")
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
### Skill format
|
|
1111
|
+
<img width="1509" height="944" alt="Screenshot 2026-03-25 at 6 06 28 PM" src="https://github.com/user-attachments/assets/e1e28e88-0ce5-41fb-b0a6-32d16dd59f86" />
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
Skills are stored as JSON and can be exported to POML. The format includes:
|
|
1115
|
+
|
|
1116
|
+
- **name, description, triggers** — identity and activation
|
|
1117
|
+
- **role, task, instructions, examples** — what the agent should do
|
|
1118
|
+
- **tools** — what tools are available (with parameter schemas)
|
|
1119
|
+
- **supervision** — agenthandler limits (maps directly to `Policy.from_dict()`)
|
|
1120
|
+
|
|
1121
|
+
## API
|
|
1122
|
+
|
|
1123
|
+
### `Policy`
|
|
1124
|
+
|
|
1125
|
+
Dataclass with all configuration. Every field has a sensible default.
|
|
1126
|
+
|
|
1127
|
+
### `Supervisor(policy, audit=None, store=None, session_id=None, agent_id=None)`
|
|
1128
|
+
|
|
1129
|
+
- `call(tool_name, fn, **kwargs)` — supervise an async tool call
|
|
1130
|
+
- `call_sync(tool_name, fn, **kwargs)` — supervise a sync tool call
|
|
1131
|
+
- `execute_approved(approval_id, fn)` — run a previously-approved tool call
|
|
1132
|
+
- `record_iteration()` — record an agent loop iteration
|
|
1133
|
+
- `record_tokens(n)` — record token usage
|
|
1134
|
+
- `budget()` — get current `BudgetSnapshot`
|
|
1135
|
+
- `finish()` — mark request complete, return final snapshot
|
|
1136
|
+
- `to_checkpoint_data()` — export state for persistence
|
|
1137
|
+
- `restore_from_checkpoint(checkpoint)` — rebuild from a checkpoint
|
|
1138
|
+
|
|
1139
|
+
### `SupervisedResult`
|
|
1140
|
+
|
|
1141
|
+
- `output` — the tool's return value (if succeeded)
|
|
1142
|
+
- `error` — `AgentHandlerError` (if failed)
|
|
1143
|
+
- `succeeded` — bool
|
|
1144
|
+
- `duration_ms` — how long the call took
|
|
1145
|
+
- `budget` — `BudgetSnapshot` at time of completion
|
|
1146
|
+
- `user_message()` — always returns a string (output or error message)
|
|
1147
|
+
|
|
1148
|
+
### `OpenClawBridge(policy, audit, host, port)`
|
|
1149
|
+
|
|
1150
|
+
- `register_tool(tool, fn)` — register a tool with its async callable
|
|
1151
|
+
- `register_skill(skill, tool_impls)` — register all tools from a skill
|
|
1152
|
+
- `serve()` — start the bridge (blocking)
|
|
1153
|
+
- `serve_background()` — start the bridge in a background thread
|
|
1154
|
+
- `shutdown()` — stop the bridge
|
|
1155
|
+
|
|
1156
|
+
### `SkillBuilder(llm)`
|
|
1157
|
+
|
|
1158
|
+
- `create(description)` — generate a skill from natural language
|
|
1159
|
+
- `refine(skill, feedback)` — modify a skill with natural language feedback
|
|
1160
|
+
|
|
1161
|
+
### `SkillStore(directory)`
|
|
1162
|
+
|
|
1163
|
+
- `save(skill)` — save a skill to disk
|
|
1164
|
+
- `get(name)` — load a skill by name
|
|
1165
|
+
- `list()` — list all skills (name + description)
|
|
1166
|
+
- `delete(name)` — delete a skill
|
|
1167
|
+
- `export_poml(name)` — export a skill as POML text
|
|
1168
|
+
- `export_all_poml(output_dir)` — export all skills as `.poml` files
|
|
1169
|
+
|
|
1170
|
+
### `ToolScope`
|
|
1171
|
+
|
|
1172
|
+
- `tool_name` — name of the tool this scope applies to
|
|
1173
|
+
- `rules` — list of `ScopeRule` objects
|
|
1174
|
+
- `max_calls_per_minute` — optional rate limit for this specific tool
|
|
1175
|
+
|
|
1176
|
+
### `ScopeRule`
|
|
1177
|
+
|
|
1178
|
+
- `param` — parameter name to constrain
|
|
1179
|
+
- `constraint` — `"allow"`, `"block"`, or `"match"`
|
|
1180
|
+
- `values` — list of patterns or regexes to check against
|
|
1181
|
+
|
|
1182
|
+
### `Skill`
|
|
1183
|
+
|
|
1184
|
+
- `to_dict()` / `from_dict(d)` — serialize/deserialize
|
|
1185
|
+
- `to_poml()` — render as POML format
|
|
1186
|
+
- `to_json()` — serialize to JSON string
|
|
1187
|
+
|
|
1188
|
+
### `SessionManager(store)`
|
|
1189
|
+
|
|
1190
|
+
- `start(agent_id, policy_dict, payload, stateless=False)` — create a new session, returns `session_id`
|
|
1191
|
+
- `pause(session_id)` — pause (supervisor raises `AgentPaused` on next call)
|
|
1192
|
+
- `resume(session_id)` — rehydrate supervisor from last checkpoint
|
|
1193
|
+
- `stop(session_id)` — stop and finalize
|
|
1194
|
+
- `restart(session_id)` — restart a stopped/failed session (new session, same policy)
|
|
1195
|
+
- `status(session_id)` — get current checkpoint
|
|
1196
|
+
- `list_sessions()` — list all sessions
|
|
1197
|
+
- `get_supervisor(session_id)` — get the active supervisor
|
|
1198
|
+
- `get_audit_entries(session_id)` — get audit log entries
|
|
1199
|
+
- `update_payload(session_id, payload)` — update opaque agent payload
|
|
1200
|
+
|
|
1201
|
+
### `StateStore` (protocol)
|
|
1202
|
+
|
|
1203
|
+
- `save_checkpoint(checkpoint)` — persist a checkpoint
|
|
1204
|
+
- `load_checkpoint(session_id)` — load by ID (returns `None` if missing)
|
|
1205
|
+
- `list_sessions()` — list all checkpoints
|
|
1206
|
+
- `delete_session(session_id)` — delete a checkpoint
|
|
1207
|
+
|
|
1208
|
+
Implementations: `SqliteStore(db_path)`, `MemoryStore()`
|
|
1209
|
+
|
|
1210
|
+
### `Redactor(categories=None, use_nlp=False)`
|
|
1211
|
+
|
|
1212
|
+
- `redact(text)` — redact PII from a string, returns `RedactionResult`
|
|
1213
|
+
- `redact_dict(d)` — redact all string values in a dict
|
|
1214
|
+
|
|
1215
|
+
### `Observer(otlp_endpoint=None)`
|
|
1216
|
+
|
|
1217
|
+
- `record_tool_call(tool_name, ...)` — record a tool call observation
|
|
1218
|
+
- `tool_summary(tool_name)` — aggregated metrics for one tool
|
|
1219
|
+
- `session_summary(session_id)` — aggregated metrics for one session
|
|
1220
|
+
- `all_tools_summary()` — metrics for all tools
|
|
1221
|
+
- `recent_calls(limit=50)` — recent call log
|
|
1222
|
+
|
|
1223
|
+
### `ApprovalQueue()`
|
|
1224
|
+
|
|
1225
|
+
- `submit(tool_name, tool_args, session_id)` — queue a tool call for approval
|
|
1226
|
+
- `approve(approval_id)` — approve a pending request
|
|
1227
|
+
- `deny(approval_id, reason="")` — deny a pending request
|
|
1228
|
+
- `list_pending(session_id=None)` — list pending approvals
|
|
1229
|
+
- `list_all(session_id=None)` — list all approvals
|
|
1230
|
+
|
|
1231
|
+
### `Pipeline(manager, agent_id, policy_dict=None)`
|
|
1232
|
+
|
|
1233
|
+
- `add_step(tool_name, fn, **kwargs)` — add a step (returns self for chaining)
|
|
1234
|
+
- `add_condition(predicate, then_step, else_step)` — conditional branch
|
|
1235
|
+
- `add_threshold(key, threshold, above_step, below_step)` — threshold branch
|
|
1236
|
+
- `add_llm_decision(llm, prompt_template, include_context=False)` — LLM-backed decision step that returns structured JSON
|
|
1237
|
+
- `with_context(session_id)` — reuse an existing session for stateful multi-run agents
|
|
1238
|
+
- `run(initial_input=None)` — execute the pipeline, optionally seeded with an initial input (e.g., a stream message)
|
|
1239
|
+
|
|
1240
|
+
### `PipelineBuilder(llm, tools, manager, db_schema=None)`
|
|
1241
|
+
|
|
1242
|
+
- `create(description)` — natural language to executable Pipeline
|
|
1243
|
+
- `create_poml(description)` — natural language to POML text (for review)
|
|
1244
|
+
- `refine(current_poml, feedback)` — modify POML with natural language feedback
|
|
1245
|
+
|
|
1246
|
+
### `parse_pipeline_poml(poml_text, tools, manager, agent_id, policy_dict)`
|
|
1247
|
+
|
|
1248
|
+
Parses a POML document with `<pipeline>` tags into a Pipeline object.
|
|
1249
|
+
|
|
1250
|
+
### `SqlConnector(connection_string, allow_write=False, max_rows=1000)`
|
|
1251
|
+
|
|
1252
|
+
- `query(sql, params)` — execute a query, returns `{"rows": [...], "row_count": N}`
|
|
1253
|
+
- `execute(sql, params)` — execute a write statement
|
|
1254
|
+
- `discover_schema()` — introspect tables, columns, types, row counts
|
|
1255
|
+
- `schema_to_text(schema)` — format schema as human-readable text
|
|
1256
|
+
- Supports: `sqlite:///path`, `postgresql://...`, `mysql://...`
|
|
1257
|
+
|
|
1258
|
+
### `MongoConnector(uri, database, allow_write=False)`
|
|
1259
|
+
|
|
1260
|
+
- `find(collection, filter, projection, sort, limit)` — query documents
|
|
1261
|
+
- `insert(collection, documents)` — insert documents (requires `allow_write`)
|
|
1262
|
+
- `discover_schema()` — sample documents to infer field structures
|
|
1263
|
+
|
|
1264
|
+
### `PipelineResult`
|
|
1265
|
+
|
|
1266
|
+
- `completed` — True if all steps succeeded
|
|
1267
|
+
- `steps` — list of `StepResult` objects
|
|
1268
|
+
- `final_output` — output of the last successful step
|
|
1269
|
+
- `total_duration_ms` — sum of all step durations
|
|
1270
|
+
- `stopped_at` — step name where pipeline stopped (on failure)
|
|
1271
|
+
- `error` — error description (on failure)
|
|
1272
|
+
|
|
1273
|
+
## Observability and cost attribution
|
|
1274
|
+
|
|
1275
|
+
Structured telemetry for agent behavior — latency, success rates, token counts, **dollar cost**, per tool, per session, per agent, per model. Optional OpenTelemetry export for Datadog, Grafana, Honeycomb, etc.
|
|
1276
|
+
|
|
1277
|
+
```python
|
|
1278
|
+
from agenthandler import Observer, PricingTable
|
|
1279
|
+
|
|
1280
|
+
# PricingTable.default() ships with common models
|
|
1281
|
+
obs = Observer(pricing=PricingTable.default())
|
|
1282
|
+
|
|
1283
|
+
obs.record_tool_call(
|
|
1284
|
+
"llm_call",
|
|
1285
|
+
duration_ms=120,
|
|
1286
|
+
input_tokens=1000,
|
|
1287
|
+
output_tokens=500,
|
|
1288
|
+
model="claude-sonnet-4-6",
|
|
1289
|
+
)
|
|
1290
|
+
print(obs.tool_summary("llm_call"))
|
|
1291
|
+
# {"calls": 1, "total_cost_usd": 0.0105, ...}
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
When using `SessionManager`, the observer is wired in automatically with default pricing — every tool call is recorded. Tools that report `{"input_tokens": N, "output_tokens": M, "model": "..."}` in their output get real dollar attribution:
|
|
1295
|
+
|
|
1296
|
+
```python
|
|
1297
|
+
mgr = SessionManager(store) # defaults to PricingTable.default()
|
|
1298
|
+
|
|
1299
|
+
async def llm_call(prompt: str) -> dict:
|
|
1300
|
+
# Your LLM wrapper
|
|
1301
|
+
response = await client.chat(...)
|
|
1302
|
+
return {
|
|
1303
|
+
"result": response.text,
|
|
1304
|
+
"input_tokens": response.usage.input_tokens,
|
|
1305
|
+
"output_tokens": response.usage.output_tokens,
|
|
1306
|
+
"model": "claude-sonnet-4-6",
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
# Cost attribution just works
|
|
1310
|
+
await sv.call("llm_call", llm_call, prompt="...")
|
|
1311
|
+
mgr.observer.cost_summary()
|
|
1312
|
+
# {"total_cost_usd": 0.0105, "by_agent": [...], "by_model": [...]}
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
Built-in pricing for Anthropic (Claude Opus/Sonnet/Haiku), OpenAI (GPT-4o, GPT-4o-mini, GPT-4 Turbo), and Google (Gemini Pro/Flash). Add your own via `pricing.set_model("my-model", 1.0, 2.0)`.
|
|
1316
|
+
|
|
1317
|
+
The dashboard **Costs tab** reads from this data live — real spend by agent, session, tool, and model, updated as your agents run.
|
|
1318
|
+
|
|
1319
|
+
REST endpoints:
|
|
1320
|
+
|
|
1321
|
+
| Method | Path | Description |
|
|
1322
|
+
|--------|------|-------------|
|
|
1323
|
+
| GET | `/metrics/tools` | Aggregated metrics for all tools |
|
|
1324
|
+
| GET | `/metrics/sessions/{id}` | Metrics for a specific session |
|
|
1325
|
+
| GET | `/metrics/recent` | Recent tool call log |
|
|
1326
|
+
| GET | `/metrics/costs` | Full cost breakdown (by agent, session, tool, model) |
|
|
1327
|
+
| GET | `/metrics/pricing` | Current pricing table |
|
|
1328
|
+
|
|
1329
|
+
OpenTelemetry export (optional — requires `opentelemetry-sdk`):
|
|
1330
|
+
|
|
1331
|
+
```python
|
|
1332
|
+
obs = Observer(otlp_endpoint="http://localhost:4317")
|
|
1333
|
+
# Spans and metrics exported automatically to your collector
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
## Contributing
|
|
1337
|
+
|
|
1338
|
+
```bash
|
|
1339
|
+
# Install dev dependencies
|
|
1340
|
+
pip install -e ".[dev]"
|
|
1341
|
+
|
|
1342
|
+
# Run tests
|
|
1343
|
+
pytest tests/ -v
|
|
1344
|
+
|
|
1345
|
+
# Lint
|
|
1346
|
+
ruff check .
|
|
1347
|
+
ruff format --check .
|
|
1348
|
+
|
|
1349
|
+
# Type check
|
|
1350
|
+
mypy agenthandler/
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
## License
|
|
1354
|
+
|
|
1355
|
+
Apache 2.0
|