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
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)
|