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/server.py ADDED
@@ -0,0 +1,752 @@
1
+ """REST control plane — FastAPI app exposing the SessionManager.
2
+
3
+ Optional dependency: install with ``pip install agenthandler[server]``.
4
+
5
+ Run with::
6
+
7
+ uvicorn agenthandler.server:app
8
+
9
+ Or with API key auth::
10
+
11
+ AGENTHANDLER_API_KEY=my-secret-key uvicorn agenthandler.server:app
12
+
13
+ Or use ``create_app()`` to build an app with a custom store.
14
+
15
+ By default, CORS is restricted to localhost origins only.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+ import secrets
23
+ from typing import Any, Callable, Dict, List, Optional
24
+
25
+ try:
26
+ from fastapi import (
27
+ Depends,
28
+ FastAPI,
29
+ HTTPException,
30
+ WebSocket,
31
+ WebSocketDisconnect,
32
+ )
33
+ from fastapi.middleware.cors import CORSMiddleware
34
+ from pydantic import BaseModel
35
+ except ImportError as e:
36
+ raise ImportError(
37
+ "agenthandler[server] extras required: pip install agenthandler[server]"
38
+ ) from e
39
+
40
+ from . import __version__
41
+ from .auth import make_auth_dependency, register_oauth_routes
42
+ from .session import SessionManager
43
+ from .store import SqliteStore
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Request / response models
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ class CreateSessionRequest(BaseModel):
51
+ agent_id: str
52
+ policy: Optional[Dict[str, Any]] = None
53
+ payload: Optional[Dict[str, Any]] = None
54
+ stateless: bool = False
55
+
56
+
57
+ class DenyRequest(BaseModel):
58
+ reason: str = ""
59
+
60
+
61
+ class GeneratePipelineRequest(BaseModel):
62
+ description: str
63
+ tools: Optional[List[str]] = None # tool names to make available
64
+
65
+
66
+ class ParsePipelineRequest(BaseModel):
67
+ poml: str
68
+ agent_id: str
69
+ policy: Optional[Dict[str, Any]] = None
70
+ stateless: bool = False
71
+
72
+
73
+ class CreatePipelineSessionRequest(BaseModel):
74
+ agent_id: str
75
+ poml: str
76
+ policy: Optional[Dict[str, Any]] = None
77
+ payload: Optional[Dict[str, Any]] = None
78
+ stateless: bool = False
79
+
80
+
81
+ class CreateTriggerRequest(BaseModel):
82
+ name: str
83
+ trigger_type: str # "interval", "cron", "webhook", "db_watch"
84
+ # Interval
85
+ seconds: Optional[float] = None
86
+ minutes: Optional[float] = None
87
+ hours: Optional[float] = None
88
+ # Cron
89
+ cron: Optional[str] = None
90
+ # DB watch
91
+ query: Optional[str] = None
92
+ threshold: Optional[float] = None
93
+ operator: Optional[str] = ">" # >, >=, <, ==, !=
94
+ poll_seconds: Optional[float] = 60
95
+ # Pipeline to run (POML)
96
+ poml: Optional[str] = None
97
+ agent_id: Optional[str] = None
98
+ policy: Optional[Dict[str, Any]] = None
99
+
100
+
101
+ class SessionSummary(BaseModel):
102
+ session_id: str
103
+ agent_id: str
104
+ status: str
105
+ iterations: int = 0
106
+ tokens_used: int = 0
107
+ token_limit: Optional[int] = None
108
+ iteration_limit: int = 5
109
+ timestamp: str = ""
110
+ payload: Optional[Dict[str, Any]] = None
111
+ stateless: bool = False
112
+ resume_count: int = 0
113
+ failure_reason: str = ""
114
+
115
+
116
+ class SessionDetail(SessionSummary):
117
+ policy_dict: Optional[Dict[str, Any]] = None
118
+ circuit_breaker_states: Optional[Dict[str, Any]] = None
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # App factory
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ _LOCALHOST_ORIGINS = [
127
+ "http://localhost",
128
+ "http://localhost:*",
129
+ "http://127.0.0.1",
130
+ "http://127.0.0.1:*",
131
+ ]
132
+
133
+
134
+ def create_app(
135
+ manager: Optional[SessionManager] = None,
136
+ allowed_origins: Optional[List[str]] = None,
137
+ api_key: Optional[str] = None,
138
+ require_auth: bool = False,
139
+ tool_registry: Optional[Dict[str, Any]] = None,
140
+ llm: Optional[Any] = None,
141
+ ) -> FastAPI:
142
+ """Build a FastAPI app wired to the given SessionManager.
143
+
144
+ If no manager is supplied, one is created with a SqliteStore.
145
+
146
+ Args:
147
+ manager: SessionManager to use. Created with SqliteStore if None.
148
+ allowed_origins: CORS allowed origins. Defaults to localhost only.
149
+ Pass ["*"] to allow all origins (NOT recommended
150
+ for production without auth).
151
+ api_key: If set, all HTTP endpoints require ``Authorization: Bearer <key>``.
152
+ If None, checks the AGENTHANDLER_API_KEY environment variable.
153
+ require_auth: If True and no API key is configured, ALL requests
154
+ are rejected with 401. The default module-level ``app``
155
+ sets this to True — set AGENTHANDLER_API_KEY to use it.
156
+ """
157
+ if manager is None:
158
+ store = SqliteStore()
159
+ manager = SessionManager(store)
160
+
161
+ # Resolve API key: explicit param > env var > disabled
162
+ resolved_key = api_key if api_key is not None else os.environ.get("AGENTHANDLER_API_KEY")
163
+
164
+ # If require_auth is set and no key is configured, reject all requests
165
+ if require_auth and resolved_key is None:
166
+ resolved_key = "__REJECT_ALL__" # sentinel — no valid token can match this
167
+
168
+ # OAuth config from env vars
169
+ oauth_provider = os.environ.get("AGENTHANDLER_OAUTH_PROVIDER")
170
+ oauth_client_id = os.environ.get("AGENTHANDLER_OAUTH_CLIENT_ID", "")
171
+ oauth_client_secret = os.environ.get("AGENTHANDLER_OAUTH_CLIENT_SECRET", "")
172
+ oauth_enabled = bool(oauth_provider and oauth_client_id and oauth_client_secret)
173
+
174
+ auth = make_auth_dependency(api_key=resolved_key, oauth_enabled=oauth_enabled)
175
+
176
+ app = FastAPI(
177
+ <<<<<<< HEAD:agenthandler/server.py
178
+ title="AgentHandler Control Plane",
179
+ version="0.91.0",
180
+ =======
181
+ title="Clawboss Control Plane",
182
+ version=__version__,
183
+ >>>>>>> 729486c (chore: version sync to 0.92.0, single-source server version, ignore stray installers):clawboss/server.py
184
+ description=(
185
+ "REST API for managing agent sessions. "
186
+ + (
187
+ "Authentication: Bearer token required."
188
+ if resolved_key
189
+ else "WARNING: No authentication configured. "
190
+ "Set AGENTHANDLER_API_KEY or pass api_key to create_app()."
191
+ )
192
+ ),
193
+ )
194
+
195
+ app.add_middleware(
196
+ CORSMiddleware,
197
+ allow_origins=allowed_origins or _LOCALHOST_ORIGINS,
198
+ allow_methods=["*"],
199
+ allow_headers=["*"],
200
+ )
201
+
202
+ # Stash on app state for tests
203
+ app.state.manager = manager
204
+ app.state.auth_enabled = resolved_key is not None or oauth_enabled
205
+
206
+ # Register OAuth routes if configured
207
+ if oauth_enabled and oauth_provider:
208
+ register_oauth_routes(app, oauth_provider, oauth_client_id, oauth_client_secret)
209
+
210
+ # ------------------------------------------------------------------
211
+ # Endpoints
212
+ # ------------------------------------------------------------------
213
+
214
+ @app.post("/sessions", response_model=SessionSummary, status_code=201)
215
+ def create_session(req: CreateSessionRequest, _: Any = Depends(auth)) -> Dict[str, Any]:
216
+ sid = manager.start(req.agent_id, req.policy, req.payload, stateless=req.stateless)
217
+ cp = manager.status(sid)
218
+ return _checkpoint_to_summary(cp)
219
+
220
+ @app.get("/sessions", response_model=List[SessionSummary])
221
+ def list_sessions(_: Any = Depends(auth)) -> List[Dict[str, Any]]:
222
+ return [_checkpoint_to_summary(cp) for cp in manager.list_sessions()]
223
+
224
+ @app.get("/sessions/{session_id}", response_model=SessionDetail)
225
+ def get_session(session_id: str, _: Any = Depends(auth)) -> Dict[str, Any]:
226
+ cp = manager.status(session_id)
227
+ if cp is None:
228
+ raise HTTPException(status_code=404, detail="Session not found")
229
+ return _checkpoint_to_detail(cp)
230
+
231
+ @app.post("/sessions/{session_id}/pause", response_model=SessionSummary)
232
+ def pause_session(session_id: str, _: Any = Depends(auth)) -> Dict[str, Any]:
233
+ try:
234
+ manager.pause(session_id)
235
+ except Exception as e:
236
+ if "not found" in str(e).lower():
237
+ raise HTTPException(status_code=404, detail="Session not found")
238
+ raise
239
+ cp = manager.status(session_id)
240
+ return _checkpoint_to_summary(cp)
241
+
242
+ @app.post("/sessions/{session_id}/resume", response_model=SessionSummary)
243
+ def resume_session(session_id: str, _: Any = Depends(auth)) -> Dict[str, Any]:
244
+ try:
245
+ manager.resume(session_id)
246
+ except Exception as e:
247
+ if "not found" in str(e).lower():
248
+ raise HTTPException(status_code=404, detail="Session not found")
249
+ raise
250
+ cp = manager.status(session_id)
251
+ return _checkpoint_to_summary(cp)
252
+
253
+ @app.post("/sessions/{session_id}/stop", response_model=SessionSummary)
254
+ def stop_session(session_id: str, _: Any = Depends(auth)) -> Dict[str, Any]:
255
+ try:
256
+ manager.stop(session_id)
257
+ except Exception as e:
258
+ if "not found" in str(e).lower():
259
+ raise HTTPException(status_code=404, detail="Session not found")
260
+ raise
261
+ cp = manager.status(session_id)
262
+ return _checkpoint_to_summary(cp)
263
+
264
+ @app.post("/sessions/{session_id}/restart", response_model=SessionSummary, status_code=201)
265
+ def restart_session(session_id: str, _: Any = Depends(auth)) -> Dict[str, Any]:
266
+ try:
267
+ new_sid = manager.restart(session_id)
268
+ except Exception as e:
269
+ if "not found" in str(e).lower():
270
+ raise HTTPException(status_code=404, detail="Session not found")
271
+ raise
272
+ cp = manager.status(new_sid)
273
+ return _checkpoint_to_summary(cp)
274
+
275
+ # ------------------------------------------------------------------
276
+ # Approval endpoints
277
+ # ------------------------------------------------------------------
278
+
279
+ @app.get("/sessions/{session_id}/approvals")
280
+ def list_approvals(session_id: str, _: Any = Depends(auth)) -> Any:
281
+ cp = manager.status(session_id)
282
+ if cp is None:
283
+ raise HTTPException(status_code=404, detail="Session not found")
284
+ return [r.to_dict() for r in manager.approval_queue.list_all(session_id)]
285
+
286
+ @app.post("/sessions/{session_id}/approvals/{approval_id}/approve")
287
+ def approve_tool_call(session_id: str, approval_id: str, _: Any = Depends(auth)) -> Any:
288
+ req = manager.approval_queue.approve(approval_id)
289
+ if req is None:
290
+ raise HTTPException(
291
+ status_code=404,
292
+ detail="Approval not found or already resolved",
293
+ )
294
+ return req.to_dict()
295
+
296
+ @app.post("/sessions/{session_id}/approvals/{approval_id}/deny")
297
+ def deny_tool_call(
298
+ session_id: str, approval_id: str, body: DenyRequest = DenyRequest(), _: Any = Depends(auth)
299
+ ) -> Any:
300
+ req = manager.approval_queue.deny(approval_id, reason=body.reason)
301
+ if req is None:
302
+ raise HTTPException(
303
+ status_code=404,
304
+ detail="Approval not found or already resolved",
305
+ )
306
+ return req.to_dict()
307
+
308
+ @app.get("/sessions/{session_id}/audit")
309
+ def get_audit(session_id: str, _: Any = Depends(auth)) -> Any:
310
+ cp = manager.status(session_id)
311
+ if cp is None:
312
+ raise HTTPException(status_code=404, detail="Session not found")
313
+ return manager.get_audit_entries(session_id)
314
+
315
+ @app.get("/sessions/{session_id}/replay")
316
+ def get_session_replay(session_id: str, _: Any = Depends(auth)) -> Dict[str, Any]:
317
+ """Reconstruct a session's timeline for inspection."""
318
+ from .replay import SessionReplay
319
+
320
+ replay = SessionReplay(manager, session_id)
321
+ if not replay.exists:
322
+ raise HTTPException(status_code=404, detail="Session not found")
323
+ return {
324
+ "summary": replay.summary().to_dict(),
325
+ "frames": replay.to_timeline(),
326
+ }
327
+
328
+ # ------------------------------------------------------------------
329
+ # Observability endpoints
330
+ # ------------------------------------------------------------------
331
+
332
+ @app.get("/metrics/tools")
333
+ def get_tool_metrics(_: Any = Depends(auth)) -> Any:
334
+ return manager.observer.all_tools_summary()
335
+
336
+ @app.get("/metrics/sessions/{session_id}")
337
+ def get_session_metrics(session_id: str, _: Any = Depends(auth)) -> Any:
338
+ cp = manager.status(session_id)
339
+ if cp is None:
340
+ raise HTTPException(status_code=404, detail="Session not found")
341
+ return manager.observer.session_summary(session_id)
342
+
343
+ @app.get("/metrics/recent")
344
+ def get_recent_calls(limit: int = 50, _: Any = Depends(auth)) -> Any:
345
+ return manager.observer.recent_calls(limit=min(limit, 200))
346
+
347
+ @app.get("/metrics/costs")
348
+ def get_cost_summary(_: Any = Depends(auth)) -> Any:
349
+ """Total cost breakdown by agent, session, tool, and model."""
350
+ return manager.observer.cost_summary()
351
+
352
+ @app.get("/metrics/pricing")
353
+ def get_pricing(_: Any = Depends(auth)) -> Any:
354
+ """Current pricing table used for cost attribution."""
355
+ pricing = manager.observer.pricing
356
+ if pricing is None:
357
+ return {"configured": False, "models": {}}
358
+ return {"configured": True, **pricing.to_dict()}
359
+
360
+ # ------------------------------------------------------------------
361
+ # Pipeline endpoints
362
+ # ------------------------------------------------------------------
363
+
364
+ tools = tool_registry or {}
365
+ app.state.tool_registry = tools
366
+ app.state.llm = llm
367
+
368
+ # Find SqlConnector instances in the tool registry for schema discovery
369
+ _sql_connectors = {
370
+ name: fn
371
+ for name, fn in tools.items()
372
+ if hasattr(fn, "__self__") and type(fn.__self__).__name__ == "SqlConnector"
373
+ }
374
+
375
+ @app.get("/pipelines/schema")
376
+ async def get_pipeline_schema(_: Any = Depends(auth)) -> Any:
377
+ """Discover database schema from registered SQL connectors."""
378
+ schemas = {}
379
+ for name, bound_method in _sql_connectors.items():
380
+ connector = bound_method.__self__
381
+ schemas[name] = await connector.discover_schema()
382
+ # Also check for SqlConnector instances registered directly
383
+ for name, fn in tools.items():
384
+ if hasattr(fn, "discover_schema") and name not in schemas:
385
+ schemas[name] = await fn.discover_schema()
386
+ return schemas
387
+
388
+ @app.get("/pipelines/tools")
389
+ def list_pipeline_tools(_: Any = Depends(auth)) -> Any:
390
+ """List available tools for pipeline building."""
391
+ result = []
392
+ for name, fn in sorted(tools.items()):
393
+ doc = (fn.__doc__ or "").strip().split("\n")[0]
394
+ result.append({"name": name, "description": doc})
395
+ return result
396
+
397
+ @app.post("/pipelines/generate")
398
+ async def generate_pipeline_poml(
399
+ req: GeneratePipelineRequest, _: Any = Depends(auth)
400
+ ) -> Dict[str, Any]:
401
+ """Generate POML from a natural language description."""
402
+ if llm is None:
403
+ raise HTTPException(
404
+ status_code=501,
405
+ detail="No LLM configured. Pass llm= to create_app().",
406
+ )
407
+ from .pipeline_poml import PipelineBuilder
408
+
409
+ # Auto-discover schema from any SQL connectors
410
+ db_schema = None
411
+ for name, fn in tools.items():
412
+ if hasattr(fn, "discover_schema"):
413
+ try:
414
+ db_schema = await fn.discover_schema()
415
+ break
416
+ except Exception:
417
+ pass
418
+ for name, bound in _sql_connectors.items():
419
+ if db_schema is None:
420
+ try:
421
+ db_schema = await bound.__self__.discover_schema()
422
+ break
423
+ except Exception:
424
+ pass
425
+
426
+ builder = PipelineBuilder(llm, tools, manager, db_schema=db_schema)
427
+ poml = await builder.create_poml(req.description)
428
+ return {"poml": poml}
429
+
430
+ @app.post("/pipelines/validate")
431
+ def validate_pipeline_poml(req: ParsePipelineRequest, _: Any = Depends(auth)) -> Dict[str, Any]:
432
+ """Validate POML and return the parsed step structure."""
433
+ from .pipeline_poml import parse_pipeline_poml
434
+
435
+ try:
436
+ pipeline = parse_pipeline_poml(
437
+ req.poml,
438
+ tools,
439
+ manager,
440
+ req.agent_id,
441
+ policy_dict=req.policy,
442
+ stateless=req.stateless,
443
+ )
444
+ except ValueError as e:
445
+ raise HTTPException(status_code=400, detail=str(e))
446
+ # Return step names without running
447
+ steps = []
448
+ for node in pipeline._nodes:
449
+ if hasattr(node, "tool_name"):
450
+ steps.append({"type": "step", "name": node.name, "tool": node.tool_name})
451
+ else:
452
+ steps.append({"type": "condition", "name": node.name})
453
+ return {"valid": True, "steps": steps}
454
+
455
+ @app.post("/pipelines/run", status_code=201)
456
+ async def run_pipeline(
457
+ req: CreatePipelineSessionRequest, _: Any = Depends(auth)
458
+ ) -> Dict[str, Any]:
459
+ """Parse POML and run the pipeline immediately."""
460
+ from .pipeline_poml import parse_pipeline_poml
461
+
462
+ try:
463
+ pipeline = parse_pipeline_poml(
464
+ req.poml,
465
+ tools,
466
+ manager,
467
+ req.agent_id,
468
+ policy_dict=req.policy,
469
+ stateless=req.stateless,
470
+ )
471
+ except ValueError as e:
472
+ raise HTTPException(status_code=400, detail=str(e))
473
+
474
+ result = await pipeline.run()
475
+ return {
476
+ "session_id": result.session_id,
477
+ "completed": result.completed,
478
+ "steps": [
479
+ {
480
+ "name": s.name,
481
+ "tool": s.tool_name,
482
+ "succeeded": s.result.succeeded,
483
+ "duration_ms": s.result.duration_ms,
484
+ "branch": s.branch,
485
+ }
486
+ for s in result.steps
487
+ ],
488
+ "final_output": str(result.final_output)[:1000] if result.final_output else None,
489
+ "error": result.error,
490
+ "total_duration_ms": result.total_duration_ms,
491
+ }
492
+
493
+ # ------------------------------------------------------------------
494
+ # Trigger endpoints
495
+ # ------------------------------------------------------------------
496
+
497
+ from .triggers import Scheduler, WebhookTrigger
498
+
499
+ scheduler = Scheduler()
500
+ webhooks: Dict[str, WebhookTrigger] = {}
501
+ app.state.scheduler = scheduler
502
+ app.state.webhooks = webhooks
503
+
504
+ @app.post("/triggers", status_code=201)
505
+ async def create_trigger(req: CreateTriggerRequest, _: Any = Depends(auth)) -> Dict[str, Any]:
506
+ """Create a new trigger."""
507
+
508
+ async def _make_pipeline_runner() -> Any:
509
+ """Build a pipeline runner from the trigger's POML."""
510
+ if not req.poml or not req.agent_id:
511
+ return None
512
+ from .pipeline_poml import parse_pipeline_poml
513
+
514
+ async def _run_pipeline() -> Any:
515
+ assert req.poml is not None
516
+ assert req.agent_id is not None
517
+ pipeline = parse_pipeline_poml(
518
+ req.poml,
519
+ tools,
520
+ manager,
521
+ req.agent_id,
522
+ policy_dict=req.policy,
523
+ )
524
+ return await pipeline.run()
525
+
526
+ return _run_pipeline
527
+
528
+ pipeline_fn = await _make_pipeline_runner()
529
+
530
+ if req.trigger_type == "interval":
531
+ total = (req.seconds or 0) + (req.minutes or 0) * 60 + (req.hours or 0) * 3600
532
+ if total <= 0:
533
+ raise HTTPException(status_code=400, detail="Interval must be > 0")
534
+ if pipeline_fn is None:
535
+ raise HTTPException(status_code=400, detail="Pipeline POML required")
536
+ scheduler.add_interval(req.name, pipeline_fn, seconds=total)
537
+ if not scheduler._running:
538
+ scheduler.start()
539
+
540
+ elif req.trigger_type == "cron":
541
+ if not req.cron:
542
+ raise HTTPException(status_code=400, detail="Cron expression required")
543
+ if pipeline_fn is None:
544
+ raise HTTPException(status_code=400, detail="Pipeline POML required")
545
+ scheduler.add_cron(req.name, pipeline_fn, cron=req.cron)
546
+ if not scheduler._running:
547
+ scheduler.start()
548
+
549
+ elif req.trigger_type == "webhook":
550
+ if pipeline_fn is None:
551
+
552
+ async def noop() -> None:
553
+ return None
554
+
555
+ pipeline_fn = noop
556
+ wh = WebhookTrigger(req.name, pipeline_fn)
557
+ webhooks[req.name] = wh
558
+
559
+ elif req.trigger_type == "db_watch":
560
+ if not req.query:
561
+ raise HTTPException(status_code=400, detail="SQL query required")
562
+ if pipeline_fn is None:
563
+ raise HTTPException(status_code=400, detail="Pipeline POML required")
564
+ # Find a sql connector in tools
565
+ sql_conn = None
566
+ for fn_ref in tools.values():
567
+ if hasattr(fn_ref, "discover_schema"):
568
+ sql_conn = fn_ref
569
+ break
570
+ if hasattr(fn_ref, "__self__") and hasattr(fn_ref.__self__, "discover_schema"):
571
+ sql_conn = fn_ref.__self__
572
+ break
573
+ if sql_conn is None:
574
+ raise HTTPException(status_code=400, detail="No SQL connector registered")
575
+
576
+ op = req.operator or ">"
577
+ threshold = req.threshold or 0
578
+ ops = {
579
+ ">": lambda v, t: v > t,
580
+ ">=": lambda v, t: v >= t,
581
+ "<": lambda v, t: v < t,
582
+ "==": lambda v, t: v == t,
583
+ "!=": lambda v, t: v != t,
584
+ }
585
+ cmp_fn = ops.get(op, ops[">"])
586
+
587
+ def make_condition(cmp: Any, thresh: float) -> Callable[[Dict[str, Any]], bool]:
588
+ def condition(r: Dict[str, Any]) -> bool:
589
+ try:
590
+ val = r["rows"][0][list(r["rows"][0].keys())[0]]
591
+ return bool(cmp(float(val), thresh))
592
+ except (IndexError, KeyError, TypeError, ValueError):
593
+ return False
594
+
595
+ return condition
596
+
597
+ scheduler.add_db_watch(
598
+ req.name,
599
+ pipeline_fn,
600
+ connector=sql_conn,
601
+ query=req.query,
602
+ condition=make_condition(cmp_fn, threshold),
603
+ poll_seconds=req.poll_seconds or 60,
604
+ )
605
+ if not scheduler._running:
606
+ scheduler.start()
607
+ else:
608
+ raise HTTPException(status_code=400, detail=f"Unknown trigger type: {req.trigger_type}")
609
+
610
+ return {"name": req.name, "type": req.trigger_type, "created": True}
611
+
612
+ @app.get("/triggers")
613
+ def list_triggers(_: Any = Depends(auth)) -> Any:
614
+ """List all registered triggers."""
615
+ triggers = scheduler.list_triggers()
616
+ for name, wh in webhooks.items():
617
+ triggers.append({"name": name, "type": "webhook", "enabled": True})
618
+ return triggers
619
+
620
+ @app.get("/triggers/history")
621
+ def get_trigger_history(limit: int = 50, _: Any = Depends(auth)) -> Any:
622
+ """Get trigger firing history."""
623
+ history = scheduler.history(limit)
624
+ for wh in webhooks.values():
625
+ history.extend(wh.history(limit))
626
+ history.sort(key=lambda r: r.get("fired_at", ""), reverse=True)
627
+ return history[:limit]
628
+
629
+ @app.post("/triggers/{trigger_name}/fire")
630
+ async def fire_webhook(trigger_name: str, _: Any = Depends(auth)) -> Any:
631
+ """Fire a webhook trigger manually."""
632
+ wh = webhooks.get(trigger_name)
633
+ if wh is None:
634
+ raise HTTPException(status_code=404, detail=f"Webhook '{trigger_name}' not found")
635
+ record = await wh.fire()
636
+ return record.to_dict()
637
+
638
+ @app.post("/triggers/{trigger_name}/enable")
639
+ def enable_trigger(trigger_name: str, _: Any = Depends(auth)) -> Dict[str, Any]:
640
+ scheduler.enable(trigger_name)
641
+ return {"name": trigger_name, "enabled": True}
642
+
643
+ @app.post("/triggers/{trigger_name}/disable")
644
+ def disable_trigger(trigger_name: str, _: Any = Depends(auth)) -> Dict[str, Any]:
645
+ scheduler.disable(trigger_name)
646
+ return {"name": trigger_name, "enabled": False}
647
+
648
+ @app.delete("/triggers/{trigger_name}")
649
+ def remove_trigger(trigger_name: str, _: Any = Depends(auth)) -> Dict[str, Any]:
650
+ removed = scheduler.remove(trigger_name)
651
+ if trigger_name in webhooks:
652
+ del webhooks[trigger_name]
653
+ removed = True
654
+ if not removed:
655
+ raise HTTPException(status_code=404, detail="Trigger not found")
656
+ return {"removed": trigger_name}
657
+
658
+ @app.websocket("/sessions/{session_id}/events")
659
+ async def session_events(websocket: WebSocket, session_id: str) -> None:
660
+ """Stream audit events and status changes for a session.
661
+
662
+ WebSocket auth: pass the API key as a query param ?token=<key>
663
+ or in the first message after connect.
664
+ """
665
+ # WebSocket auth via query param
666
+ if resolved_key is not None:
667
+ token = websocket.query_params.get("token", "")
668
+ if not secrets.compare_digest(token, resolved_key):
669
+ await websocket.close(code=4001)
670
+ return
671
+
672
+ cp = manager.status(session_id)
673
+ if cp is None:
674
+ await websocket.close(code=4004)
675
+ return
676
+
677
+ await websocket.accept()
678
+ last_count = 0
679
+
680
+ try:
681
+ while True:
682
+ entries = manager.get_audit_entries(session_id)
683
+ if len(entries) > last_count:
684
+ for entry in entries[last_count:]:
685
+ await websocket.send_json({"type": "audit", "data": entry})
686
+ last_count = len(entries)
687
+
688
+ cp = manager.status(session_id)
689
+ if cp is not None:
690
+ await websocket.send_json(
691
+ {
692
+ "type": "status",
693
+ "data": {
694
+ "session_id": cp.session_id,
695
+ "status": cp.status.value,
696
+ "iterations": cp.iterations,
697
+ "tokens_used": cp.tokens_used,
698
+ },
699
+ }
700
+ )
701
+
702
+ # Send pending approvals
703
+ pending = manager.approval_queue.list_pending(session_id)
704
+ for req in pending:
705
+ await websocket.send_json(
706
+ {
707
+ "type": "approval_required",
708
+ "data": req.to_dict(),
709
+ }
710
+ )
711
+
712
+ await asyncio.sleep(1)
713
+ except WebSocketDisconnect:
714
+ pass
715
+
716
+ return app
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # Helpers
721
+ # ---------------------------------------------------------------------------
722
+
723
+
724
+ def _checkpoint_to_summary(cp: Any) -> Dict[str, Any]:
725
+ return {
726
+ "session_id": cp.session_id,
727
+ "agent_id": cp.agent_id,
728
+ "status": cp.status.value,
729
+ "iterations": cp.iterations,
730
+ "tokens_used": cp.tokens_used,
731
+ "token_limit": cp.token_limit,
732
+ "iteration_limit": cp.iteration_limit,
733
+ "timestamp": cp.timestamp,
734
+ "payload": cp.payload,
735
+ "stateless": cp.stateless,
736
+ "resume_count": cp.resume_count,
737
+ "failure_reason": cp.failure_reason,
738
+ }
739
+
740
+
741
+ def _checkpoint_to_detail(cp: Any) -> Dict[str, Any]:
742
+ d = _checkpoint_to_summary(cp)
743
+ d["policy_dict"] = cp.policy_dict
744
+ d["circuit_breaker_states"] = cp.circuit_breaker_states
745
+ return d
746
+
747
+
748
+ # ---------------------------------------------------------------------------
749
+ # Default app instance for ``uvicorn agenthandler.server:app``
750
+ # ---------------------------------------------------------------------------
751
+
752
+ app = create_app(require_auth=True)