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.

@@ -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
+ [![CI](https://github.com/arunvenkatadri/AgentHandler/actions/workflows/ci.yml/badge.svg)](https://github.com/arunvenkatadri/AgentHandler/actions/workflows/ci.yml)
54
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
55
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green.svg)](LICENSE)
56
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)]()
57
+ [![Tests](https://img.shields.io/badge/tests-658-brightgreen.svg)]()
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