agentdrift-mcp 2.0.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.
agentdrift/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """AgentDrift shared library.
2
+
3
+ The package owns the CLI, MCP server, API, and backend-agnostic storage
4
+ abstraction so installed entrypoints resolve entirely within the package.
5
+ """
6
+
7
+ __version__ = "2.0.0"
@@ -0,0 +1 @@
1
+ """Workspace memory HTTP transport."""
@@ -0,0 +1,88 @@
1
+ """Authenticated MCP JSON-RPC HTTP and workspace inspection routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import os
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, Header, HTTPException
11
+ from fastapi.responses import Response
12
+ from pydantic import BaseModel, Field
13
+
14
+ from agentdrift import __version__
15
+ from agentdrift.mcp.workspace_tools import TOOLS, call_tool, get_service
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ async def require_service_token(authorization: str | None = Header(default=None)) -> None:
21
+ expected = os.getenv("AGENTDRIFT_SERVICE_TOKEN", "").strip()
22
+ presented = (
23
+ authorization[7:].strip()
24
+ if authorization and authorization.lower().startswith("bearer ")
25
+ else ""
26
+ )
27
+ if not expected or not presented or not hmac.compare_digest(expected, presented):
28
+ raise HTTPException(
29
+ 401, "valid service bearer token required", headers={"WWW-Authenticate": "Bearer"}
30
+ )
31
+
32
+
33
+ class RpcRequest(BaseModel):
34
+ jsonrpc: str = "2.0"
35
+ id: str | int | None = None
36
+ method: str
37
+ params: dict[str, Any] = Field(default_factory=dict)
38
+
39
+
40
+ @router.post("/api/v1/mcp/tools/call")
41
+ async def mcp_http_call(body: RpcRequest, authorization: str | None = Header(default=None)):
42
+ await require_service_token(authorization)
43
+ if body.jsonrpc != "2.0":
44
+ return {
45
+ "jsonrpc": "2.0",
46
+ "id": body.id,
47
+ "error": {"code": -32600, "message": "jsonrpc must be 2.0"},
48
+ }
49
+ if body.method == "initialize":
50
+ result = {
51
+ "protocolVersion": body.params.get("protocolVersion", "2025-03-26"),
52
+ "capabilities": {"tools": {"listChanged": False}},
53
+ "serverInfo": {"name": "agentdrift-workspace-memory", "version": __version__},
54
+ }
55
+ elif body.method in {"notifications/initialized", "notifications/cancelled"}:
56
+ return Response(status_code=202)
57
+ elif body.method == "ping":
58
+ result = {}
59
+ elif body.method == "tools/list":
60
+ result = {"tools": TOOLS}
61
+ elif body.method == "tools/call":
62
+ name = body.params.get("name")
63
+ args = body.params.get("arguments", {})
64
+ try:
65
+ data = await call_tool(name, args)
66
+ except KeyError as exc:
67
+ return {"jsonrpc": "2.0", "id": body.id, "error": {"code": -32601, "message": str(exc)}}
68
+ except (TypeError, ValueError) as exc:
69
+ return {"jsonrpc": "2.0", "id": body.id, "error": {"code": -32602, "message": str(exc)}}
70
+ structured = data if isinstance(data, dict) else {"items": data}
71
+ result = {
72
+ "content": [{"type": "text", "text": json.dumps(data, default=str)}],
73
+ "structuredContent": structured,
74
+ }
75
+ else:
76
+ return {
77
+ "jsonrpc": "2.0",
78
+ "id": body.id,
79
+ "error": {"code": -32601, "message": f"unsupported MCP method: {body.method}"},
80
+ }
81
+ return {"jsonrpc": "2.0", "id": body.id, "result": result}
82
+
83
+
84
+ @router.get("/api/v1/workspace/{session_id}/timeline")
85
+ async def workspace_timeline(session_id: str, authorization: str | None = Header(default=None)):
86
+ await require_service_token(authorization)
87
+ service = get_service()
88
+ return {"session_id": session_id, "actions": await service.timeline(session_id)}
agentdrift/cli.py ADDED
@@ -0,0 +1,556 @@
1
+ """AgentDrift terminal CLI — replaces the web dashboard.
2
+
3
+ Talks to the daemon at http://127.0.0.1:8901.
4
+
5
+ Usage:
6
+ python3 cli.py health
7
+ python3 cli.py scan --threshold 0.92
8
+ python3 cli.py ingest --agent-id demo --session-id s1 --payload '{"key": "val"}'
9
+ python3 cli.py evaluate --premise "the cat sits" --hypothesis "the cat sits"
10
+ python3 cli.py test-all
11
+ python3 cli.py watch --session-id s1 --interval 1.0 --threshold 0.92
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import time
19
+ import uuid
20
+ from collections import deque
21
+ from datetime import datetime
22
+
23
+ import requests
24
+ import typer
25
+ from rich import box
26
+ from rich.console import Console
27
+ from rich.layout import Layout
28
+ from rich.live import Live
29
+ from rich.panel import Panel
30
+ from rich.table import Table
31
+ from rich.text import Text
32
+
33
+ DAEMON = os.getenv("AGENTDRIFT_DAEMON", "http://127.0.0.1:8901").rstrip("/")
34
+ TIMEOUT = 10
35
+
36
+ app = typer.Typer(help="AgentDrift CLI — terminal-native daemon client.")
37
+ console = Console()
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # HTTP helpers (no tracebacks on connection failure)
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def _get(path: str) -> dict | list:
46
+ try:
47
+ r = requests.get(f"{DAEMON}{path}", timeout=TIMEOUT)
48
+ r.raise_for_status()
49
+ return r.json()
50
+ except requests.ConnectionError:
51
+ console.print(
52
+ Panel(
53
+ f"[bold]Daemon offline[/bold] at {DAEMON}\n"
54
+ "Start it with:\n"
55
+ " [cyan]uvicorn app.main:app --port 8901 --reload --reload-dir app[/cyan]",
56
+ title="Connection failed",
57
+ style="red",
58
+ )
59
+ )
60
+ raise typer.Exit(1)
61
+ except requests.Timeout:
62
+ console.print(Panel(f"Daemon at {DAEMON} timed out on GET {path}.", style="red"))
63
+ raise typer.Exit(1)
64
+ except requests.HTTPError as exc:
65
+ console.print(Panel(f"GET {path} failed: {exc}", style="red"))
66
+ raise typer.Exit(1)
67
+
68
+
69
+ def _post(path: str, payload: object) -> dict | list:
70
+ try:
71
+ r = requests.post(f"{DAEMON}{path}", json=payload, timeout=TIMEOUT)
72
+ r.raise_for_status()
73
+ return r.json()
74
+ except requests.ConnectionError:
75
+ console.print(
76
+ Panel(
77
+ f"[bold]Daemon offline[/bold] at {DAEMON}\n"
78
+ "Start it with:\n"
79
+ " [cyan]uvicorn app.main:app --port 8901 --reload --reload-dir app[/cyan]",
80
+ title="Connection failed",
81
+ style="red",
82
+ )
83
+ )
84
+ raise typer.Exit(1)
85
+ except requests.Timeout:
86
+ console.print(Panel(f"Daemon at {DAEMON} timed out on POST {path}.", style="red"))
87
+ raise typer.Exit(1)
88
+ except requests.HTTPError as exc:
89
+ detail = ""
90
+ try:
91
+ detail = f"\n{exc.response.text[:500]}"
92
+ except Exception:
93
+ pass
94
+ console.print(Panel(f"POST {path} failed: {exc}{detail}", style="red"))
95
+ raise typer.Exit(1)
96
+
97
+
98
+ def _fetch_stats() -> dict:
99
+ for path in ("/stats", "/v1/stats", "/api/stats"):
100
+ try:
101
+ r = requests.get(f"{DAEMON}{path}", timeout=TIMEOUT)
102
+ if r.status_code == 200:
103
+ data = r.json()
104
+ return data if isinstance(data, dict) else {}
105
+ except requests.RequestException:
106
+ continue
107
+ return {}
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Commands
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ @app.command()
116
+ def health() -> None:
117
+ """Query GET /health and show status, uptime, backend and counters."""
118
+ data = _get("/health")
119
+ if not isinstance(data, dict):
120
+ console.print(Panel(f"Unexpected /health response: {data!r}", style="red"))
121
+ raise typer.Exit(1)
122
+ stats = _fetch_stats()
123
+
124
+ table = Table(title="AgentDrift daemon", box=box.ROUNDED)
125
+ table.add_column("Field", style="bold cyan")
126
+ table.add_column("Value")
127
+ table.add_row("Status", str(data.get("status", "?")))
128
+ table.add_row("Version", str(data.get("version", "?")))
129
+ table.add_row("DB connected", str(data.get("db_connected", "?")))
130
+ table.add_row("Mode", str(data.get("mode", data.get("db_mode", "?"))))
131
+ table.add_row("Backend", str(data.get("embeddings", data.get("backend", "?"))))
132
+ table.add_row("Uptime (s)", str(data.get("uptime_seconds", "?")))
133
+ table.add_row("Sessions", str(stats.get("sessions", "?")))
134
+ table.add_row("Executions", str(stats.get("executions", "?")))
135
+ table.add_row("Detections", str(stats.get("detections", "?")))
136
+ console.print(table)
137
+
138
+
139
+ @app.command()
140
+ def scan(threshold: float = typer.Option(0.92, help="Loop similarity threshold.")) -> None:
141
+ """POST /v1/sentinel/scan and display ping-pong loop results."""
142
+ data = _post("/v1/sentinel/scan", {"threshold": threshold})
143
+ if not isinstance(data, dict):
144
+ console.print(Panel(f"Unexpected scan response: {data!r}", style="red"))
145
+ raise typer.Exit(1)
146
+ detections = int(data.get("detections", 0) or 0)
147
+ thr = data.get("threshold", threshold)
148
+ if detections > 0:
149
+ console.print(
150
+ Panel(
151
+ f"[bold]Ping-pong loop detected[/bold]\n"
152
+ f"Detections: [bold red]{detections}[/bold red]\n"
153
+ f"Threshold: {thr}",
154
+ title="Sentinel scan",
155
+ style="red",
156
+ )
157
+ )
158
+ else:
159
+ console.print(
160
+ Panel(
161
+ f"[bold]No loops found[/bold]\nThreshold: {thr}",
162
+ title="Sentinel scan",
163
+ style="green",
164
+ )
165
+ )
166
+ loops = data.get("loops") or []
167
+ if loops:
168
+ table = Table(title="Loops", box=box.ROUNDED)
169
+ table.add_column("Session")
170
+ table.add_column("Execution")
171
+ table.add_column("Prior")
172
+ table.add_column("Similarity", justify="right")
173
+ for loop in loops[:20]:
174
+ table.add_row(
175
+ str(loop.get("session_id", "")),
176
+ str(loop.get("execution_id", "")),
177
+ str(loop.get("prior_execution_id", "")),
178
+ str(loop.get("similarity", "")),
179
+ )
180
+ console.print(table)
181
+
182
+
183
+ @app.command()
184
+ def ingest(
185
+ agent_id: str = typer.Option("demo-agent", "--agent-id", help="Agent ID."),
186
+ session_id: str = typer.Option("demo-session", "--session-id", help="Session ID."),
187
+ node_id: str = typer.Option("default", "--node-id", help="Node ID."),
188
+ payload: str = typer.Option('{"key": "val"}', "--payload", help="JSON payload dict."),
189
+ state_hash: str = typer.Option("", "--state-hash", help="Optional state hash."),
190
+ ) -> None:
191
+ """Package input into a TelemetryBatch and POST /v1/ingest."""
192
+ try:
193
+ parsed = json.loads(payload)
194
+ except json.JSONDecodeError as exc:
195
+ console.print(Panel(f"Invalid --payload JSON: {exc}", style="red"))
196
+ raise typer.Exit(1)
197
+ if not isinstance(parsed, dict):
198
+ console.print(Panel("--payload must decode to a JSON object.", style="red"))
199
+ raise typer.Exit(1)
200
+
201
+ if "input_payload" in parsed or "output_payload" in parsed:
202
+ input_payload = parsed.get("input_payload", {})
203
+ output_payload = parsed.get("output_payload", {})
204
+ else:
205
+ input_payload, output_payload = parsed, parsed
206
+
207
+ event: dict = {
208
+ "session_id": session_id,
209
+ "agent_id": agent_id,
210
+ "node_id": node_id,
211
+ "input_payload": input_payload,
212
+ "output_payload": output_payload,
213
+ }
214
+ if state_hash:
215
+ event["state_hash"] = state_hash
216
+
217
+ data = _post("/v1/ingest", {"executions": [event]})
218
+ if not isinstance(data, dict):
219
+ console.print(Panel(f"Unexpected ingest response: {data!r}", style="red"))
220
+ raise typer.Exit(1)
221
+ table = Table(title="Ingest result", box=box.ROUNDED)
222
+ table.add_column("Field", style="bold cyan")
223
+ table.add_column("Value")
224
+ for key in ("ok", "accepted", "persisted", "rejected", "errors"):
225
+ table.add_row(key, str(data.get(key, "?")))
226
+ console.print(table)
227
+ if data.get("errors"):
228
+ raise typer.Exit(1)
229
+
230
+
231
+ @app.command()
232
+ def evaluate(
233
+ premise: str = typer.Option(..., "--premise", help="Premise text."),
234
+ hypothesis: str = typer.Option(..., "--hypothesis", help="Hypothesis text."),
235
+ ) -> None:
236
+ """POST /v1/evaluate with one pair and render the NLI Gate table."""
237
+ item_id = f"cli_{uuid.uuid4().hex[:8]}"
238
+ data = _post("/v1/evaluate", [{"id": item_id, "premise": premise, "hypothesis": hypothesis}])
239
+ results = data.get("results", []) if isinstance(data, dict) else []
240
+ if not results:
241
+ console.print(Panel(f"No NLI results returned: {data!r}", style="red"))
242
+ raise typer.Exit(1)
243
+
244
+ table = Table(title="NLI Gate evaluation", box=box.ROUNDED)
245
+ table.add_column("ID")
246
+ table.add_column("Label", style="bold")
247
+ table.add_column("Entailment", justify="right")
248
+ table.add_column("Neutral", justify="right")
249
+ table.add_column("Contradiction", justify="right")
250
+ table.add_column("Margin", justify="right")
251
+ for res in results:
252
+ label = str(res.get("label", "?"))
253
+ color = {"entailment": "green", "contradiction": "red"}.get(label, "yellow")
254
+ table.add_row(
255
+ str(res.get("id", "")),
256
+ f"[{color}]{label}[/{color}]",
257
+ str(res.get("entailment", "?")),
258
+ str(res.get("neutral", "?")),
259
+ str(res.get("contradiction", "?")),
260
+ str(res.get("margin", "?")),
261
+ )
262
+ console.print(table)
263
+
264
+
265
+ @app.command()
266
+ def watch(
267
+ session_id: str | None = typer.Option(None, "--session-id", help="Only tail this session."),
268
+ interval: float = typer.Option(1.0, "--interval", help="Polling interval in seconds."),
269
+ threshold: float = typer.Option(0.92, "--threshold", help="Loop similarity threshold."),
270
+ bell: bool = typer.Option(True, "--bell/--no-bell", help="Terminal bell when drift triggers."),
271
+ ) -> None:
272
+ """Live-tail executions with terminal alerts when drift occurs."""
273
+ interval = min(max(interval, 0.2), 30.0)
274
+ stream: deque[Text] = deque(maxlen=15)
275
+ seen_exe: set[str] = set()
276
+ known_loops: set[tuple[str, str]] = set()
277
+ last_alert: str | None = None
278
+ alert_count = 0
279
+ online = False
280
+
281
+ layout = Layout()
282
+ layout.split_column(
283
+ Layout(name="header", size=3),
284
+ Layout(name="stream", ratio=1),
285
+ Layout(name="alert", size=7),
286
+ )
287
+ filt = session_id or "all sessions"
288
+ layout["header"].update(
289
+ Panel(
290
+ f"Daemon: {DAEMON} • filter: {filt} • every {interval:g}s • threshold {threshold} • Ctrl+C to stop",
291
+ title="agentdrift watch",
292
+ style="cyan",
293
+ )
294
+ )
295
+ layout["stream"].update(Panel("Waiting for executions…", title="Execution stream"))
296
+ layout["alert"].update(Panel("No drift detected.", title="Alerts", style="dim"))
297
+
298
+ def poll_json(method: str, path: str, payload: object = None):
299
+ try:
300
+ if method == "GET":
301
+ r = requests.get(f"{DAEMON}{path}", timeout=min(TIMEOUT, interval + 5))
302
+ else:
303
+ r = requests.post(
304
+ f"{DAEMON}{path}", json=payload, timeout=min(TIMEOUT, interval + 5)
305
+ )
306
+ r.raise_for_status()
307
+ return r.json()
308
+ except requests.RequestException:
309
+ return None
310
+
311
+ def render() -> Layout:
312
+ stream_panel = Panel(
313
+ Text("\n").join(stream) if stream else Text("Waiting for executions…", style="dim"),
314
+ title="Execution stream",
315
+ )
316
+ layout["stream"].update(stream_panel)
317
+ if last_alert:
318
+ layout["alert"].update(
319
+ Panel(last_alert, title=f"Alerts ({alert_count})", style="bold red")
320
+ )
321
+ return layout
322
+
323
+ try:
324
+ with Live(render(), console=console, refresh_per_second=4, transient=False) as live:
325
+ while True:
326
+ exes = poll_json("GET", "/v1/executions?limit=200")
327
+ if exes is None:
328
+ if online:
329
+ online = False
330
+ stream.append(Text("connection lost — retrying…", style="yellow"))
331
+ layout["header"].update(
332
+ Panel(
333
+ f"Daemon: {DAEMON} • OFFLINE, retrying every {interval:g}s • Ctrl+C to stop",
334
+ title="agentdrift watch",
335
+ style="red",
336
+ )
337
+ )
338
+ live.update(render())
339
+ time.sleep(interval)
340
+ continue
341
+ online = True
342
+ layout["header"].update(
343
+ Panel(
344
+ f"Daemon: {DAEMON} • filter: {filt} • every {interval:g}s"
345
+ f" • threshold {threshold} • alerts {alert_count} • Ctrl+C to stop",
346
+ title="agentdrift watch",
347
+ style="cyan",
348
+ )
349
+ )
350
+ if isinstance(exes, list):
351
+ ordered = sorted(
352
+ exes, key=lambda e: e.get("sequence", 0) if isinstance(e, dict) else 0
353
+ )
354
+ for exe in ordered:
355
+ if not isinstance(exe, dict):
356
+ continue
357
+ if session_id and exe.get("session_id") != session_id:
358
+ continue
359
+ eid = str(exe.get("execution_id", ""))
360
+ if not eid or eid in seen_exe:
361
+ continue
362
+ seen_exe.add(eid)
363
+ stream.append(
364
+ Text.from_markup(
365
+ f"[dim]{_short_time(exe.get('created_at'))}[/dim] "
366
+ f"[bold]\\[seq {exe.get('sequence', '?')}] "
367
+ f"{exe.get('agent_id', '?')}:{exe.get('node_id', '?')}[/bold]"
368
+ f" -> {_summarize_payload(exe)}"
369
+ )
370
+ )
371
+
372
+ scan = poll_json("POST", "/v1/sentinel/scan", {"threshold": threshold})
373
+ loops: list = []
374
+ if isinstance(scan, dict) and scan.get("loops"):
375
+ loops = [lp for lp in scan["loops"] if isinstance(lp, dict)]
376
+ if session_id:
377
+ loops = [lp for lp in loops if lp.get("session_id") == session_id]
378
+ else:
379
+ dets = poll_json("GET", "/v1/detections?limit=200")
380
+ if isinstance(dets, list):
381
+ for d in dets:
382
+ if not isinstance(d, dict) or d.get("kind") != "ping_pong_loop":
383
+ continue
384
+ if session_id and d.get("session_id") != session_id:
385
+ continue
386
+ loops.append(
387
+ {
388
+ "session_id": d.get("session_id"),
389
+ "execution_id": d.get("execution_id"),
390
+ "prior_execution_id": d.get("prior_execution_id"),
391
+ "similarity": d.get("similarity"),
392
+ }
393
+ )
394
+ for loop in loops:
395
+ key = (str(loop.get("session_id")), str(loop.get("execution_id")))
396
+ if key in known_loops:
397
+ continue
398
+ known_loops.add(key)
399
+ alert_count += 1
400
+ last_alert = (
401
+ f"🚨 DRIFT DETECTED: Ping-Pong Loop in Session {loop.get('session_id')} "
402
+ f"between execution {loop.get('prior_execution_id')} and {loop.get('execution_id')} "
403
+ f"(similarity: {loop.get('similarity')})"
404
+ )
405
+ if bell:
406
+ try:
407
+ console.bell()
408
+ except Exception:
409
+ pass
410
+ stream.append(Text("🚨 drift alert — see Alerts pane", style="bold red"))
411
+
412
+ live.update(render())
413
+ time.sleep(interval)
414
+ except KeyboardInterrupt:
415
+ console.print("[dim]Stopped watching.[/dim]")
416
+
417
+
418
+ def _short_time(value: object) -> str:
419
+ """Format an ISO timestamp as HH:MM:SS; fall back to now."""
420
+ if isinstance(value, str) and value:
421
+ try:
422
+ return (
423
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
424
+ .astimezone()
425
+ .strftime("%H:%M:%S")
426
+ )
427
+ except ValueError:
428
+ pass
429
+ return datetime.now().strftime("%H:%M:%S")
430
+
431
+
432
+ def _summarize_payload(exe: dict, width: int = 100) -> str:
433
+ """One-line summary of an execution's payload for the tail view."""
434
+ for key in ("output_payload", "input_payload"):
435
+ payload = exe.get(key)
436
+ if payload:
437
+ try:
438
+ text = (
439
+ payload
440
+ if isinstance(payload, str)
441
+ else json.dumps(payload, sort_keys=True, default=str)
442
+ )
443
+ except (TypeError, ValueError):
444
+ text = str(payload)
445
+ text = " ".join(text.split())
446
+ if exe.get("state_hash"):
447
+ text += f" [state={exe.get('state_hash')}]"
448
+ return text[:width] + ("…" if len(text) > width else "")
449
+ if exe.get("state_hash"):
450
+ return f"[state={exe.get('state_hash')}]"
451
+ return "(empty payload)"
452
+
453
+
454
+ @app.command(name="test-all")
455
+ def test_all() -> None:
456
+ """End-to-end checks: health → ingest x2 → scan → evaluate → scorecard."""
457
+ scorecard: list[tuple[str, bool, str]] = []
458
+
459
+ # 1. /health
460
+ try:
461
+ h = _get("/health")
462
+ ok = isinstance(h, dict) and h.get("status") == "ok"
463
+ scorecard.append(("health", ok, str(h.get("version", "?")) if ok else repr(h)[:120]))
464
+ except typer.Exit:
465
+ scorecard.append(("health", False, "connection failed"))
466
+ _print_scorecard(scorecard)
467
+ raise
468
+
469
+ # 2. Ingest two consecutive identical events (ping-pong trigger)
470
+ session_id = f"cli-e2e-{uuid.uuid4().hex[:8]}"
471
+ ping_hash = f"cli-ping-{uuid.uuid4().hex[:8]}"
472
+ event_payload = {"state": "waiting-for-tool", "action": "repeat-check"}
473
+ ingest_ok = True
474
+ ingest_detail = ""
475
+ for i in range(2):
476
+ try:
477
+ res = _post(
478
+ "/v1/ingest",
479
+ {
480
+ "executions": [
481
+ {
482
+ "session_id": session_id,
483
+ "agent_id": "cli-e2e-agent",
484
+ "node_id": "e2e-node",
485
+ "input_payload": {"goal": "cli e2e loop check"},
486
+ "output_payload": event_payload,
487
+ "state_hash": ping_hash,
488
+ }
489
+ ]
490
+ },
491
+ )
492
+ if not (isinstance(res, dict) and res.get("persisted", 0) >= 1):
493
+ ingest_ok = False
494
+ ingest_detail = repr(res)[:120]
495
+ except typer.Exit:
496
+ ingest_ok = False
497
+ ingest_detail = "connection failed"
498
+ break
499
+ scorecard.append(("ingest x2", ingest_ok, "2 events persisted" if ingest_ok else ingest_detail))
500
+
501
+ # 3. Sentinel scan asserts >= 1 detection
502
+ try:
503
+ s = _post("/v1/sentinel/scan", {"threshold": 0.92})
504
+ dets = int(s.get("detections", 0) or 0) if isinstance(s, dict) else 0
505
+ scorecard.append(("sentinel scan", dets >= 1, f"detections={dets}"))
506
+ except typer.Exit:
507
+ scorecard.append(("sentinel scan", False, "connection failed"))
508
+
509
+ # 4. NLI evaluate scoring
510
+ try:
511
+ e = _post(
512
+ "/v1/evaluate",
513
+ [
514
+ {
515
+ "id": "cli-e2e-nli",
516
+ "premise": "the cat sits on the mat",
517
+ "hypothesis": "the cat sits on the mat",
518
+ }
519
+ ],
520
+ )
521
+ results = e.get("results", []) if isinstance(e, dict) else []
522
+ nli_ok = bool(results) and all(
523
+ all(k in r for k in ("entailment", "neutral", "contradiction", "label", "margin"))
524
+ for r in results
525
+ )
526
+ label = results[0].get("label", "?") if results else "?"
527
+ scorecard.append(("nli evaluate", nli_ok, f"label={label}" if nli_ok else repr(e)[:120]))
528
+ except typer.Exit:
529
+ scorecard.append(("nli evaluate", False, "connection failed"))
530
+
531
+ _print_scorecard(scorecard)
532
+ if not all(passed for _, passed, _ in scorecard):
533
+ raise typer.Exit(1)
534
+
535
+
536
+ def _print_scorecard(scorecard: list[tuple[str, bool, str]]) -> None:
537
+ table = Table(title="AgentDrift E2E scorecard", box=box.ROUNDED)
538
+ table.add_column("Check", style="bold cyan")
539
+ table.add_column("Result")
540
+ table.add_column("Detail")
541
+ for name, passed, detail in scorecard:
542
+ table.add_row(name, "[green]PASS[/green]" if passed else "[red]FAIL[/red]", detail)
543
+ console.print(table)
544
+ if all(p for _, p, _ in scorecard):
545
+ console.print(Panel("All checks passed.", style="green"))
546
+ else:
547
+ console.print(Panel("One or more checks failed — see scorecard.", style="red"))
548
+
549
+
550
+ def main() -> None:
551
+ """Console-script entrypoint (`agentdrift` command)."""
552
+ app()
553
+
554
+
555
+ if __name__ == "__main__":
556
+ main()
@@ -0,0 +1 @@
1
+ """Shared workspace memory domain."""