agent-loop-guard-runtime 0.6.0a2__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.
Files changed (76) hide show
  1. agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
  2. agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
  3. agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
  4. agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
  5. agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
  6. agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
  7. app/__init__.py +6 -0
  8. app/api/__init__.py +1 -0
  9. app/api/admin_routes.py +179 -0
  10. app/api/anthropic_routes.py +21 -0
  11. app/api/common.py +457 -0
  12. app/api/mcp_routes.py +218 -0
  13. app/api/openai_routes.py +26 -0
  14. app/api/replay_routes.py +202 -0
  15. app/api/ui_routes.py +295 -0
  16. app/benchmark/__init__.py +2 -0
  17. app/benchmark/adapters.py +97 -0
  18. app/benchmark/data/starter-v1.json +38 -0
  19. app/benchmark/dataset.py +59 -0
  20. app/benchmark/models.py +46 -0
  21. app/benchmark/runner.py +63 -0
  22. app/benchmark/scorers.py +34 -0
  23. app/benchmark/statistics.py +48 -0
  24. app/benchmark/storage.py +78 -0
  25. app/cli.py +624 -0
  26. app/core/config.py +196 -0
  27. app/core/demo.py +109 -0
  28. app/core/loop_detector.py +120 -0
  29. app/core/policy_engine.py +167 -0
  30. app/core/redaction.py +84 -0
  31. app/core/security.py +54 -0
  32. app/core/token_meter.py +67 -0
  33. app/db/models.py +296 -0
  34. app/db/repository.py +1488 -0
  35. app/db/session.py +57 -0
  36. app/main.py +59 -0
  37. app/mcp/__init__.py +1 -0
  38. app/mcp/gateway.py +230 -0
  39. app/mcp/policy.py +281 -0
  40. app/mcp/presets/development.yml +25 -0
  41. app/mcp/presets/filesystem.yml +18 -0
  42. app/mcp/stdio.py +142 -0
  43. app/platform/__init__.py +1 -0
  44. app/platform/alembic/__init__.py +2 -0
  45. app/platform/alembic/env.py +38 -0
  46. app/platform/alembic/script.py.mako +24 -0
  47. app/platform/alembic/versions/0001_initial_schema.py +18 -0
  48. app/platform/alembic/versions/__init__.py +2 -0
  49. app/platform/events.py +44 -0
  50. app/platform/maintenance.py +138 -0
  51. app/platform/migrations.py +24 -0
  52. app/platform/setup.py +92 -0
  53. app/providers/__init__.py +5 -0
  54. app/providers/base.py +23 -0
  55. app/providers/mock.py +169 -0
  56. app/providers/upstream.py +101 -0
  57. app/replay/__init__.py +1 -0
  58. app/replay/costs.py +37 -0
  59. app/replay/formats.py +87 -0
  60. app/replay/sdk.py +102 -0
  61. app/sandbox/__init__.py +2 -0
  62. app/sandbox/policy.py +22 -0
  63. app/sandbox/workspace.py +281 -0
  64. app/static/styles.css +327 -0
  65. app/templates/agents.html +48 -0
  66. app/templates/base.html +27 -0
  67. app/templates/dashboard.html +55 -0
  68. app/templates/demo.html +36 -0
  69. app/templates/mcp.html +69 -0
  70. app/templates/policies.html +30 -0
  71. app/templates/replay.html +63 -0
  72. app/templates/replay_compare.html +74 -0
  73. app/templates/replay_detail.html +130 -0
  74. app/templates/session_detail.html +71 -0
  75. app/templates/sessions.html +24 -0
  76. app/templates/settings.html +20 -0
app/db/repository.py ADDED
@@ -0,0 +1,1488 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any
8
+
9
+ from sqlalchemy import desc, func, select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.core.config import AppConfig
13
+ from app.core.redaction import redact_text
14
+ from app.db.models import (
15
+ Agent,
16
+ Event,
17
+ GuardSession,
18
+ MCPApproval,
19
+ MCPDecisionEvent,
20
+ MCPServer,
21
+ MCPSession,
22
+ MCPToolSnapshot,
23
+ Policy,
24
+ Project,
25
+ RequestRecord,
26
+ ToolCall,
27
+ TraceArtifact,
28
+ TraceEvent,
29
+ TraceRun,
30
+ TraceSpan,
31
+ utcnow,
32
+ )
33
+ from app.replay.costs import estimate_cost_micros
34
+
35
+
36
+ def new_id(prefix: str) -> str:
37
+ return f"{prefix}_{uuid.uuid4().hex[:24]}"
38
+
39
+
40
+ def key_hash(key: str) -> str:
41
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()
42
+
43
+
44
+ def _json_dumps(value: Any) -> str:
45
+ try:
46
+ raw = json.dumps(value or {}, ensure_ascii=False, sort_keys=True)
47
+ except TypeError:
48
+ raw = json.dumps({"value": str(value)}, ensure_ascii=False, sort_keys=True)
49
+ return redact_text(raw) or "{}"
50
+
51
+
52
+ def _json_loads(value: str | None) -> dict[str, Any]:
53
+ if not value:
54
+ return {}
55
+ try:
56
+ loaded = json.loads(value)
57
+ except json.JSONDecodeError:
58
+ return {"raw": value}
59
+ return loaded if isinstance(loaded, dict) else {"value": loaded}
60
+
61
+
62
+ def _dt_to_ns(value: datetime) -> int:
63
+ return int(_aware(value).timestamp() * 1_000_000_000)
64
+
65
+
66
+ def _ns_to_iso(value: int | None) -> str | None:
67
+ if value is None:
68
+ return None
69
+ return datetime.fromtimestamp(value / 1_000_000_000, UTC).isoformat()
70
+
71
+
72
+ def _now_ns() -> int:
73
+ return _dt_to_ns(utcnow())
74
+
75
+
76
+ def _aware(value: datetime) -> datetime:
77
+ if value.tzinfo is None:
78
+ return value.replace(tzinfo=UTC)
79
+ return value
80
+
81
+
82
+ def default_policies(project_id: str) -> list[Policy]:
83
+ return [
84
+ Policy(
85
+ id="rule_exact_repeat",
86
+ project_id=project_id,
87
+ rule_type="exact_repeat",
88
+ threshold=3,
89
+ window_size=8,
90
+ action="block",
91
+ enabled=True,
92
+ ),
93
+ Policy(
94
+ id="rule_tool_repeat",
95
+ project_id=project_id,
96
+ rule_type="tool_repeat",
97
+ threshold=3,
98
+ window_size=8,
99
+ action="block",
100
+ enabled=True,
101
+ ),
102
+ Policy(
103
+ id="rule_error_retry",
104
+ project_id=project_id,
105
+ rule_type="error_retry",
106
+ threshold=5,
107
+ window_size=5,
108
+ action="block",
109
+ enabled=True,
110
+ ),
111
+ Policy(
112
+ id="rule_sequence",
113
+ project_id=project_id,
114
+ rule_type="sequence_repeat",
115
+ threshold=3,
116
+ window_size=15,
117
+ action="block",
118
+ enabled=True,
119
+ ),
120
+ Policy(
121
+ id="rule_max_requests",
122
+ project_id=project_id,
123
+ rule_type="max_requests",
124
+ threshold=50,
125
+ window_size=0,
126
+ action="block",
127
+ enabled=True,
128
+ ),
129
+ Policy(
130
+ id="rule_max_tokens",
131
+ project_id=project_id,
132
+ rule_type="max_tokens",
133
+ threshold=100_000,
134
+ window_size=0,
135
+ action="block",
136
+ enabled=True,
137
+ ),
138
+ ]
139
+
140
+
141
+ def ensure_seed_data(db: Session, config: AppConfig) -> None:
142
+ project = db.get(Project, config.default_project_id)
143
+ if project is None:
144
+ project = Project(
145
+ id=config.default_project_id,
146
+ name="Default project",
147
+ mode=config.default_mode,
148
+ provider=config.default_provider,
149
+ )
150
+ db.add(project)
151
+
152
+ default_agent = db.get(Agent, "agt_default")
153
+ if default_agent is None:
154
+ default_agent = Agent(
155
+ id="agt_default",
156
+ project_id=config.default_project_id,
157
+ name="Default local agent",
158
+ protocol="openai",
159
+ key_hash=key_hash(config.gateway_key),
160
+ enabled=True,
161
+ )
162
+ db.add(default_agent)
163
+
164
+ existing_policy_count = db.scalar(
165
+ select(func.count(Policy.id)).where(Policy.project_id == config.default_project_id)
166
+ )
167
+ if not existing_policy_count:
168
+ db.add_all(default_policies(config.default_project_id))
169
+
170
+ for server_id, row in config.mcp_servers.items():
171
+ server = db.get(MCPServer, server_id)
172
+ target = str(row.get("target") or "mock://filesystem")
173
+ if server is None:
174
+ db.add(
175
+ MCPServer(
176
+ id=server_id,
177
+ name=str(row.get("name") or server_id),
178
+ transport=str(row.get("transport") or "mock"),
179
+ target=target,
180
+ fingerprint=key_hash(target),
181
+ )
182
+ )
183
+
184
+ db.commit()
185
+
186
+
187
+ class Repository:
188
+ def __init__(self, db: Session):
189
+ self.db = db
190
+
191
+ def agent_for_key(self, raw_key: str) -> Agent | None:
192
+ digest = key_hash(raw_key)
193
+ return self.db.scalar(
194
+ select(Agent).where(Agent.key_hash == digest, Agent.enabled.is_(True)).limit(1)
195
+ )
196
+
197
+ def project(self, project_id: str) -> Project | None:
198
+ return self.db.get(Project, project_id)
199
+
200
+ def mcp_server(self, server_id: str) -> MCPServer | None:
201
+ return self.db.get(MCPServer, server_id)
202
+
203
+ def ensure_mcp_server(
204
+ self, server_id: str, name: str, transport: str, target: str
205
+ ) -> MCPServer:
206
+ server = self.db.get(MCPServer, server_id)
207
+ if server is None:
208
+ server = MCPServer(
209
+ id=server_id,
210
+ name=name,
211
+ transport=transport,
212
+ target=target,
213
+ fingerprint=key_hash(target),
214
+ )
215
+ self.db.add(server)
216
+ self.db.commit()
217
+ return server
218
+
219
+ def list_mcp_servers(self) -> list[MCPServer]:
220
+ return list(self.db.scalars(select(MCPServer).order_by(MCPServer.name)))
221
+
222
+ def start_mcp_session(
223
+ self,
224
+ server_id: str,
225
+ *,
226
+ client_name: str | None = None,
227
+ protocol_version: str | None = None,
228
+ mode: str = "enforce",
229
+ session_id: str | None = None,
230
+ ) -> MCPSession:
231
+ session = MCPSession(
232
+ id=session_id or new_id("mcp_ses"),
233
+ server_id=server_id,
234
+ client_name=client_name,
235
+ protocol_version=protocol_version,
236
+ mode=mode,
237
+ )
238
+ self.db.add(session)
239
+ self.db.commit()
240
+ return session
241
+
242
+ def end_mcp_session(self, session_id: str) -> MCPSession | None:
243
+ session = self.db.get(MCPSession, session_id)
244
+ if session is None:
245
+ return None
246
+ session.ended_at = utcnow()
247
+ self.db.commit()
248
+ return session
249
+
250
+ def record_mcp_tools(
251
+ self, session_id: str, server_id: str, tools: list[dict[str, Any]]
252
+ ) -> None:
253
+ for tool in tools:
254
+ schema = tool.get("inputSchema") or {"type": "object"}
255
+ name = str(tool.get("name") or "unknown")
256
+ schema_json = json.dumps(schema, ensure_ascii=False, sort_keys=True)
257
+ risk_tags = []
258
+ lowered = name.lower()
259
+ if any(token in lowered for token in ("write", "delete", "remove")):
260
+ risk_tags.append("filesystem_write")
261
+ if any(token in lowered for token in ("exec", "shell", "command")):
262
+ risk_tags.append("command_execution")
263
+ self.db.add(
264
+ MCPToolSnapshot(
265
+ id=new_id("mcp_tool"),
266
+ session_id=session_id,
267
+ server_id=server_id,
268
+ tool_name=name,
269
+ schema_hash=key_hash(schema_json),
270
+ schema_json=_json_dumps(schema),
271
+ risk_tags_json=json.dumps(risk_tags),
272
+ )
273
+ )
274
+ self.db.commit()
275
+
276
+ def mcp_tool_schema(self, server_id: str, tool_name: str) -> dict[str, Any] | None:
277
+ snapshot = self.db.scalar(
278
+ select(MCPToolSnapshot)
279
+ .where(
280
+ MCPToolSnapshot.server_id == server_id,
281
+ MCPToolSnapshot.tool_name == tool_name,
282
+ )
283
+ .order_by(desc(MCPToolSnapshot.created_at))
284
+ .limit(1)
285
+ )
286
+ return _json_loads(snapshot.schema_json) if snapshot else None
287
+
288
+ def record_mcp_decision(
289
+ self,
290
+ *,
291
+ server_id: str,
292
+ session_id: str | None,
293
+ request_id: str | int | None,
294
+ tool_name: str,
295
+ argument_hash: str,
296
+ policy_version: str,
297
+ action: str,
298
+ reason: str,
299
+ rule_id: str | None,
300
+ mode: str,
301
+ attributes: dict[str, Any] | None = None,
302
+ ) -> MCPDecisionEvent:
303
+ event = MCPDecisionEvent(
304
+ id=new_id("mcp_evt"),
305
+ session_id=session_id,
306
+ server_id=server_id,
307
+ request_id=str(request_id) if request_id is not None else None,
308
+ tool_name=tool_name,
309
+ argument_hash=argument_hash,
310
+ policy_version=policy_version,
311
+ action=action,
312
+ reason=reason[:300],
313
+ rule_id=rule_id,
314
+ mode=mode,
315
+ attributes_json=_json_dumps(attributes or {}),
316
+ )
317
+ self.db.add(event)
318
+ self.db.commit()
319
+
320
+ now_ns = _now_ns()
321
+ run = self.create_trace_run(
322
+ {
323
+ "project_id": "default",
324
+ "task_id": f"mcp:{server_id}:{request_id or event.id}",
325
+ "agent_name": "mcp-permission-firewall",
326
+ "status": "blocked" if action == "deny" else "ok",
327
+ "failure_tag": "MCP_POLICY_BLOCK" if action == "deny" else None,
328
+ "start_ns": now_ns,
329
+ "end_ns": now_ns,
330
+ "attributes": {"source": "mcp", "mcp.server_id": server_id},
331
+ }
332
+ )
333
+ root = self.trace_spans(run.id)[0]
334
+ span = self.add_trace_span(
335
+ run.id,
336
+ {
337
+ "parent_span_id": root.id,
338
+ "name": "tool.call",
339
+ "status": "blocked" if action == "deny" else "ok",
340
+ "start_ns": now_ns,
341
+ "end_ns": now_ns,
342
+ "attributes": {
343
+ "tool.name": tool_name,
344
+ "tool.arguments_hash": argument_hash,
345
+ "mcp.server_id": server_id,
346
+ "policy.action": action,
347
+ },
348
+ },
349
+ )
350
+ self.add_trace_events(
351
+ run.id,
352
+ [
353
+ {
354
+ "span_id": span.id if span else None,
355
+ "name": "policy.decision",
356
+ "severity": "error" if action == "deny" else "info",
357
+ "timestamp_ns": now_ns,
358
+ "attributes": {
359
+ "engine": "mcp",
360
+ "action": action,
361
+ "rule_id": rule_id,
362
+ "reason": reason,
363
+ "policy_version": policy_version,
364
+ },
365
+ }
366
+ ],
367
+ )
368
+ event.trace_id = run.id
369
+ self.db.commit()
370
+ return event
371
+
372
+ def create_mcp_approval(
373
+ self, decision_event_id: str, argument_hash: str, timeout_seconds: int
374
+ ) -> MCPApproval:
375
+ approval = MCPApproval(
376
+ id=new_id("apr"),
377
+ decision_event_id=decision_event_id,
378
+ status="pending",
379
+ scope="once",
380
+ argument_hash=argument_hash,
381
+ expires_at=utcnow() + timedelta(seconds=max(0, timeout_seconds)),
382
+ )
383
+ self.db.add(approval)
384
+ self.db.commit()
385
+ return approval
386
+
387
+ def decide_mcp_approval(
388
+ self, approval_id: str, status: str, scope: str = "once"
389
+ ) -> MCPApproval | None:
390
+ approval = self.db.get(MCPApproval, approval_id)
391
+ if approval is None or approval.status != "pending" or _aware(approval.expires_at) < utcnow():
392
+ return None
393
+ approval.status = "allowed" if status == "allow" else "denied"
394
+ approval.scope = "session" if scope == "session" else "once"
395
+ approval.decided_at = utcnow()
396
+ self.db.commit()
397
+ return approval
398
+
399
+ def consume_mcp_approval(
400
+ self, server_id: str, tool_name: str, argument_hash: str, session_id: str | None
401
+ ) -> MCPApproval | None:
402
+ statement = (
403
+ select(MCPApproval)
404
+ .join(MCPDecisionEvent, MCPDecisionEvent.id == MCPApproval.decision_event_id)
405
+ .where(
406
+ MCPApproval.status == "allowed",
407
+ MCPApproval.argument_hash == argument_hash,
408
+ MCPApproval.expires_at >= utcnow(),
409
+ MCPDecisionEvent.server_id == server_id,
410
+ MCPDecisionEvent.tool_name == tool_name,
411
+ )
412
+ .order_by(desc(MCPApproval.decided_at))
413
+ )
414
+ if session_id:
415
+ statement = statement.where(MCPDecisionEvent.session_id == session_id)
416
+ approval = self.db.scalar(statement.limit(1))
417
+ if approval and approval.scope == "once":
418
+ approval.status = "consumed"
419
+ self.db.commit()
420
+ return approval
421
+
422
+ def list_mcp_approvals(self, pending_only: bool = False) -> list[MCPApproval]:
423
+ statement = select(MCPApproval)
424
+ if pending_only:
425
+ statement = statement.where(MCPApproval.status == "pending")
426
+ return list(self.db.scalars(statement.order_by(desc(MCPApproval.created_at))))
427
+
428
+ def list_mcp_events(self, limit: int = 200) -> list[MCPDecisionEvent]:
429
+ return list(
430
+ self.db.scalars(
431
+ select(MCPDecisionEvent)
432
+ .order_by(desc(MCPDecisionEvent.created_at))
433
+ .limit(limit)
434
+ )
435
+ )
436
+
437
+ def policies(self, project_id: str) -> list[Policy]:
438
+ return list(
439
+ self.db.scalars(select(Policy).where(Policy.project_id == project_id).order_by(Policy.id))
440
+ )
441
+
442
+ def upsert_policies(self, project_id: str, rows: list[dict[str, Any]]) -> list[Policy]:
443
+ existing = {p.id: p for p in self.policies(project_id)}
444
+ for row in rows:
445
+ policy_id = str(row.get("id") or f"rule_{row['rule_type']}")
446
+ policy = existing.get(policy_id)
447
+ if policy is None:
448
+ policy = Policy(id=policy_id, project_id=project_id, rule_type=str(row["rule_type"]))
449
+ self.db.add(policy)
450
+ policy.threshold = int(row.get("threshold", policy.threshold or 1))
451
+ policy.window_size = int(row.get("window_size", policy.window_size or 0))
452
+ policy.action = str(row.get("action", policy.action or "block"))
453
+ policy.enabled = bool(row.get("enabled", policy.enabled))
454
+ self.db.commit()
455
+ return self.policies(project_id)
456
+
457
+ def create_agent(
458
+ self, project_id: str, name: str, protocol: str = "openai", raw_key: str | None = None
459
+ ) -> tuple[Agent, str]:
460
+ raw_key = raw_key or f"alg_{uuid.uuid4().hex}"
461
+ agent = Agent(
462
+ id=new_id("agt"),
463
+ project_id=project_id,
464
+ name=name,
465
+ protocol=protocol,
466
+ key_hash=key_hash(raw_key),
467
+ enabled=True,
468
+ )
469
+ self.db.add(agent)
470
+ self.db.commit()
471
+ return agent, raw_key
472
+
473
+ def list_agents(self) -> list[Agent]:
474
+ return list(self.db.scalars(select(Agent).order_by(Agent.created_at.desc())))
475
+
476
+ def pause_agent(self, agent_id: str) -> Agent | None:
477
+ agent = self.db.get(Agent, agent_id)
478
+ if agent is None:
479
+ return None
480
+ agent.paused = True
481
+ self.db.commit()
482
+ return agent
483
+
484
+ def resume_agent(self, agent_id: str) -> Agent | None:
485
+ agent = self.db.get(Agent, agent_id)
486
+ if agent is None:
487
+ return None
488
+ agent.paused = False
489
+ self.db.commit()
490
+ return agent
491
+
492
+ def get_or_create_session(
493
+ self,
494
+ project_id: str,
495
+ agent_id: str,
496
+ external_session_id: str | None,
497
+ inactive_timeout_seconds: int,
498
+ ) -> GuardSession:
499
+ query = select(GuardSession).where(
500
+ GuardSession.project_id == project_id,
501
+ GuardSession.agent_id == agent_id,
502
+ GuardSession.status.in_(["active", "paused"]),
503
+ )
504
+ if external_session_id:
505
+ query = query.where(GuardSession.external_session_id == external_session_id)
506
+ else:
507
+ query = query.where(GuardSession.external_session_id.is_(None))
508
+ query = query.order_by(desc(GuardSession.updated_at)).limit(1)
509
+ session = self.db.scalar(query)
510
+ now = utcnow()
511
+ if session is not None:
512
+ if now - _aware(session.updated_at) <= timedelta(seconds=inactive_timeout_seconds):
513
+ return session
514
+ session.status = "ended"
515
+ session.ended_at = now
516
+
517
+ session = GuardSession(
518
+ id=new_id("ses"),
519
+ project_id=project_id,
520
+ agent_id=agent_id,
521
+ external_session_id=external_session_id,
522
+ status="active",
523
+ )
524
+ self.db.add(session)
525
+ self.db.commit()
526
+ return session
527
+
528
+ def get_session(self, session_id: str) -> GuardSession | None:
529
+ return self.db.get(GuardSession, session_id)
530
+
531
+ def list_sessions(self, limit: int = 50) -> list[GuardSession]:
532
+ return list(
533
+ self.db.scalars(select(GuardSession).order_by(desc(GuardSession.updated_at)).limit(limit))
534
+ )
535
+
536
+ def update_session(
537
+ self, session_id: str, *, name: str | None = None, note: str | None = None
538
+ ) -> GuardSession | None:
539
+ session = self.get_session(session_id)
540
+ if session is None:
541
+ return None
542
+ if name is not None:
543
+ session.name = name.strip() or None
544
+ if note is not None:
545
+ session.note = note.strip() or None
546
+ session.updated_at = utcnow()
547
+ self.db.commit()
548
+ return session
549
+
550
+ def pause_session(self, session_id: str) -> GuardSession | None:
551
+ session = self.get_session(session_id)
552
+ if session is None:
553
+ return None
554
+ session.status = "paused"
555
+ session.updated_at = utcnow()
556
+ self.event(session.id, None, "manual_pause", "info", "SESSION_PAUSED", "manual", {})
557
+ self.db.commit()
558
+ return session
559
+
560
+ def resume_session(self, session_id: str) -> GuardSession | None:
561
+ session = self.get_session(session_id)
562
+ if session is None:
563
+ return None
564
+ session.status = "active"
565
+ session.updated_at = utcnow()
566
+ self.event(session.id, None, "manual_resume", "info", "SESSION_RESUMED", "manual", {})
567
+ self.db.commit()
568
+ return session
569
+
570
+ def recent_request_fingerprints(self, session_id: str, limit: int) -> list[str]:
571
+ rows = list(
572
+ self.db.scalars(
573
+ select(RequestRecord.fingerprint)
574
+ .where(RequestRecord.session_id == session_id)
575
+ .order_by(desc(RequestRecord.created_at))
576
+ .limit(limit)
577
+ )
578
+ )
579
+ return list(reversed(rows))
580
+
581
+ def recent_tool_fingerprints(self, session_id: str, limit: int) -> list[str]:
582
+ rows = list(
583
+ self.db.execute(
584
+ select(ToolCall.tool_name, ToolCall.args_hash)
585
+ .join(RequestRecord, RequestRecord.id == ToolCall.request_id)
586
+ .where(RequestRecord.session_id == session_id)
587
+ .order_by(desc(RequestRecord.created_at))
588
+ .limit(limit)
589
+ )
590
+ )
591
+ return [f"{name}:{args_hash}" for name, args_hash in reversed(rows)]
592
+
593
+ def consecutive_error_count(self, session_id: str) -> tuple[str | None, int]:
594
+ rows = list(
595
+ self.db.scalars(
596
+ select(RequestRecord.error_fingerprint)
597
+ .where(RequestRecord.session_id == session_id)
598
+ .order_by(desc(RequestRecord.created_at))
599
+ .limit(20)
600
+ )
601
+ )
602
+ first = rows[0] if rows else None
603
+ if not first:
604
+ return None, 0
605
+ count = 0
606
+ for row in rows:
607
+ if row == first:
608
+ count += 1
609
+ else:
610
+ break
611
+ return first, count
612
+
613
+ def record_request(
614
+ self,
615
+ session: GuardSession,
616
+ protocol: str,
617
+ endpoint: str,
618
+ model: str | None,
619
+ fingerprint: str,
620
+ status: int,
621
+ latency_ms: int,
622
+ tokens: dict[str, int | bool],
623
+ decision: str,
624
+ reason_code: str | None,
625
+ request_preview: str | None,
626
+ response_preview: str | None,
627
+ tool_calls: list[tuple[str, str]],
628
+ error_fingerprint: str | None = None,
629
+ ) -> RequestRecord:
630
+ created_at = utcnow()
631
+ record = RequestRecord(
632
+ id=new_id("req"),
633
+ session_id=session.id,
634
+ protocol=protocol,
635
+ endpoint=endpoint,
636
+ model=model,
637
+ fingerprint=fingerprint,
638
+ error_fingerprint=error_fingerprint,
639
+ status=status,
640
+ latency_ms=latency_ms,
641
+ input_tokens=int(tokens.get("input_tokens", 0)),
642
+ output_tokens=int(tokens.get("output_tokens", 0)),
643
+ total_tokens=int(tokens.get("total_tokens", 0)),
644
+ estimated_tokens=bool(tokens.get("estimated", True)),
645
+ decision=decision,
646
+ reason_code=reason_code,
647
+ request_preview=request_preview,
648
+ response_preview=response_preview,
649
+ created_at=created_at,
650
+ )
651
+ self.db.add(record)
652
+ for name, args_hash in tool_calls:
653
+ self.db.add(
654
+ ToolCall(
655
+ id=new_id("tool"),
656
+ request_id=record.id,
657
+ tool_name=name[:160],
658
+ args_hash=args_hash,
659
+ )
660
+ )
661
+
662
+ session.request_count += 1
663
+ session.input_tokens += record.input_tokens
664
+ session.output_tokens += record.output_tokens
665
+ session.total_tokens += record.total_tokens
666
+ session.updated_at = utcnow()
667
+ if decision == "block":
668
+ session.blocked_count += 1
669
+ elif decision == "shadow_flag":
670
+ session.flagged_count += 1
671
+ self._record_proxy_trace(
672
+ session=session,
673
+ record=record,
674
+ protocol=protocol,
675
+ endpoint=endpoint,
676
+ model=model,
677
+ latency_ms=latency_ms,
678
+ tokens=tokens,
679
+ decision=decision,
680
+ reason_code=reason_code,
681
+ request_preview=request_preview,
682
+ response_preview=response_preview,
683
+ tool_calls=tool_calls,
684
+ error_fingerprint=error_fingerprint,
685
+ )
686
+ self.db.commit()
687
+ return record
688
+
689
+ def ensure_trace_for_session(self, session: GuardSession) -> tuple[TraceRun, TraceSpan]:
690
+ run = self.db.scalar(
691
+ select(TraceRun).where(TraceRun.source_session_id == session.id).limit(1)
692
+ )
693
+ root = None
694
+ if run is not None:
695
+ root = self.db.scalar(
696
+ select(TraceSpan)
697
+ .where(
698
+ TraceSpan.trace_id == run.id,
699
+ TraceSpan.parent_span_id.is_(None),
700
+ TraceSpan.name == "agent.run",
701
+ )
702
+ .limit(1)
703
+ )
704
+
705
+ if run is None:
706
+ agent = self.db.get(Agent, session.agent_id)
707
+ run = TraceRun(
708
+ id=new_id("trc"),
709
+ source_session_id=session.id,
710
+ project_id=session.project_id,
711
+ task_id=session.external_session_id,
712
+ task_fingerprint=key_hash(session.external_session_id or session.id),
713
+ agent_name=agent.name if agent else session.agent_id,
714
+ status="running" if session.status == "active" else session.status,
715
+ input_tokens=session.input_tokens,
716
+ output_tokens=session.output_tokens,
717
+ total_tokens=session.total_tokens,
718
+ duration_ms=max(0, int((utcnow() - _aware(session.started_at)).total_seconds() * 1000)),
719
+ span_count=1,
720
+ event_count=0,
721
+ attributes_json=_json_dumps(
722
+ {
723
+ "source": "agent_loop_guard",
724
+ "session.id": session.id,
725
+ "session.external_id": session.external_session_id,
726
+ "project.id": session.project_id,
727
+ "agent.id": session.agent_id,
728
+ }
729
+ ),
730
+ )
731
+ self.db.add(run)
732
+ root = TraceSpan(
733
+ id=new_id("spn"),
734
+ trace_id=run.id,
735
+ parent_span_id=None,
736
+ name="agent.run",
737
+ status="ok",
738
+ start_ns=_dt_to_ns(session.started_at),
739
+ end_ns=None,
740
+ duration_ms=run.duration_ms,
741
+ attributes_json=_json_dumps(
742
+ {
743
+ "agent.name": run.agent_name,
744
+ "project.id": session.project_id,
745
+ "session.id": session.id,
746
+ "task.id": session.external_session_id,
747
+ }
748
+ ),
749
+ )
750
+ self.db.add(root)
751
+ elif root is None:
752
+ root = TraceSpan(
753
+ id=new_id("spn"),
754
+ trace_id=run.id,
755
+ parent_span_id=None,
756
+ name="agent.run",
757
+ status="ok",
758
+ start_ns=_dt_to_ns(session.started_at),
759
+ end_ns=None,
760
+ duration_ms=run.duration_ms,
761
+ attributes_json=_json_dumps({"session.id": session.id}),
762
+ )
763
+ self.db.add(root)
764
+ run.span_count = (run.span_count or 0) + 1
765
+
766
+ run.status = "running" if session.status == "active" else session.status
767
+ run.input_tokens = session.input_tokens
768
+ run.output_tokens = session.output_tokens
769
+ run.total_tokens = session.total_tokens
770
+ run.updated_at = utcnow()
771
+ run.duration_ms = max(0, int((run.updated_at - _aware(session.started_at)).total_seconds() * 1000))
772
+ root.duration_ms = run.duration_ms
773
+ return run, root
774
+
775
+ def _record_proxy_trace(
776
+ self,
777
+ *,
778
+ session: GuardSession,
779
+ record: RequestRecord,
780
+ protocol: str,
781
+ endpoint: str,
782
+ model: str | None,
783
+ latency_ms: int,
784
+ tokens: dict[str, int | bool],
785
+ decision: str,
786
+ reason_code: str | None,
787
+ request_preview: str | None,
788
+ response_preview: str | None,
789
+ tool_calls: list[tuple[str, str]],
790
+ error_fingerprint: str | None,
791
+ ) -> None:
792
+ run, root = self.ensure_trace_for_session(session)
793
+ end_ns = _dt_to_ns(record.created_at)
794
+ start_ns = max(0, end_ns - (latency_ms * 1_000_000))
795
+ span_status = "error" if record.status >= 400 or decision == "block" else "ok"
796
+ if decision == "block":
797
+ span_status = "blocked"
798
+ span = TraceSpan(
799
+ id=new_id("spn"),
800
+ trace_id=run.id,
801
+ parent_span_id=root.id,
802
+ name="gen_ai.request",
803
+ status=span_status,
804
+ start_ns=start_ns,
805
+ end_ns=end_ns,
806
+ duration_ms=latency_ms,
807
+ attributes_json=_json_dumps(
808
+ {
809
+ "request.id": record.id,
810
+ "gen_ai.system": protocol,
811
+ "gen_ai.request.model": model,
812
+ "http.route": endpoint,
813
+ "http.status_code": record.status,
814
+ "input_tokens": int(tokens.get("input_tokens", 0)),
815
+ "output_tokens": int(tokens.get("output_tokens", 0)),
816
+ "total_tokens": int(tokens.get("total_tokens", 0)),
817
+ "tokens.estimated": bool(tokens.get("estimated", True)),
818
+ "policy.decision": decision,
819
+ "policy.reason": reason_code,
820
+ "request.preview": request_preview,
821
+ "response.preview": response_preview,
822
+ "error.fingerprint": error_fingerprint,
823
+ "tool.count": len(tool_calls),
824
+ }
825
+ ),
826
+ )
827
+ self.db.add(span)
828
+ run.span_count = (run.span_count or 0) + 1
829
+ run.model = run.model or model
830
+ estimated_cost, cost_estimated = estimate_cost_micros(
831
+ model,
832
+ int(tokens.get("input_tokens", 0)),
833
+ int(tokens.get("output_tokens", 0)),
834
+ )
835
+ run.total_cost_micros = (run.total_cost_micros or 0) + estimated_cost
836
+ run.attributes_json = _json_dumps(
837
+ {**_json_loads(run.attributes_json), "cost.estimated": cost_estimated}
838
+ )
839
+ run.failure_tag = reason_code or run.failure_tag
840
+ if span_status in {"error", "blocked"}:
841
+ run.status = span_status
842
+ run.updated_at = record.created_at
843
+
844
+ if reason_code:
845
+ self.db.add(
846
+ TraceEvent(
847
+ id=new_id("tev"),
848
+ trace_id=run.id,
849
+ span_id=span.id,
850
+ name="policy.decision",
851
+ severity="error" if decision == "block" else "warning",
852
+ timestamp_ns=end_ns,
853
+ attributes_json=_json_dumps(
854
+ {
855
+ "decision": decision,
856
+ "reason_code": reason_code,
857
+ "request.id": record.id,
858
+ }
859
+ ),
860
+ )
861
+ )
862
+ run.event_count = (run.event_count or 0) + 1
863
+
864
+ for tool_name, args_hash in tool_calls:
865
+ self.db.add(
866
+ TraceEvent(
867
+ id=new_id("tev"),
868
+ trace_id=run.id,
869
+ span_id=span.id,
870
+ name="tool.reference",
871
+ severity="info",
872
+ timestamp_ns=end_ns,
873
+ attributes_json=_json_dumps(
874
+ {"tool.name": tool_name, "tool.arguments_hash": args_hash}
875
+ ),
876
+ )
877
+ )
878
+ run.event_count = (run.event_count or 0) + 1
879
+
880
+ def create_trace_run(self, data: dict[str, Any]) -> TraceRun:
881
+ trace_id = str(data.get("trace_id") or data.get("id") or new_id("trc"))
882
+ run = self.db.get(TraceRun, trace_id)
883
+ if run is None:
884
+ run = TraceRun(id=trace_id)
885
+ self.db.add(run)
886
+ run.project_id = str(data.get("project_id") or run.project_id or "default")
887
+ run.task_id = data.get("task_id") or run.task_id
888
+ task_fingerprint = data.get("task_fingerprint")
889
+ if task_fingerprint is None and run.task_id:
890
+ task_fingerprint = key_hash(str(run.task_id))
891
+ run.task_fingerprint = task_fingerprint or run.task_fingerprint
892
+ run.agent_name = data.get("agent_name") or run.agent_name
893
+ run.model = data.get("model") or run.model
894
+ run.status = str(data.get("status") or run.status or "running")
895
+ run.failure_tag = data.get("failure_tag") or run.failure_tag
896
+ run.input_tokens = int(data.get("input_tokens") or run.input_tokens or 0)
897
+ run.output_tokens = int(data.get("output_tokens") or run.output_tokens or 0)
898
+ run.total_tokens = int(data.get("total_tokens") or run.total_tokens or 0)
899
+ run.total_cost_micros = int(data.get("total_cost_micros") or run.total_cost_micros or 0)
900
+ run.pinned = bool(data.get("pinned", run.pinned))
901
+ run.attributes_json = _json_dumps(data.get("attributes") or _json_loads(run.attributes_json))
902
+ run.updated_at = utcnow()
903
+
904
+ if not self.db.scalar(select(TraceSpan.id).where(TraceSpan.trace_id == run.id).limit(1)):
905
+ self.db.add(
906
+ TraceSpan(
907
+ id=new_id("spn"),
908
+ trace_id=run.id,
909
+ parent_span_id=None,
910
+ name="agent.run",
911
+ status=run.status if run.status != "running" else "ok",
912
+ start_ns=int(data.get("start_ns") or _now_ns()),
913
+ end_ns=data.get("end_ns"),
914
+ duration_ms=int(data.get("duration_ms") or 0),
915
+ attributes_json=_json_dumps(
916
+ {
917
+ "agent.name": run.agent_name,
918
+ "project.id": run.project_id,
919
+ "task.id": run.task_id,
920
+ }
921
+ ),
922
+ )
923
+ )
924
+ run.span_count = 1
925
+
926
+ self.db.commit()
927
+ self.refresh_trace_summary(run.id)
928
+ return self.db.get(TraceRun, run.id) or run
929
+
930
+ def add_trace_span(self, trace_id: str, data: dict[str, Any]) -> TraceSpan | None:
931
+ run = self.db.get(TraceRun, trace_id)
932
+ if run is None:
933
+ return None
934
+ span_id = str(data.get("span_id") or data.get("id") or new_id("spn"))
935
+ span = self.db.get(TraceSpan, span_id)
936
+ if span is None:
937
+ span = TraceSpan(id=span_id, trace_id=trace_id, start_ns=int(data.get("start_ns") or _now_ns()), name="span")
938
+ self.db.add(span)
939
+ span.trace_id = trace_id
940
+ span.parent_span_id = data.get("parent_span_id")
941
+ span.name = str(data.get("name") or span.name)
942
+ span.status = str(data.get("status") or span.status or "ok")
943
+ span.start_ns = int(data.get("start_ns") or span.start_ns or _now_ns())
944
+ end_ns = data.get("end_ns")
945
+ span.end_ns = int(end_ns) if end_ns is not None else None
946
+ if span.end_ns is not None:
947
+ span.duration_ms = max(0, int((span.end_ns - span.start_ns) / 1_000_000))
948
+ else:
949
+ span.duration_ms = int(data.get("duration_ms") or span.duration_ms or 0)
950
+ span.attributes_json = _json_dumps(data.get("attributes") or _json_loads(span.attributes_json))
951
+ run.updated_at = utcnow()
952
+ self.db.commit()
953
+ self.refresh_trace_summary(trace_id)
954
+ return self.db.get(TraceSpan, span.id) or span
955
+
956
+ def add_trace_events(self, trace_id: str, rows: list[dict[str, Any]]) -> list[TraceEvent] | None:
957
+ run = self.db.get(TraceRun, trace_id)
958
+ if run is None:
959
+ return None
960
+ events: list[TraceEvent] = []
961
+ for row in rows:
962
+ event = TraceEvent(
963
+ id=str(row.get("event_id") or row.get("id") or new_id("tev")),
964
+ trace_id=trace_id,
965
+ span_id=row.get("span_id"),
966
+ name=str(row.get("name") or row.get("type") or "event"),
967
+ severity=str(row.get("severity") or "info"),
968
+ timestamp_ns=int(row.get("timestamp_ns") or _now_ns()),
969
+ attributes_json=_json_dumps(row.get("attributes") or row.get("details") or {}),
970
+ )
971
+ self.db.add(event)
972
+ events.append(event)
973
+ run.updated_at = utcnow()
974
+ self.db.commit()
975
+ self.refresh_trace_summary(trace_id)
976
+ return events
977
+
978
+ def add_trace_artifact(self, trace_id: str, data: dict[str, Any]) -> TraceArtifact | None:
979
+ run = self.db.get(TraceRun, trace_id)
980
+ if run is None:
981
+ return None
982
+ artifact = TraceArtifact(
983
+ id=str(data.get("artifact_id") or data.get("id") or new_id("art")),
984
+ trace_id=trace_id,
985
+ path=str(data.get("path") or ""),
986
+ mime_type=data.get("mime_type"),
987
+ size=int(data.get("size") or 0),
988
+ sha256=data.get("sha256"),
989
+ attributes_json=_json_dumps(data.get("attributes") or {}),
990
+ )
991
+ self.db.add(artifact)
992
+ run.updated_at = utcnow()
993
+ self.db.commit()
994
+ return artifact
995
+
996
+ def refresh_trace_summary(self, trace_id: str) -> None:
997
+ run = self.db.get(TraceRun, trace_id)
998
+ if run is None:
999
+ return
1000
+ run.span_count = int(
1001
+ self.db.scalar(select(func.count(TraceSpan.id)).where(TraceSpan.trace_id == trace_id))
1002
+ or 0
1003
+ )
1004
+ run.event_count = int(
1005
+ self.db.scalar(select(func.count(TraceEvent.id)).where(TraceEvent.trace_id == trace_id))
1006
+ or 0
1007
+ )
1008
+ first_start = self.db.scalar(
1009
+ select(func.min(TraceSpan.start_ns)).where(TraceSpan.trace_id == trace_id)
1010
+ )
1011
+ last_end = self.db.scalar(
1012
+ select(func.max(func.coalesce(TraceSpan.end_ns, TraceSpan.start_ns))).where(
1013
+ TraceSpan.trace_id == trace_id
1014
+ )
1015
+ )
1016
+ if first_start is not None and last_end is not None:
1017
+ run.duration_ms = max(0, int((int(last_end) - int(first_start)) / 1_000_000))
1018
+ if not run.failure_tag:
1019
+ run.failure_tag = self._classify_trace_failure(trace_id)
1020
+ run.updated_at = utcnow()
1021
+ self.db.commit()
1022
+
1023
+ def _classify_trace_failure(self, trace_id: str) -> str | None:
1024
+ spans = self.trace_spans(trace_id)
1025
+ events = self.trace_events(trace_id)
1026
+ details = " ".join(
1027
+ [span.name + " " + span.status + " " + span.attributes_json for span in spans]
1028
+ + [event.name + " " + event.attributes_json for event in events]
1029
+ ).lower()
1030
+ if "loop_" in details or "loop" in details:
1031
+ return "loop"
1032
+ if "timeout" in details:
1033
+ return "timeout"
1034
+ if "rate_limit" in details or "rate limit" in details:
1035
+ return "rate_limit"
1036
+ if "policy" in details and ("blocked" in details or '"action": "deny"' in details):
1037
+ return "policy_block"
1038
+ if "test" in details and ("failed" in details or "error" in details):
1039
+ return "test_failure"
1040
+ return None
1041
+
1042
+ def pin_trace(self, trace_id: str, pinned: bool = True) -> TraceRun | None:
1043
+ run = self.db.get(TraceRun, trace_id)
1044
+ if run is None:
1045
+ return None
1046
+ run.pinned = pinned
1047
+ run.updated_at = utcnow()
1048
+ self.db.commit()
1049
+ return run
1050
+
1051
+ def list_trace_runs(
1052
+ self, limit: int = 50, project_id: str | None = None, query: str | None = None
1053
+ ) -> list[TraceRun]:
1054
+ statement = select(TraceRun)
1055
+ if project_id:
1056
+ statement = statement.where(TraceRun.project_id == project_id)
1057
+ if query:
1058
+ pattern = f"%{query}%"
1059
+ statement = statement.where(
1060
+ TraceRun.id.like(pattern)
1061
+ | TraceRun.task_id.like(pattern)
1062
+ | TraceRun.agent_name.like(pattern)
1063
+ | TraceRun.model.like(pattern)
1064
+ | TraceRun.failure_tag.like(pattern)
1065
+ )
1066
+ statement = statement.order_by(desc(TraceRun.updated_at)).limit(limit)
1067
+ return list(self.db.scalars(statement))
1068
+
1069
+ def get_trace_run(self, trace_id: str) -> TraceRun | None:
1070
+ return self.db.get(TraceRun, trace_id)
1071
+
1072
+ def trace_spans(self, trace_id: str) -> list[TraceSpan]:
1073
+ return list(
1074
+ self.db.scalars(
1075
+ select(TraceSpan).where(TraceSpan.trace_id == trace_id).order_by(TraceSpan.start_ns)
1076
+ )
1077
+ )
1078
+
1079
+ def trace_events(self, trace_id: str) -> list[TraceEvent]:
1080
+ return list(
1081
+ self.db.scalars(
1082
+ select(TraceEvent)
1083
+ .where(TraceEvent.trace_id == trace_id)
1084
+ .order_by(TraceEvent.timestamp_ns)
1085
+ )
1086
+ )
1087
+
1088
+ def trace_artifacts(self, trace_id: str) -> list[TraceArtifact]:
1089
+ return list(
1090
+ self.db.scalars(
1091
+ select(TraceArtifact)
1092
+ .where(TraceArtifact.trace_id == trace_id)
1093
+ .order_by(TraceArtifact.created_at)
1094
+ )
1095
+ )
1096
+
1097
+ def trace_export(self, trace_id: str) -> dict[str, Any] | None:
1098
+ run = self.get_trace_run(trace_id)
1099
+ if run is None:
1100
+ return None
1101
+ return {
1102
+ "schema_version": "trace.v1",
1103
+ "run": trace_run_dict(run),
1104
+ "spans": [trace_span_dict(item) for item in self.trace_spans(trace_id)],
1105
+ "events": [trace_event_dict(item) for item in self.trace_events(trace_id)],
1106
+ "artifacts": [trace_artifact_dict(item) for item in self.trace_artifacts(trace_id)],
1107
+ }
1108
+
1109
+ def import_trace_bundle(self, bundle: dict[str, Any]) -> TraceRun:
1110
+ run_data = dict(bundle.get("run") or {})
1111
+ if not run_data:
1112
+ raise ValueError("bundle.run is required")
1113
+
1114
+ requested_id = str(run_data.get("id") or run_data.get("trace_id") or new_id("trc"))
1115
+ trace_id = requested_id if self.get_trace_run(requested_id) is None else new_id("trc")
1116
+ run_data["trace_id"] = trace_id
1117
+ run_data.pop("id", None)
1118
+ run = self.create_trace_run(run_data)
1119
+ root = next(
1120
+ span
1121
+ for span in self.trace_spans(run.id)
1122
+ if span.parent_span_id is None and span.name == "agent.run"
1123
+ )
1124
+
1125
+ source_spans = [dict(item) for item in bundle.get("spans", [])]
1126
+ source_root_ids = {
1127
+ str(item.get("id") or item.get("span_id"))
1128
+ for item in source_spans
1129
+ if item.get("name") == "agent.run" and item.get("parent_span_id") is None
1130
+ }
1131
+ id_map = {source_id: root.id for source_id in source_root_ids}
1132
+ for item in source_spans:
1133
+ source_id = str(item.get("id") or item.get("span_id") or new_id("source"))
1134
+ id_map.setdefault(source_id, new_id("spn"))
1135
+
1136
+ pending = [
1137
+ item
1138
+ for item in source_spans
1139
+ if str(item.get("id") or item.get("span_id")) not in source_root_ids
1140
+ ]
1141
+ inserted = set(source_root_ids)
1142
+ while pending:
1143
+ ready = [
1144
+ item
1145
+ for item in pending
1146
+ if not item.get("parent_span_id")
1147
+ or str(item["parent_span_id"]) in inserted
1148
+ or str(item["parent_span_id"]) not in id_map
1149
+ ]
1150
+ if not ready:
1151
+ ready = [pending[0]]
1152
+ for item in ready:
1153
+ source_id = str(item.get("id") or item.get("span_id"))
1154
+ parent_id = str(item.get("parent_span_id") or "")
1155
+ span_data = dict(item)
1156
+ span_data["span_id"] = id_map[source_id]
1157
+ span_data["parent_span_id"] = id_map.get(parent_id, root.id)
1158
+ self.add_trace_span(run.id, span_data)
1159
+ inserted.add(source_id)
1160
+ pending.remove(item)
1161
+
1162
+ events = []
1163
+ for item in bundle.get("events", []):
1164
+ event = dict(item)
1165
+ source_span_id = str(event.get("span_id") or "")
1166
+ event["event_id"] = new_id("tev")
1167
+ event["span_id"] = id_map.get(source_span_id, root.id)
1168
+ events.append(event)
1169
+ if events:
1170
+ self.add_trace_events(run.id, events)
1171
+
1172
+ for item in bundle.get("artifacts", []):
1173
+ artifact = dict(item)
1174
+ artifact["artifact_id"] = new_id("art")
1175
+ self.add_trace_artifact(run.id, artifact)
1176
+ return self.get_trace_run(run.id) or run
1177
+
1178
+ def compare_traces(self, left_id: str, right_id: str) -> dict[str, Any] | None:
1179
+ left = self.get_trace_run(left_id)
1180
+ right = self.get_trace_run(right_id)
1181
+ if left is None or right is None:
1182
+ return None
1183
+ fields = ("duration_ms", "input_tokens", "output_tokens", "total_tokens", "total_cost_micros", "span_count", "event_count")
1184
+ deltas = {field: getattr(right, field) - getattr(left, field) for field in fields}
1185
+ left_spans = self.trace_spans(left_id)
1186
+ right_spans = self.trace_spans(right_id)
1187
+ left_by_name: dict[str, int] = {}
1188
+ right_by_name: dict[str, int] = {}
1189
+ for span in left_spans:
1190
+ left_by_name[span.name] = left_by_name.get(span.name, 0) + 1
1191
+ for span in right_spans:
1192
+ right_by_name[span.name] = right_by_name.get(span.name, 0) + 1
1193
+ span_name_deltas = {
1194
+ name: right_by_name.get(name, 0) - left_by_name.get(name, 0)
1195
+ for name in sorted(set(left_by_name) | set(right_by_name))
1196
+ }
1197
+ left_occurrences: dict[str, int] = {}
1198
+ right_occurrences: dict[str, int] = {}
1199
+
1200
+ def keyed(spans: list[TraceSpan], counters: dict[str, int]) -> dict[str, TraceSpan]:
1201
+ result: dict[str, TraceSpan] = {}
1202
+ for span in spans:
1203
+ counters[span.name] = counters.get(span.name, 0) + 1
1204
+ result[f"{span.name}#{counters[span.name]}"] = span
1205
+ return result
1206
+
1207
+ left_keyed = keyed(left_spans, left_occurrences)
1208
+ right_keyed = keyed(right_spans, right_occurrences)
1209
+ aligned_steps = []
1210
+ for key in sorted(set(left_keyed) | set(right_keyed)):
1211
+ left_span = left_keyed.get(key)
1212
+ right_span = right_keyed.get(key)
1213
+ aligned_steps.append(
1214
+ {
1215
+ "step": key,
1216
+ "left_status": left_span.status if left_span else None,
1217
+ "right_status": right_span.status if right_span else None,
1218
+ "left_duration_ms": left_span.duration_ms if left_span else None,
1219
+ "right_duration_ms": right_span.duration_ms if right_span else None,
1220
+ "duration_delta_ms": (
1221
+ right_span.duration_ms - left_span.duration_ms
1222
+ if left_span and right_span
1223
+ else None
1224
+ ),
1225
+ }
1226
+ )
1227
+ return {
1228
+ "schema_version": "trace.compare.v1",
1229
+ "left": trace_run_dict(left),
1230
+ "right": trace_run_dict(right),
1231
+ "delta": deltas,
1232
+ "span_name_delta": span_name_deltas,
1233
+ "aligned_steps": aligned_steps,
1234
+ }
1235
+
1236
+ def event(
1237
+ self,
1238
+ session_id: str,
1239
+ request_id: str | None,
1240
+ event_type: str,
1241
+ severity: str,
1242
+ reason_code: str | None,
1243
+ mode: str,
1244
+ payload: dict[str, Any],
1245
+ rule_id: str | None = None,
1246
+ ) -> Event:
1247
+ event = Event(
1248
+ id=new_id("evt"),
1249
+ session_id=session_id,
1250
+ request_id=request_id,
1251
+ type=event_type,
1252
+ rule_id=rule_id,
1253
+ severity=severity,
1254
+ reason_code=reason_code,
1255
+ mode=mode,
1256
+ payload_json=json.dumps(payload, sort_keys=True),
1257
+ )
1258
+ self.db.add(event)
1259
+ self.db.commit()
1260
+ return event
1261
+
1262
+ def events_for_session(self, session_id: str) -> list[Event]:
1263
+ return list(
1264
+ self.db.scalars(select(Event).where(Event.session_id == session_id).order_by(Event.created_at))
1265
+ )
1266
+
1267
+ def requests_for_session(self, session_id: str) -> list[RequestRecord]:
1268
+ return list(
1269
+ self.db.scalars(
1270
+ select(RequestRecord)
1271
+ .where(RequestRecord.session_id == session_id)
1272
+ .order_by(RequestRecord.created_at)
1273
+ )
1274
+ )
1275
+
1276
+ def aggregate_stats(self) -> dict[str, int]:
1277
+ sessions = int(self.db.scalar(select(func.count(GuardSession.id))) or 0)
1278
+ requests = int(self.db.scalar(select(func.count(RequestRecord.id))) or 0)
1279
+ traces = int(self.db.scalar(select(func.count(TraceRun.id))) or 0)
1280
+ spans = int(self.db.scalar(select(func.count(TraceSpan.id))) or 0)
1281
+ trace_events = int(self.db.scalar(select(func.count(TraceEvent.id))) or 0)
1282
+ flags = int(self.db.scalar(select(func.coalesce(func.sum(GuardSession.flagged_count), 0))) or 0)
1283
+ blocks = int(self.db.scalar(select(func.coalesce(func.sum(GuardSession.blocked_count), 0))) or 0)
1284
+ tokens = int(self.db.scalar(select(func.coalesce(func.sum(GuardSession.total_tokens), 0))) or 0)
1285
+ return {
1286
+ "sessions": sessions,
1287
+ "requests": requests,
1288
+ "traces": traces,
1289
+ "spans": spans,
1290
+ "trace_events": trace_events,
1291
+ "flags": flags,
1292
+ "blocks": blocks,
1293
+ "tokens": tokens,
1294
+ }
1295
+
1296
+
1297
+ def event_dict(event: Event) -> dict[str, Any]:
1298
+ return {
1299
+ "schema_version": "1.0",
1300
+ "event_id": event.id,
1301
+ "session_id": event.session_id,
1302
+ "request_id": event.request_id,
1303
+ "type": event.type,
1304
+ "severity": event.severity,
1305
+ "rule_id": event.rule_id,
1306
+ "reason_code": event.reason_code,
1307
+ "mode": event.mode,
1308
+ "created_at": event.created_at.isoformat(),
1309
+ "details": json.loads(event.payload_json or "{}"),
1310
+ }
1311
+
1312
+
1313
+ def request_dict(record: RequestRecord) -> dict[str, Any]:
1314
+ return {
1315
+ "id": record.id,
1316
+ "protocol": record.protocol,
1317
+ "endpoint": record.endpoint,
1318
+ "model": record.model,
1319
+ "fingerprint": record.fingerprint,
1320
+ "error_fingerprint": record.error_fingerprint,
1321
+ "status": record.status,
1322
+ "latency_ms": record.latency_ms,
1323
+ "input_tokens": record.input_tokens,
1324
+ "output_tokens": record.output_tokens,
1325
+ "total_tokens": record.total_tokens,
1326
+ "estimated_tokens": record.estimated_tokens,
1327
+ "decision": record.decision,
1328
+ "reason_code": record.reason_code,
1329
+ "created_at": record.created_at.isoformat(),
1330
+ }
1331
+
1332
+
1333
+ def session_dict(session: GuardSession) -> dict[str, Any]:
1334
+ return {
1335
+ "id": session.id,
1336
+ "project_id": session.project_id,
1337
+ "agent_id": session.agent_id,
1338
+ "external_session_id": session.external_session_id,
1339
+ "status": session.status,
1340
+ "name": session.name,
1341
+ "note": session.note,
1342
+ "started_at": session.started_at.isoformat(),
1343
+ "updated_at": session.updated_at.isoformat(),
1344
+ "ended_at": session.ended_at.isoformat() if session.ended_at else None,
1345
+ "request_count": session.request_count,
1346
+ "input_tokens": session.input_tokens,
1347
+ "output_tokens": session.output_tokens,
1348
+ "total_tokens": session.total_tokens,
1349
+ "flagged_count": session.flagged_count,
1350
+ "blocked_count": session.blocked_count,
1351
+ }
1352
+
1353
+
1354
+ def policy_dict(policy: Policy) -> dict[str, Any]:
1355
+ return {
1356
+ "id": policy.id,
1357
+ "project_id": policy.project_id,
1358
+ "rule_type": policy.rule_type,
1359
+ "threshold": policy.threshold,
1360
+ "window_size": policy.window_size,
1361
+ "action": policy.action,
1362
+ "enabled": policy.enabled,
1363
+ }
1364
+
1365
+
1366
+ def agent_dict(agent: Agent) -> dict[str, Any]:
1367
+ return {
1368
+ "id": agent.id,
1369
+ "project_id": agent.project_id,
1370
+ "name": agent.name,
1371
+ "protocol": agent.protocol,
1372
+ "enabled": agent.enabled,
1373
+ "paused": agent.paused,
1374
+ "created_at": agent.created_at.isoformat(),
1375
+ }
1376
+
1377
+
1378
+ def trace_run_dict(run: TraceRun) -> dict[str, Any]:
1379
+ return {
1380
+ "id": run.id,
1381
+ "source_session_id": run.source_session_id,
1382
+ "project_id": run.project_id,
1383
+ "task_id": run.task_id,
1384
+ "task_fingerprint": run.task_fingerprint,
1385
+ "agent_name": run.agent_name,
1386
+ "model": run.model,
1387
+ "status": run.status,
1388
+ "failure_tag": run.failure_tag,
1389
+ "input_tokens": run.input_tokens,
1390
+ "output_tokens": run.output_tokens,
1391
+ "total_tokens": run.total_tokens,
1392
+ "total_cost_micros": run.total_cost_micros,
1393
+ "duration_ms": run.duration_ms,
1394
+ "span_count": run.span_count,
1395
+ "event_count": run.event_count,
1396
+ "pinned": run.pinned,
1397
+ "attributes": _json_loads(run.attributes_json),
1398
+ "created_at": run.created_at.isoformat(),
1399
+ "updated_at": run.updated_at.isoformat(),
1400
+ }
1401
+
1402
+
1403
+ def trace_span_dict(span: TraceSpan) -> dict[str, Any]:
1404
+ return {
1405
+ "id": span.id,
1406
+ "trace_id": span.trace_id,
1407
+ "parent_span_id": span.parent_span_id,
1408
+ "name": span.name,
1409
+ "status": span.status,
1410
+ "start_ns": span.start_ns,
1411
+ "end_ns": span.end_ns,
1412
+ "start_at": _ns_to_iso(span.start_ns),
1413
+ "end_at": _ns_to_iso(span.end_ns),
1414
+ "duration_ms": span.duration_ms,
1415
+ "attributes": _json_loads(span.attributes_json),
1416
+ }
1417
+
1418
+
1419
+ def trace_event_dict(event: TraceEvent) -> dict[str, Any]:
1420
+ return {
1421
+ "id": event.id,
1422
+ "trace_id": event.trace_id,
1423
+ "span_id": event.span_id,
1424
+ "name": event.name,
1425
+ "severity": event.severity,
1426
+ "timestamp_ns": event.timestamp_ns,
1427
+ "timestamp": _ns_to_iso(event.timestamp_ns),
1428
+ "attributes": _json_loads(event.attributes_json),
1429
+ }
1430
+
1431
+
1432
+ def trace_artifact_dict(artifact: TraceArtifact) -> dict[str, Any]:
1433
+ return {
1434
+ "id": artifact.id,
1435
+ "trace_id": artifact.trace_id,
1436
+ "path": artifact.path,
1437
+ "mime_type": artifact.mime_type,
1438
+ "size": artifact.size,
1439
+ "sha256": artifact.sha256,
1440
+ "attributes": _json_loads(artifact.attributes_json),
1441
+ "created_at": artifact.created_at.isoformat(),
1442
+ }
1443
+
1444
+
1445
+ def mcp_server_dict(server: MCPServer) -> dict[str, Any]:
1446
+ return {
1447
+ "id": server.id,
1448
+ "name": server.name,
1449
+ "transport": server.transport,
1450
+ "target": server.target,
1451
+ "fingerprint": server.fingerprint,
1452
+ "enabled": server.enabled,
1453
+ "first_seen_at": server.first_seen_at.isoformat(),
1454
+ }
1455
+
1456
+
1457
+ def mcp_event_dict(event: MCPDecisionEvent) -> dict[str, Any]:
1458
+ return {
1459
+ "id": event.id,
1460
+ "session_id": event.session_id,
1461
+ "server_id": event.server_id,
1462
+ "trace_id": event.trace_id,
1463
+ "request_id": event.request_id,
1464
+ "tool_name": event.tool_name,
1465
+ "argument_hash": event.argument_hash,
1466
+ "policy_version": event.policy_version,
1467
+ "action": event.action,
1468
+ "reason": event.reason,
1469
+ "rule_id": event.rule_id,
1470
+ "mode": event.mode,
1471
+ "latency_ms": event.latency_ms,
1472
+ "result_status": event.result_status,
1473
+ "attributes": _json_loads(event.attributes_json),
1474
+ "created_at": event.created_at.isoformat(),
1475
+ }
1476
+
1477
+
1478
+ def mcp_approval_dict(approval: MCPApproval) -> dict[str, Any]:
1479
+ return {
1480
+ "id": approval.id,
1481
+ "decision_event_id": approval.decision_event_id,
1482
+ "status": approval.status,
1483
+ "scope": approval.scope,
1484
+ "argument_hash": approval.argument_hash,
1485
+ "expires_at": approval.expires_at.isoformat(),
1486
+ "decided_at": approval.decided_at.isoformat() if approval.decided_at else None,
1487
+ "created_at": approval.created_at.isoformat(),
1488
+ }