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 +7 -0
- agentdrift/api/__init__.py +1 -0
- agentdrift/api/workspace.py +88 -0
- agentdrift/cli.py +556 -0
- agentdrift/core/__init__.py +1 -0
- agentdrift/core/workspace_repository.py +233 -0
- agentdrift/core/workspace_service.py +269 -0
- agentdrift/mcp/__init__.py +1 -0
- agentdrift/mcp/cloud_server.py +749 -0
- agentdrift/mcp/server.py +679 -0
- agentdrift/mcp/workspace_tools.py +129 -0
- agentdrift/storage/__init__.py +14 -0
- agentdrift/storage/base.py +47 -0
- agentdrift/storage/factory.py +23 -0
- agentdrift/storage/local.py +97 -0
- agentdrift/storage/supabase.py +276 -0
- agentdrift_mcp-2.0.0.dist-info/METADATA +304 -0
- agentdrift_mcp-2.0.0.dist-info/RECORD +36 -0
- agentdrift_mcp-2.0.0.dist-info/WHEEL +5 -0
- agentdrift_mcp-2.0.0.dist-info/entry_points.txt +3 -0
- agentdrift_mcp-2.0.0.dist-info/licenses/LICENSE +201 -0
- agentdrift_mcp-2.0.0.dist-info/top_level.txt +4 -0
- app/__init__.py +0 -0
- app/config.py +54 -0
- app/database.py +44 -0
- app/drift.py +139 -0
- app/embeddings.py +163 -0
- app/hybrid_store.py +716 -0
- app/main.py +790 -0
- app/models.py +73 -0
- app/routers/__init__.py +0 -0
- app/routers/api.py +179 -0
- app/schemas.py +109 -0
- app/store.py +504 -0
- cli.py +7 -0
- mcp_server.py +7 -0
agentdrift/__init__.py
ADDED
|
@@ -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."""
|