trace-agent-server 0.0.1__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.
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any
5
+
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from trace_agent_server.models import Event, Run
10
+ from trace_agent_server.schemas import (
11
+ DashboardResponse,
12
+ DashboardStats,
13
+ DashboardTrendItem,
14
+ DashboardTopTool,
15
+ DashboardTopError,
16
+ )
17
+
18
+
19
+ def build_dashboard(session: Session, period: str) -> DashboardResponse:
20
+ now = datetime.now(timezone.utc)
21
+ if period == "24h":
22
+ cutoff = now - timedelta(hours=24)
23
+ elif period == "7d":
24
+ cutoff = now - timedelta(days=7)
25
+ else: # 30d
26
+ cutoff = now - timedelta(days=30)
27
+
28
+ # Base query for runs in period
29
+ base_query = select(Run).where(Run.started_at >= cutoff)
30
+ runs = list(session.scalars(base_query))
31
+
32
+ total = len(runs)
33
+ completed = sum(1 for r in runs if r.status == "completed")
34
+ failed = sum(1 for r in runs if r.status == "failed")
35
+ running = sum(1 for r in runs if r.status == "running")
36
+ total_errors = sum(r.error_count for r in runs)
37
+ total_tools = sum(r.tool_count for r in runs)
38
+ total_artifacts = sum(r.artifact_count for r in runs)
39
+ total_prompt = sum(r.total_prompt_tokens for r in runs)
40
+ total_completion = sum(r.total_completion_tokens for r in runs)
41
+
42
+ durations = [
43
+ int((r.ended_at - r.started_at).total_seconds() * 1000)
44
+ for r in runs
45
+ if r.ended_at and r.started_at
46
+ ]
47
+ avg_duration = int(sum(durations) / len(durations)) if durations else None
48
+
49
+ # Trend: daily counts for last 7 days
50
+ trend = []
51
+ for i in range(6, -1, -1):
52
+ day_start = (now - timedelta(days=i)).replace(hour=0, minute=0, second=0, microsecond=0)
53
+ day_end = day_start + timedelta(days=1)
54
+ day_runs = list(session.scalars(
55
+ select(Run).where(Run.started_at >= day_start, Run.started_at < day_end)
56
+ ))
57
+ trend.append(DashboardTrendItem(
58
+ date=day_start.strftime("%Y-%m-%d"),
59
+ runs=len(day_runs),
60
+ completed=sum(1 for r in day_runs if r.status == "completed"),
61
+ failed=sum(1 for r in day_runs if r.status == "failed"),
62
+ ))
63
+
64
+ # Top tools from events
65
+ tool_stats = session.execute(
66
+ select(Event.tool_name, func.count(Event.id), func.avg(Event.duration_ms))
67
+ .where(Event.type.in_(["tool.succeeded", "tool.failed"]), Event.timestamp >= cutoff)
68
+ .group_by(Event.tool_name)
69
+ .order_by(func.count(Event.id).desc())
70
+ .limit(10)
71
+ ).all()
72
+ top_tools = [
73
+ DashboardTopTool(tool_name=name or "unknown", call_count=count, avg_duration_ms=int(avg_ms or 0))
74
+ for name, count, avg_ms in tool_stats
75
+ ]
76
+
77
+ # Top errors
78
+ error_stats = session.execute(
79
+ select(Event.error_code, func.count(Event.id))
80
+ .where(Event.error_code.isnot(None), Event.timestamp >= cutoff)
81
+ .group_by(Event.error_code)
82
+ .order_by(func.count(Event.id).desc())
83
+ .limit(10)
84
+ ).all()
85
+ top_errors = [
86
+ DashboardTopError(error_code=code, count=count)
87
+ for code, count in error_stats
88
+ ]
89
+
90
+ # Provider distribution
91
+ provider_dist = session.execute(
92
+ select(Run.provider, func.count(Run.id))
93
+ .where(Run.started_at >= cutoff)
94
+ .group_by(Run.provider)
95
+ ).all()
96
+ provider_distribution = [{"provider": p or "unknown", "count": c} for p, c in provider_dist]
97
+
98
+ # Model distribution
99
+ model_dist = session.execute(
100
+ select(Run.model, func.count(Run.id))
101
+ .where(Run.started_at >= cutoff)
102
+ .group_by(Run.model)
103
+ ).all()
104
+ model_distribution = [{"model": m, "count": c} for m, c in model_dist if m]
105
+
106
+ return DashboardResponse(
107
+ stats=DashboardStats(
108
+ total_runs=total,
109
+ completed_runs=completed,
110
+ failed_runs=failed,
111
+ running_runs=running,
112
+ total_errors=total_errors,
113
+ total_tools=total_tools,
114
+ total_artifacts=total_artifacts,
115
+ avg_duration_ms=avg_duration,
116
+ total_prompt_tokens=total_prompt,
117
+ total_completion_tokens=total_completion,
118
+ total_tokens=total_prompt + total_completion,
119
+ ),
120
+ trend=trend,
121
+ top_tools=top_tools,
122
+ top_errors=top_errors,
123
+ provider_distribution=provider_distribution,
124
+ model_distribution=model_distribution,
125
+ )
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from sqlalchemy.orm import Session
8
+
9
+ from trace_agent_server.models import Artifact, Event, Run
10
+
11
+
12
+ def utcnow() -> datetime:
13
+ return datetime.now(timezone.utc)
14
+
15
+
16
+ # In-memory pub/sub for SSE (run_id -> list of queues)
17
+ _sse_queues: dict[str, list[asyncio.Queue]] = {}
18
+
19
+
20
+ def _get_sse_queues(run_id: str) -> list[asyncio.Queue]:
21
+ return _sse_queues.get(run_id, [])
22
+
23
+
24
+ def _register_sse_queue(run_id: str, queue: asyncio.Queue) -> None:
25
+ if run_id not in _sse_queues:
26
+ _sse_queues[run_id] = []
27
+ _sse_queues[run_id].append(queue)
28
+
29
+
30
+ def _unregister_sse_queue(run_id: str, queue: asyncio.Queue) -> None:
31
+ if run_id in _sse_queues:
32
+ _sse_queues[run_id] = [q for q in _sse_queues[run_id] if q is not queue]
33
+ if not _sse_queues[run_id]:
34
+ del _sse_queues[run_id]
35
+
36
+
37
+ def _notify_sse_listeners(run_id: str, event_data: dict[str, Any]) -> None:
38
+ queues = _get_sse_queues(run_id)
39
+ for queue in queues:
40
+ try:
41
+ queue.put_nowait(event_data)
42
+ except asyncio.QueueFull:
43
+ pass
44
+
45
+
46
+ def record_event(
47
+ session: Session,
48
+ run: Run,
49
+ *,
50
+ actor: str,
51
+ event_type: str,
52
+ summary: str,
53
+ status: str | None = None,
54
+ step_id: str | None = None,
55
+ parent_step_id: str | None = None,
56
+ duration_ms: int | None = None,
57
+ provider: str | None = None,
58
+ model: str | None = None,
59
+ tool_name: str | None = None,
60
+ artifact_refs: list[Any] | None = None,
61
+ error_code: str | None = None,
62
+ payload_hash: str | None = None,
63
+ payload_full: dict[str, Any] | None = None,
64
+ metadata: dict[str, Any] | None = None,
65
+ turn_id: str | None = None,
66
+ ) -> Event:
67
+ run.last_event_seq += 1
68
+ run.updated_at = utcnow()
69
+ event = Event(
70
+ run_id=run.id,
71
+ turn_id=turn_id,
72
+ seq=run.last_event_seq,
73
+ timestamp=utcnow(),
74
+ step_id=step_id,
75
+ parent_step_id=parent_step_id,
76
+ actor=actor,
77
+ type=event_type,
78
+ summary=summary,
79
+ status=status,
80
+ duration_ms=duration_ms,
81
+ provider=provider,
82
+ model=model,
83
+ tool_name=tool_name,
84
+ artifact_refs=artifact_refs or [],
85
+ error_code=error_code,
86
+ payload_hash=payload_hash,
87
+ payload_full=payload_full,
88
+ event_metadata=metadata or {},
89
+ )
90
+ session.add(event)
91
+ session.flush()
92
+ _notify_sse_listeners(
93
+ run.id,
94
+ {
95
+ "id": event.id,
96
+ "seq": event.seq,
97
+ "type": event.type,
98
+ "actor": event.actor,
99
+ "summary": event.summary,
100
+ "status": event.status,
101
+ "step_id": event.step_id,
102
+ "tool_name": event.tool_name,
103
+ "timestamp": event.timestamp.isoformat() if event.timestamp else None,
104
+ },
105
+ )
106
+ return event
107
+
108
+
109
+ def create_artifact(
110
+ session: Session,
111
+ run: Run,
112
+ *,
113
+ source_event_id: int | None,
114
+ kind: str,
115
+ label: str,
116
+ summary: str,
117
+ content_hash: str | None,
118
+ metadata: dict[str, Any] | None = None,
119
+ ) -> Artifact:
120
+ artifact = Artifact(
121
+ run_id=run.id,
122
+ source_event_id=source_event_id,
123
+ kind=kind,
124
+ label=label,
125
+ summary=summary,
126
+ content_hash=content_hash,
127
+ artifact_metadata=metadata or {},
128
+ )
129
+ session.add(artifact)
130
+ session.flush()
131
+ return artifact
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import Any
5
+
6
+ from sqlalchemy import delete, select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from trace_agent_server.models import Decision, Event, Run
10
+
11
+
12
+ def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
13
+ events = list(session.scalars(select(Event).where(Event.run_id == run.id).order_by(Event.seq)))
14
+ session.execute(delete(Decision).where(Decision.run_id == run.id))
15
+ session.flush()
16
+
17
+ flags: list[dict[str, Any]] = []
18
+ seen_flags: set[tuple[str, str]] = set()
19
+ loop_tracker: dict[tuple[str, str], list[Event]] = defaultdict(list)
20
+ failure_streaks: dict[str, int] = defaultdict(int)
21
+ last_failure_by_tool: dict[str, Event] = {}
22
+ last_failure_by_command: dict[str, Event] = {}
23
+ retry_count = 0
24
+
25
+ def add_flag(flag_type: str, label: str, event_ids: list[int], metadata: dict[str, Any] | None = None) -> None:
26
+ key = (flag_type, label)
27
+ if key in seen_flags:
28
+ return
29
+ seen_flags.add(key)
30
+ flags.append({"type": flag_type, "label": label, "event_ids": event_ids, "metadata": metadata or {}})
31
+
32
+ def add_decision(
33
+ *,
34
+ seq: int,
35
+ kind: str,
36
+ label: str,
37
+ chosen_action: str,
38
+ rationale_summary: str,
39
+ evidence_event_ids: list[int],
40
+ trigger_event_ids: list[int] | None = None,
41
+ outcome: str | None = None,
42
+ metadata: dict[str, Any] | None = None,
43
+ ) -> None:
44
+ session.add(
45
+ Decision(
46
+ run_id=run.id,
47
+ seq=seq,
48
+ kind=kind,
49
+ label=label,
50
+ chosen_action=chosen_action,
51
+ rationale_summary=rationale_summary,
52
+ trigger_event_ids=trigger_event_ids or evidence_event_ids,
53
+ evidence_event_ids=evidence_event_ids,
54
+ alternatives=[],
55
+ outcome=outcome,
56
+ decision_metadata=metadata or {},
57
+ )
58
+ )
59
+
60
+ for event in events:
61
+ if event.type == "tool.requested":
62
+ tool_name = event.tool_name or "unknown tool"
63
+ add_decision(
64
+ seq=event.seq,
65
+ kind="tool_choice",
66
+ label=f"Selected {tool_name}",
67
+ chosen_action=tool_name,
68
+ rationale_summary=f"The agent changed state by requesting {tool_name}.",
69
+ evidence_event_ids=[event.id],
70
+ outcome="requested",
71
+ )
72
+ key = (tool_name, event.summary)
73
+ loop_tracker[key].append(event)
74
+ if len(loop_tracker[key]) >= 3:
75
+ add_flag(
76
+ "loop",
77
+ f"{tool_name} repeated {len(loop_tracker[key])} times without a new observable branch.",
78
+ [item.id for item in loop_tracker[key][-3:]],
79
+ {"tool_name": tool_name},
80
+ )
81
+ if tool_name in last_failure_by_tool:
82
+ failed_event = last_failure_by_tool[tool_name]
83
+ retry_count += 1
84
+ add_decision(
85
+ seq=event.seq,
86
+ kind="retry",
87
+ label=f"Retried {tool_name}",
88
+ chosen_action=tool_name,
89
+ rationale_summary=f"The agent retried {tool_name} after a previous failure.",
90
+ evidence_event_ids=[failed_event.id, event.id],
91
+ outcome="retry",
92
+ )
93
+
94
+ if event.type == "tool.failed":
95
+ tool_name = event.tool_name or "unknown tool"
96
+ last_failure_by_tool[tool_name] = event
97
+ failure_streaks[event.step_id or tool_name] += 1
98
+ if failure_streaks[event.step_id or tool_name] >= 2:
99
+ add_flag(
100
+ "blocker",
101
+ f"{tool_name} failed repeatedly on the same step.",
102
+ [event.id],
103
+ {"tool_name": tool_name, "step_id": event.step_id},
104
+ )
105
+
106
+ if event.type == "tool.succeeded":
107
+ tool_name = event.tool_name or "unknown tool"
108
+ failure_streaks[event.step_id or tool_name] = 0
109
+ if tool_name in last_failure_by_tool:
110
+ failed_event = last_failure_by_tool.pop(tool_name)
111
+ add_decision(
112
+ seq=event.seq,
113
+ kind="recovery",
114
+ label=f"Recovered {tool_name}",
115
+ chosen_action=tool_name,
116
+ rationale_summary=f"The agent recovered by completing {tool_name} after a prior failure.",
117
+ evidence_event_ids=[failed_event.id, event.id],
118
+ outcome="succeeded",
119
+ )
120
+
121
+ if event.type == "command.started":
122
+ command_name = str(event.event_metadata.get("command") or "unknown command")
123
+ add_decision(
124
+ seq=event.seq,
125
+ kind="command",
126
+ label=f"Ran command {command_name}",
127
+ chosen_action=command_name,
128
+ rationale_summary=f"The agent executed command {command_name}.",
129
+ evidence_event_ids=[event.id],
130
+ outcome="started",
131
+ )
132
+
133
+ if event.type == "command.failed":
134
+ command_name = str(event.event_metadata.get("command") or "unknown command")
135
+ last_failure_by_command[command_name] = event
136
+ failure_streaks[event.step_id or command_name] += 1
137
+ add_flag(
138
+ "command_failure",
139
+ f"{command_name} failed and may have blocked the run.",
140
+ [event.id],
141
+ {"command": command_name, "step_id": event.step_id},
142
+ )
143
+
144
+ if event.type == "command.succeeded":
145
+ command_name = str(event.event_metadata.get("command") or "unknown command")
146
+ failure_streaks[event.step_id or command_name] = 0
147
+ if command_name in last_failure_by_command:
148
+ failed_event = last_failure_by_command.pop(command_name)
149
+ add_decision(
150
+ seq=event.seq,
151
+ kind="recovery",
152
+ label=f"Recovered command {command_name}",
153
+ chosen_action=command_name,
154
+ rationale_summary=f"The agent recovered by re-running {command_name} successfully.",
155
+ evidence_event_ids=[failed_event.id, event.id],
156
+ outcome="succeeded",
157
+ )
158
+
159
+ if event.type == "run.completed":
160
+ add_decision(
161
+ seq=event.seq,
162
+ kind="completion",
163
+ label="Completed run",
164
+ chosen_action="complete",
165
+ rationale_summary="The run ended with a final answer.",
166
+ evidence_event_ids=[event.id],
167
+ outcome="completed",
168
+ )
169
+ if event.type == "run.failed":
170
+ add_decision(
171
+ seq=event.seq,
172
+ kind="termination",
173
+ label="Failed run",
174
+ chosen_action="fail",
175
+ rationale_summary="The run ended after unresolved errors.",
176
+ evidence_event_ids=[event.id],
177
+ outcome="failed",
178
+ )
179
+
180
+ session.flush()
181
+ ordered_decisions = list(session.scalars(select(Decision).where(Decision.run_id == run.id).order_by(Decision.seq)))
182
+ narrative: list[dict[str, Any]] = []
183
+ if events:
184
+ narrative.append({"text": f"The run started with goal: {run.goal}", "evidence_event_ids": [events[0].id]})
185
+ if run.tool_count:
186
+ tool_names = sorted({event.tool_name for event in events if event.tool_name})
187
+ narrative.append(
188
+ {
189
+ "text": f"The agent executed {run.tool_count} tool call(s) across {len(tool_names)} tool(s): {', '.join(tool_names)}.",
190
+ "evidence_event_ids": [event.id for event in events if event.type in {"tool.succeeded", "tool.failed"}],
191
+ }
192
+ )
193
+ command_events = [event for event in events if event.type in {"command.succeeded", "command.failed"}]
194
+ if command_events:
195
+ command_names = sorted({str(event.event_metadata.get("command") or "unknown command") for event in command_events})
196
+ narrative.append(
197
+ {
198
+ "text": f"The agent executed {len(command_events)} command result(s): {', '.join(command_names[:4])}.",
199
+ "evidence_event_ids": [event.id for event in command_events],
200
+ }
201
+ )
202
+ if flags:
203
+ narrative.append(
204
+ {
205
+ "text": "Observed blockers or loops were detected and attached to evidence below.",
206
+ "evidence_event_ids": [event_id for flag in flags for event_id in flag["event_ids"]],
207
+ }
208
+ )
209
+ if ordered_decisions:
210
+ first_labels = ", ".join(decision.label for decision in ordered_decisions[:3])
211
+ narrative.append(
212
+ {
213
+ "text": f"Key turning points: {first_labels}.",
214
+ "evidence_event_ids": [event_id for decision in ordered_decisions[:3] for event_id in decision.evidence_event_ids],
215
+ }
216
+ )
217
+ if run.final_output_summary:
218
+ tail_event = events[-1] if events else None
219
+ narrative.append(
220
+ {
221
+ "text": f"Final outcome: {run.final_output_summary}",
222
+ "evidence_event_ids": [tail_event.id] if tail_event else [],
223
+ }
224
+ )
225
+
226
+ run.retry_count = retry_count
227
+ run.flag_cache = flags
228
+ run.explanation_cache = {"narrative": narrative}
229
+ session.flush()
230
+ return {"narrative": narrative, "flags": flags, "decisions": ordered_decisions}
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from trace_agent_server.models import Artifact, Decision, Event
6
+
7
+
8
+ def build_execution_graph(events: list[Event], artifacts: list[Artifact]) -> dict[str, Any]:
9
+ nodes: list[dict[str, Any]] = []
10
+ edges: list[dict[str, Any]] = []
11
+ x = 0.0
12
+ y = 0.0
13
+
14
+ for index, event in enumerate(events):
15
+ nodes.append(
16
+ {
17
+ "id": f"event-{event.id}",
18
+ "type": "event",
19
+ "label": event.type,
20
+ "position": {"x": x, "y": y},
21
+ "data": {
22
+ "summary": event.summary,
23
+ "eventId": event.id,
24
+ "toolName": event.tool_name,
25
+ "status": event.status,
26
+ "evidenceIds": [event.id],
27
+ },
28
+ }
29
+ )
30
+ if index > 0:
31
+ edges.append(
32
+ {
33
+ "id": f"edge-event-{events[index - 1].id}-{event.id}",
34
+ "source": f"event-{events[index - 1].id}",
35
+ "target": f"event-{event.id}",
36
+ "label": None,
37
+ "animated": event.type in {"tool.failed", "run.failed"},
38
+ }
39
+ )
40
+ x += 220
41
+ if (index + 1) % 4 == 0:
42
+ x = 0.0
43
+ y += 170
44
+
45
+ artifact_y = y + 200
46
+ for index, artifact in enumerate(artifacts):
47
+ nodes.append(
48
+ {
49
+ "id": f"artifact-{artifact.id}",
50
+ "type": "artifact",
51
+ "label": artifact.label,
52
+ "position": {"x": float(index * 220), "y": artifact_y},
53
+ "data": {
54
+ "summary": artifact.summary,
55
+ "artifactId": artifact.id,
56
+ "kind": artifact.kind,
57
+ "evidenceIds": [artifact.source_event_id] if artifact.source_event_id else [],
58
+ },
59
+ }
60
+ )
61
+ if artifact.source_event_id:
62
+ edges.append(
63
+ {
64
+ "id": f"edge-artifact-{artifact.id}",
65
+ "source": f"event-{artifact.source_event_id}",
66
+ "target": f"artifact-{artifact.id}",
67
+ "label": "artifact",
68
+ "animated": False,
69
+ }
70
+ )
71
+ return {"nodes": nodes, "edges": edges}
72
+
73
+
74
+ def build_decision_graph(decisions: list[Decision]) -> dict[str, Any]:
75
+ nodes: list[dict[str, Any]] = []
76
+ edges: list[dict[str, Any]] = []
77
+ for index, decision in enumerate(decisions):
78
+ nodes.append(
79
+ {
80
+ "id": f"decision-{decision.id}",
81
+ "type": "decision",
82
+ "label": decision.label,
83
+ "position": {"x": float(index * 240), "y": 60.0 if index % 2 == 0 else 180.0},
84
+ "data": {
85
+ "kind": decision.kind,
86
+ "summary": decision.rationale_summary,
87
+ "decisionId": decision.id,
88
+ "evidenceIds": decision.evidence_event_ids,
89
+ "outcome": decision.outcome,
90
+ },
91
+ }
92
+ )
93
+ if index > 0:
94
+ edges.append(
95
+ {
96
+ "id": f"edge-decision-{decisions[index - 1].id}-{decision.id}",
97
+ "source": f"decision-{decisions[index - 1].id}",
98
+ "target": f"decision-{decision.id}",
99
+ "label": decision.kind,
100
+ "animated": decision.kind in {"retry", "recovery"},
101
+ }
102
+ )
103
+ return {"nodes": nodes, "edges": edges}