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.
- trace_agent_server/__init__.py +5 -0
- trace_agent_server/api/__init__.py +1 -0
- trace_agent_server/api/runs.py +1214 -0
- trace_agent_server/cli.py +29 -0
- trace_agent_server/config.py +57 -0
- trace_agent_server/db.py +24 -0
- trace_agent_server/main.py +73 -0
- trace_agent_server/models.py +131 -0
- trace_agent_server/schemas.py +549 -0
- trace_agent_server/services/__init__.py +1 -0
- trace_agent_server/services/analytics.py +305 -0
- trace_agent_server/services/audit_guardrails.py +303 -0
- trace_agent_server/services/compare.py +851 -0
- trace_agent_server/services/dashboard.py +125 -0
- trace_agent_server/services/events.py +131 -0
- trace_agent_server/services/explanation.py +230 -0
- trace_agent_server/services/graph.py +103 -0
- trace_agent_server/services/observability.py +140 -0
- trace_agent_server/services/provider.py +547 -0
- trace_agent_server/services/run_summary.py +363 -0
- trace_agent_server-0.0.1.dist-info/METADATA +21 -0
- trace_agent_server-0.0.1.dist-info/RECORD +25 -0
- trace_agent_server-0.0.1.dist-info/WHEEL +5 -0
- trace_agent_server-0.0.1.dist-info/entry_points.txt +2 -0
- trace_agent_server-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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}
|