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,1214 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
9
+ from fastapi.responses import StreamingResponse
10
+ from sqlalchemy import func, select
11
+ from sqlalchemy.orm import Session
12
+
13
+ from trace_agent_server.db import get_session
14
+ from trace_agent_server.models import Artifact, Decision, Event, Run, Turn
15
+ from trace_agent_sdk.redaction import stable_hash, summarize_value
16
+ from trace_agent_server.schemas import (
17
+ ArtifactOut,
18
+ DashboardResponse,
19
+ DecisionOut,
20
+ EventOut,
21
+ ExplanationResponse,
22
+ FailRunRequest,
23
+ FinishRunRequest,
24
+ GraphResponse,
25
+ ObservationsRequest,
26
+ RunCreateRequest,
27
+ RunAnalyticsResponse,
28
+ RunCompareResponse,
29
+ RunCreateResponse,
30
+ RunListItem,
31
+ RunOverview,
32
+ TimelineGroup,
33
+ TimelineResponse,
34
+ ToolResultsRequest,
35
+ TurnCreateRequest,
36
+ TurnCreateResponse,
37
+ TurnOut,
38
+ )
39
+ from trace_agent_server.services.analytics import build_similar_runs, build_tool_graph, build_tool_metrics
40
+ from trace_agent_server.services.compare import compare_runs
41
+ from trace_agent_server.services.events import create_artifact, record_event
42
+ from trace_agent_server.services.explanation import derive_explanation
43
+ from trace_agent_server.services.graph import build_decision_graph, build_execution_graph
44
+ from trace_agent_server.services.provider import ProviderTurnRequest
45
+ from trace_agent_server.services.run_summary import (
46
+ build_command_chain,
47
+ build_execution_metadata,
48
+ build_file_activity,
49
+ build_patch_activity,
50
+ build_timeline_groups,
51
+ build_tool_chain,
52
+ )
53
+
54
+ router = APIRouter(prefix="/api")
55
+
56
+
57
+ def utcnow() -> datetime:
58
+ return datetime.now(timezone.utc)
59
+
60
+
61
+ def _get_run_or_404(session: Session, run_id: str) -> Run:
62
+ run = session.get(Run, run_id)
63
+ if not run:
64
+ raise HTTPException(status_code=404, detail="Run not found.")
65
+ return run
66
+
67
+
68
+ def _serialize_event(event: Event) -> EventOut:
69
+ return EventOut(
70
+ id=event.id,
71
+ seq=event.seq,
72
+ timestamp=event.timestamp,
73
+ turn_id=event.turn_id,
74
+ step_id=event.step_id,
75
+ parent_step_id=event.parent_step_id,
76
+ actor=event.actor,
77
+ type=event.type,
78
+ summary=event.summary,
79
+ status=event.status,
80
+ duration_ms=event.duration_ms,
81
+ provider=event.provider,
82
+ model=event.model,
83
+ tool_name=event.tool_name,
84
+ artifact_refs=event.artifact_refs,
85
+ error_code=event.error_code,
86
+ payload_hash=event.payload_hash,
87
+ payload_full=event.payload_full,
88
+ metadata=event.event_metadata,
89
+ )
90
+
91
+
92
+ def _serialize_decision(decision: Decision) -> DecisionOut:
93
+ return DecisionOut(
94
+ id=decision.id,
95
+ seq=decision.seq,
96
+ kind=decision.kind,
97
+ label=decision.label,
98
+ chosen_action=decision.chosen_action,
99
+ rationale_summary=decision.rationale_summary,
100
+ trigger_event_ids=decision.trigger_event_ids,
101
+ evidence_event_ids=decision.evidence_event_ids,
102
+ alternatives=decision.alternatives,
103
+ outcome=decision.outcome,
104
+ metadata=decision.decision_metadata,
105
+ )
106
+
107
+
108
+ def _serialize_artifact(artifact: Artifact) -> ArtifactOut:
109
+ return ArtifactOut(
110
+ id=artifact.id,
111
+ kind=artifact.kind,
112
+ label=artifact.label,
113
+ summary=artifact.summary,
114
+ metadata=artifact.artifact_metadata,
115
+ source_event_id=artifact.source_event_id,
116
+ created_at=artifact.created_at,
117
+ )
118
+
119
+
120
+ def _serialize_timeline_group(group: dict[str, Any]) -> TimelineGroup:
121
+ return TimelineGroup(
122
+ step_id=group["step_id"],
123
+ label=group["label"],
124
+ status=group["status"],
125
+ step_type=group["step_type"],
126
+ tool_name=group["tool_name"],
127
+ call_id=group["call_id"],
128
+ parent_step_id=group["parent_step_id"],
129
+ event_count=group["event_count"],
130
+ started_at=group["started_at"],
131
+ ended_at=group["ended_at"],
132
+ duration_ms=group["duration_ms"],
133
+ events=[_serialize_event(event) for event in group["events"]],
134
+ )
135
+
136
+
137
+ def _serialize_run_overview(run: Run, events: list[Event]) -> RunOverview:
138
+ return RunOverview(
139
+ id=run.id,
140
+ agent_name=run.agent_name,
141
+ goal=run.goal,
142
+ status=run.status,
143
+ provider=run.provider,
144
+ model=run.model,
145
+ redaction_level=run.redaction_level,
146
+ tool_count=run.tool_count,
147
+ error_count=run.error_count,
148
+ retry_count=run.retry_count,
149
+ artifact_count=run.artifact_count,
150
+ total_prompt_tokens=run.total_prompt_tokens,
151
+ total_completion_tokens=run.total_completion_tokens,
152
+ started_at=run.started_at,
153
+ ended_at=run.ended_at,
154
+ metadata=run.run_metadata,
155
+ final_output_summary=run.final_output_summary,
156
+ flags=run.flag_cache,
157
+ execution=build_execution_metadata(run, events),
158
+ tool_chain=build_tool_chain(events),
159
+ command_chain=build_command_chain(events),
160
+ file_activity=build_file_activity(events),
161
+ patch_activity=build_patch_activity(events),
162
+ )
163
+
164
+
165
+ @router.get("/runs", response_model=list[RunListItem])
166
+ def list_runs(
167
+ session: Session = Depends(get_session),
168
+ limit: int = Query(default=50, ge=1, le=200),
169
+ offset: int = Query(default=0, ge=0),
170
+ agent_name: str | None = None,
171
+ status: str | None = None,
172
+ provider: str | None = None,
173
+ model: str | None = None,
174
+ search: str | None = None,
175
+ started_after: datetime | None = None,
176
+ started_before: datetime | None = None,
177
+ ) -> list[RunListItem]:
178
+ query = select(Run)
179
+ if agent_name:
180
+ query = query.where(Run.agent_name == agent_name)
181
+ if status:
182
+ query = query.where(Run.status == status)
183
+ if provider:
184
+ query = query.where(Run.provider == provider)
185
+ if model:
186
+ query = query.where(Run.model == model)
187
+ if search:
188
+ query = query.where(
189
+ (Run.goal.ilike(f"%{search}%")) | (Run.id.ilike(f"%{search}%"))
190
+ )
191
+ if started_after:
192
+ query = query.where(Run.started_at >= started_after)
193
+ if started_before:
194
+ query = query.where(Run.started_at <= started_before)
195
+ runs = list(session.scalars(query.order_by(Run.started_at.desc()).offset(offset).limit(limit)))
196
+ return [
197
+ RunListItem(
198
+ id=run.id,
199
+ agent_name=run.agent_name,
200
+ goal=run.goal,
201
+ status=run.status,
202
+ started_at=run.started_at,
203
+ ended_at=run.ended_at,
204
+ tool_count=run.tool_count,
205
+ error_count=run.error_count,
206
+ retry_count=run.retry_count,
207
+ artifact_count=run.artifact_count,
208
+ total_prompt_tokens=run.total_prompt_tokens,
209
+ total_completion_tokens=run.total_completion_tokens,
210
+ model=run.model,
211
+ provider=run.provider,
212
+ )
213
+ for run in runs
214
+ ]
215
+
216
+
217
+ @router.post("/runs", response_model=RunCreateResponse)
218
+ def create_run(payload: RunCreateRequest, session: Session = Depends(get_session)) -> RunCreateResponse:
219
+ run = Run(agent_name=payload.agent_name, goal=payload.goal, run_metadata=payload.metadata)
220
+ session.add(run)
221
+ session.flush()
222
+ record_event(
223
+ session,
224
+ run,
225
+ actor="proxy",
226
+ event_type="run.started",
227
+ summary=f"Run created for agent {run.agent_name}.",
228
+ status="running",
229
+ )
230
+ session.commit()
231
+ return RunCreateResponse(run_id=run.id, status=run.status)
232
+
233
+
234
+ @router.post("/runs/{run_id}/turns", response_model=TurnCreateResponse)
235
+ def create_turn(
236
+ run_id: str,
237
+ payload: TurnCreateRequest,
238
+ request: Request,
239
+ session: Session = Depends(get_session),
240
+ ) -> TurnCreateResponse:
241
+ run = _get_run_or_404(session, run_id)
242
+ if not run.provider:
243
+ run.provider = payload.provider
244
+ if not run.model:
245
+ run.model = payload.model
246
+ registry = request.app.state.provider_registry
247
+ guardrails = getattr(request.app.state, "guardrails", None)
248
+ adapter = registry.get(payload.provider)
249
+ capture_full = getattr(request.app.state.settings, "capture_full_payloads", True)
250
+ if adapter is None:
251
+ raise HTTPException(status_code=400, detail=f"Unsupported provider: {payload.provider}")
252
+
253
+ sanitized_messages = payload.messages
254
+ input_guardrail_findings: list[dict[str, Any]] = []
255
+ blocked_by_guardrail = False
256
+ guardrail_block_reason: str | None = None
257
+ if guardrails is not None:
258
+ sanitized_messages, input_guardrail_findings, blocked_by_guardrail, guardrail_block_reason = guardrails.sanitize_messages(payload.messages)
259
+ if input_guardrail_findings:
260
+ record_event(
261
+ session,
262
+ run,
263
+ actor="proxy",
264
+ event_type="guardrail.input_checked",
265
+ summary=f"Guardrails found {len(input_guardrail_findings)} input finding(s).",
266
+ status="observed",
267
+ provider=payload.provider,
268
+ model=payload.model,
269
+ metadata={"findings": input_guardrail_findings},
270
+ )
271
+ if blocked_by_guardrail:
272
+ run.error_count += 1
273
+ record_event(
274
+ session,
275
+ run,
276
+ actor="proxy",
277
+ event_type="guardrail.blocked",
278
+ summary=f"Turn blocked by guardrails: {guardrail_block_reason or 'policy'}.",
279
+ status="failed",
280
+ provider=payload.provider,
281
+ model=payload.model,
282
+ error_code="guardrail_blocked",
283
+ metadata={
284
+ "reason": guardrail_block_reason or "policy",
285
+ "findings": input_guardrail_findings,
286
+ },
287
+ )
288
+ session.commit()
289
+ raise HTTPException(status_code=400, detail="Input blocked by guardrails.")
290
+
291
+ tool_redaction = {tool.name: tool.redact_fields for tool in payload.tools}
292
+ if payload.tools:
293
+ run.run_metadata = {
294
+ **run.run_metadata,
295
+ "registered_tools": [tool.name for tool in payload.tools],
296
+ "registered_tool_descriptions": {tool.name: tool.description for tool in payload.tools},
297
+ }
298
+ message_summary = {
299
+ "message_count": len(sanitized_messages),
300
+ "last_roles": [message.get("role", "unknown") for message in sanitized_messages[-3:]],
301
+ }
302
+ record_event(
303
+ session,
304
+ run,
305
+ actor="proxy",
306
+ event_type="model.requested",
307
+ summary=summarize_value(message_summary),
308
+ status="running",
309
+ provider=payload.provider,
310
+ model=payload.model,
311
+ payload_hash=stable_hash(sanitized_messages),
312
+ payload_full={"messages": sanitized_messages} if capture_full else None,
313
+ metadata={
314
+ "registered_tool_count": len(payload.tools),
315
+ "tool_names": [tool.name for tool in payload.tools],
316
+ "tool_choice": payload.tool_choice,
317
+ "message_count": len(sanitized_messages),
318
+ "guardrail_input_finding_count": len(input_guardrail_findings),
319
+ },
320
+ )
321
+
322
+ try:
323
+ response = adapter.generate_turn(
324
+ ProviderTurnRequest(
325
+ model=payload.model,
326
+ messages=sanitized_messages,
327
+ tools=payload.tools,
328
+ tool_choice=payload.tool_choice,
329
+ )
330
+ )
331
+ except Exception as exc: # pragma: no cover
332
+ run.error_count += 1
333
+ record_event(
334
+ session,
335
+ run,
336
+ actor="provider",
337
+ event_type="run.failed",
338
+ summary=f"Provider error: {exc}",
339
+ status="failed",
340
+ provider=payload.provider,
341
+ model=payload.model,
342
+ error_code="provider_error",
343
+ )
344
+ run.status = "failed"
345
+ run.ended_at = utcnow()
346
+ session.commit()
347
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
348
+
349
+ run.provider = payload.provider
350
+ run.model = payload.model
351
+
352
+ # Create Turn record with full messages and token usage
353
+ usage = response.metadata.get("usage", {}) if response.metadata else {}
354
+ turn = Turn(
355
+ run_id=run.id,
356
+ seq=run.last_event_seq + 1,
357
+ provider=payload.provider,
358
+ model=payload.model,
359
+ messages=sanitized_messages,
360
+ assistant_message=response.assistant_message,
361
+ tool_calls=[{"id": c.id, "name": c.name, "arguments": c.arguments, "step_id": c.step_id} for c in response.tool_calls],
362
+ prompt_tokens=usage.get("prompt_tokens"),
363
+ completion_tokens=usage.get("completion_tokens"),
364
+ total_tokens=usage.get("total_tokens"),
365
+ reasoning_tokens=usage.get("reasoning_tokens"),
366
+ finish_reason=response.metadata.get("finish_reason") if response.metadata else None,
367
+ response_id=response.metadata.get("response_id") if response.metadata else None,
368
+ )
369
+ session.add(turn)
370
+ session.flush()
371
+
372
+ if turn.prompt_tokens:
373
+ run.total_prompt_tokens += turn.prompt_tokens
374
+ if turn.completion_tokens:
375
+ run.total_completion_tokens += turn.completion_tokens
376
+
377
+ output_guardrail_findings: list[dict[str, Any]] = []
378
+ if guardrails is not None and response.assistant_message is not None:
379
+ sanitized_assistant_message, output_guardrail_findings = guardrails.sanitize_assistant_message(response.assistant_message)
380
+ response.assistant_message = sanitized_assistant_message
381
+ if output_guardrail_findings:
382
+ record_event(
383
+ session,
384
+ run,
385
+ actor="proxy",
386
+ event_type="guardrail.output_checked",
387
+ summary=f"Guardrails found {len(output_guardrail_findings)} output finding(s).",
388
+ status="observed",
389
+ provider=payload.provider,
390
+ model=payload.model,
391
+ metadata={"findings": output_guardrail_findings},
392
+ )
393
+
394
+ response_metadata = dict(response.metadata or {})
395
+ response_metadata["guardrail_output_finding_count"] = len(output_guardrail_findings)
396
+ record_event(
397
+ session,
398
+ run,
399
+ actor="provider",
400
+ event_type="model.responded",
401
+ summary=response.summary,
402
+ status="running",
403
+ provider=payload.provider,
404
+ model=payload.model,
405
+ payload_hash=stable_hash(response.assistant_message or [tool_call.__dict__ for tool_call in response.tool_calls]),
406
+ payload_full={"assistant_message": response.assistant_message, "tool_calls": turn.tool_calls} if capture_full else None,
407
+ turn_id=turn.id,
408
+ metadata=response_metadata,
409
+ )
410
+
411
+ tool_calls = []
412
+ for call in response.tool_calls:
413
+ tool_calls.append(call)
414
+ record_event(
415
+ session,
416
+ run,
417
+ actor="provider",
418
+ event_type="tool.requested",
419
+ summary=summarize_value(call.arguments, tool_redaction.get(call.name, [])),
420
+ status="requested",
421
+ step_id=call.step_id,
422
+ parent_step_id=call.parent_step_id,
423
+ provider=payload.provider,
424
+ model=payload.model,
425
+ tool_name=call.name,
426
+ payload_hash=stable_hash(call.arguments),
427
+ payload_full={"arguments": call.arguments} if capture_full else None,
428
+ turn_id=turn.id,
429
+ metadata={"call_id": call.id},
430
+ )
431
+
432
+ session.commit()
433
+ if tool_calls:
434
+ return TurnCreateResponse(
435
+ status="requires_action",
436
+ tool_calls=[
437
+ {
438
+ "id": call.id,
439
+ "name": call.name,
440
+ "arguments": call.arguments,
441
+ "step_id": call.step_id,
442
+ "parent_step_id": call.parent_step_id,
443
+ }
444
+ for call in tool_calls
445
+ ],
446
+ )
447
+ return TurnCreateResponse(status="message", assistant_message=response.assistant_message)
448
+
449
+
450
+ @router.post("/runs/{run_id}/tool-results")
451
+ def store_tool_results(
452
+ run_id: str,
453
+ payload: ToolResultsRequest,
454
+ request: Request,
455
+ session: Session = Depends(get_session),
456
+ ) -> dict[str, Any]:
457
+ run = _get_run_or_404(session, run_id)
458
+ capture_full = getattr(request.app.state.settings, "capture_full_payloads", True)
459
+
460
+ for result in payload.results:
461
+ started_event = record_event(
462
+ session,
463
+ run,
464
+ actor="sdk",
465
+ event_type="tool.started",
466
+ summary=f"Started {result.name}.",
467
+ status="running",
468
+ step_id=result.step_id,
469
+ tool_name=result.name,
470
+ payload_hash=stable_hash(result.args),
471
+ payload_full={"args": result.args} if capture_full else None,
472
+ metadata={"call_id": result.call_id, "args_summary": summarize_value(result.args)},
473
+ )
474
+ if result.status == "succeeded":
475
+ run.tool_count += 1
476
+ completed_event = record_event(
477
+ session,
478
+ run,
479
+ actor="sdk",
480
+ event_type="tool.succeeded",
481
+ summary=result.output_summary or f"{result.name} completed.",
482
+ status="succeeded",
483
+ step_id=result.step_id,
484
+ tool_name=result.name,
485
+ duration_ms=result.duration_ms,
486
+ payload_hash=result.output_hash,
487
+ payload_full={"output_summary": result.output_summary} if capture_full else None,
488
+ metadata={"call_id": result.call_id, "artifact_count": len(result.artifacts)},
489
+ )
490
+ else:
491
+ run.tool_count += 1
492
+ run.error_count += 1
493
+ completed_event = record_event(
494
+ session,
495
+ run,
496
+ actor="sdk",
497
+ event_type="tool.failed",
498
+ summary=result.error_summary or f"{result.name} failed.",
499
+ status="failed",
500
+ step_id=result.step_id,
501
+ tool_name=result.name,
502
+ duration_ms=result.duration_ms,
503
+ error_code=result.error_code,
504
+ payload_hash=result.output_hash,
505
+ payload_full={"error_summary": result.error_summary} if capture_full else None,
506
+ metadata={"call_id": result.call_id, "artifact_count": len(result.artifacts)},
507
+ )
508
+
509
+ for artifact_input in result.artifacts:
510
+ artifact = create_artifact(
511
+ session,
512
+ run,
513
+ source_event_id=completed_event.id,
514
+ kind=artifact_input.kind,
515
+ label=artifact_input.label,
516
+ summary=artifact_input.summary,
517
+ content_hash=stable_hash({"kind": artifact_input.kind, "label": artifact_input.label, "summary": artifact_input.summary}),
518
+ metadata=artifact_input.metadata,
519
+ )
520
+ run.artifact_count += 1
521
+ record_event(
522
+ session,
523
+ run,
524
+ actor="sdk",
525
+ event_type="artifact.created",
526
+ summary=artifact.summary,
527
+ status="created",
528
+ step_id=result.step_id,
529
+ tool_name=result.name,
530
+ artifact_refs=[artifact.id],
531
+ metadata={"artifact_label": artifact.label, "call_id": result.call_id},
532
+ )
533
+
534
+ started_event.status = "completed"
535
+
536
+ session.commit()
537
+ return {"status": "ok", "recorded": len(payload.results)}
538
+
539
+
540
+ @router.post("/runs/{run_id}/observations")
541
+ def store_observations(
542
+ run_id: str,
543
+ payload: ObservationsRequest,
544
+ request: Request,
545
+ session: Session = Depends(get_session),
546
+ ) -> dict[str, Any]:
547
+ run = _get_run_or_404(session, run_id)
548
+ capture_full = getattr(request.app.state.settings, "capture_full_payloads", True)
549
+
550
+ for observation in payload.observations:
551
+ if observation.kind == "command":
552
+ command_id = observation.command_id or observation.step_id or f"command-{run.last_event_seq + 1}"
553
+ command_label = summarize_value(observation.command or "unknown command", max_length=180)
554
+ record_event(
555
+ session,
556
+ run,
557
+ actor="sdk",
558
+ event_type="command.started",
559
+ summary=f"Started command: {command_label}.",
560
+ status="running",
561
+ step_id=observation.step_id or command_id,
562
+ parent_step_id=observation.parent_step_id,
563
+ payload_hash=stable_hash({"command": observation.command, "cwd": observation.cwd}),
564
+ payload_full={"command": observation.command, "cwd": observation.cwd} if capture_full else None,
565
+ metadata={
566
+ "command_id": command_id,
567
+ "command": observation.command,
568
+ "cwd": observation.cwd,
569
+ },
570
+ )
571
+ if observation.status == "failed":
572
+ run.error_count += 1
573
+ record_event(
574
+ session,
575
+ run,
576
+ actor="sdk",
577
+ event_type="command.failed",
578
+ summary=observation.error_summary or f"Command failed: {command_label}.",
579
+ status="failed",
580
+ step_id=observation.step_id or command_id,
581
+ parent_step_id=observation.parent_step_id,
582
+ duration_ms=observation.duration_ms,
583
+ error_code=observation.error_code,
584
+ payload_hash=stable_hash({"command": observation.command, "error": observation.error_summary}),
585
+ payload_full={"command": observation.command, "error_summary": observation.error_summary, "stdout_summary": observation.stdout_summary, "stderr_summary": observation.stderr_summary} if capture_full else None,
586
+ metadata={
587
+ "command_id": command_id,
588
+ "command": observation.command,
589
+ "cwd": observation.cwd,
590
+ "exit_code": observation.exit_code,
591
+ "output_summary": observation.output_summary,
592
+ "stdout_summary": observation.stdout_summary,
593
+ "stderr_summary": observation.stderr_summary,
594
+ },
595
+ )
596
+ else:
597
+ record_event(
598
+ session,
599
+ run,
600
+ actor="sdk",
601
+ event_type="command.succeeded",
602
+ summary=observation.output_summary or f"Command succeeded: {command_label}.",
603
+ status="succeeded",
604
+ step_id=observation.step_id or command_id,
605
+ parent_step_id=observation.parent_step_id,
606
+ duration_ms=observation.duration_ms,
607
+ payload_hash=stable_hash({"command": observation.command, "output": observation.output_summary}),
608
+ payload_full={"command": observation.command, "output_summary": observation.output_summary, "stdout_summary": observation.stdout_summary, "stderr_summary": observation.stderr_summary} if capture_full else None,
609
+ metadata={
610
+ "command_id": command_id,
611
+ "command": observation.command,
612
+ "cwd": observation.cwd,
613
+ "exit_code": observation.exit_code,
614
+ "output_summary": observation.output_summary,
615
+ "stdout_summary": observation.stdout_summary,
616
+ "stderr_summary": observation.stderr_summary,
617
+ },
618
+ )
619
+ continue
620
+
621
+ if observation.kind == "file_read":
622
+ summary = f"Read {observation.path or 'unknown file'}."
623
+ if observation.size_bytes is not None:
624
+ summary = f"Read {observation.path or 'unknown file'} ({observation.size_bytes} bytes)."
625
+ record_event(
626
+ session,
627
+ run,
628
+ actor="sdk",
629
+ event_type="file.read",
630
+ summary=summary,
631
+ status="observed",
632
+ step_id=observation.step_id,
633
+ parent_step_id=observation.parent_step_id,
634
+ payload_hash=observation.content_hash,
635
+ payload_full={"path": observation.path, "size_bytes": observation.size_bytes} if capture_full else None,
636
+ metadata={
637
+ "path": observation.path,
638
+ "content_hash": observation.content_hash,
639
+ "size_bytes": observation.size_bytes,
640
+ "extension": observation.extension,
641
+ **observation.metadata,
642
+ },
643
+ )
644
+ continue
645
+
646
+ if observation.kind == "file_write":
647
+ summary = observation.diff_summary or f"Wrote {observation.path or 'unknown file'}."
648
+ record_event(
649
+ session,
650
+ run,
651
+ actor="sdk",
652
+ event_type="file.written",
653
+ summary=summary,
654
+ status="succeeded",
655
+ step_id=observation.step_id,
656
+ parent_step_id=observation.parent_step_id,
657
+ payload_hash=observation.after_hash or observation.content_hash,
658
+ payload_full={"path": observation.path, "change_type": observation.change_type, "diff_summary": observation.diff_summary} if capture_full else None,
659
+ metadata={
660
+ "path": observation.path,
661
+ "change_type": observation.change_type,
662
+ "before_hash": observation.before_hash,
663
+ "after_hash": observation.after_hash,
664
+ "content_hash": observation.content_hash,
665
+ "diff_summary": observation.diff_summary,
666
+ "line_additions": observation.line_additions,
667
+ "line_deletions": observation.line_deletions,
668
+ "extension": observation.extension,
669
+ "size_bytes": observation.size_bytes,
670
+ **observation.metadata,
671
+ },
672
+ )
673
+ continue
674
+
675
+ if observation.kind == "patch":
676
+ record_event(
677
+ session,
678
+ run,
679
+ actor="sdk",
680
+ event_type="patch.applied",
681
+ summary=observation.diff_summary or f"Applied patch to {observation.path or 'unknown file'}.",
682
+ status="succeeded",
683
+ step_id=observation.step_id,
684
+ parent_step_id=observation.parent_step_id,
685
+ payload_hash=observation.after_hash,
686
+ payload_full={"path": observation.path, "diff_summary": observation.diff_summary} if capture_full else None,
687
+ metadata={
688
+ "path": observation.path,
689
+ "before_hash": observation.before_hash,
690
+ "after_hash": observation.after_hash,
691
+ "diff_summary": observation.diff_summary,
692
+ "line_additions": observation.line_additions,
693
+ "line_deletions": observation.line_deletions,
694
+ **observation.metadata,
695
+ },
696
+ )
697
+ continue
698
+
699
+ if observation.kind == "artifact" and observation.artifact is not None:
700
+ artifact_event = record_event(
701
+ session,
702
+ run,
703
+ actor="sdk",
704
+ event_type="artifact.updated",
705
+ summary=observation.artifact.summary,
706
+ status="created",
707
+ step_id=observation.step_id,
708
+ parent_step_id=observation.parent_step_id,
709
+ artifact_refs=[],
710
+ payload_hash=observation.artifact.metadata.get("content_hash"),
711
+ metadata={
712
+ "artifact_label": observation.artifact.label,
713
+ "artifact_kind": observation.artifact.kind,
714
+ **observation.metadata,
715
+ },
716
+ )
717
+ artifact = create_artifact(
718
+ session,
719
+ run,
720
+ source_event_id=artifact_event.id,
721
+ kind=observation.artifact.kind,
722
+ label=observation.artifact.label,
723
+ summary=observation.artifact.summary,
724
+ content_hash=observation.artifact.metadata.get("content_hash"),
725
+ metadata=observation.artifact.metadata,
726
+ )
727
+ run.artifact_count += 1
728
+ artifact_event.artifact_refs = [artifact.id]
729
+ continue
730
+
731
+ # ── LangChain observation kinds ────────────────────────────────────
732
+
733
+ if observation.kind == "chain_step":
734
+ event_type = "chain.started" if observation.status == "running" else "chain.completed"
735
+ if observation.status == "failed":
736
+ event_type = "chain.failed"
737
+ record_event(
738
+ session,
739
+ run,
740
+ actor="sdk",
741
+ event_type=event_type,
742
+ summary=observation.input_summary or f"Chain step: {observation.metadata.get('chain_type', 'unknown')}",
743
+ status=observation.status or "observed",
744
+ step_id=observation.step_id,
745
+ parent_step_id=observation.parent_step_id,
746
+ duration_ms=observation.duration_ms,
747
+ error_code=observation.error_code,
748
+ payload_hash=stable_hash(observation.metadata),
749
+ payload_full=observation.metadata if capture_full else None,
750
+ metadata=observation.metadata,
751
+ )
752
+ continue
753
+
754
+ if observation.kind == "llm_call":
755
+ event_type = "llm.started" if observation.status == "running" else "llm.completed"
756
+ if observation.status == "failed":
757
+ event_type = "llm.failed"
758
+ record_event(
759
+ session,
760
+ run,
761
+ actor="sdk" if observation.status == "running" else "provider",
762
+ event_type=event_type,
763
+ summary=observation.input_summary or f"LLM call via {observation.provider or 'unknown'}",
764
+ status=observation.status or "observed",
765
+ step_id=observation.step_id,
766
+ parent_step_id=observation.parent_step_id,
767
+ duration_ms=observation.duration_ms,
768
+ provider=observation.provider,
769
+ model=observation.model,
770
+ error_code=observation.error_code,
771
+ payload_hash=stable_hash(observation.metadata),
772
+ payload_full=observation.metadata if capture_full else None,
773
+ metadata={
774
+ "provider": observation.provider,
775
+ "model": observation.model,
776
+ **observation.metadata,
777
+ },
778
+ )
779
+ continue
780
+
781
+ if observation.kind == "tool_call":
782
+ event_type = "tool.started" if observation.status == "running" else "tool.succeeded"
783
+ if observation.status == "failed":
784
+ event_type = "tool.failed"
785
+ run.error_count += 1
786
+ record_event(
787
+ session,
788
+ run,
789
+ actor="sdk",
790
+ event_type=event_type,
791
+ summary=observation.input_summary or f"Tool call: {observation.tool_name or 'unknown'}",
792
+ status=observation.status or "observed",
793
+ step_id=observation.step_id,
794
+ parent_step_id=observation.parent_step_id,
795
+ tool_name=observation.tool_name,
796
+ duration_ms=observation.duration_ms,
797
+ error_code=observation.error_code,
798
+ payload_hash=stable_hash(observation.metadata),
799
+ payload_full=observation.metadata if capture_full else None,
800
+ metadata={
801
+ "tool_name": observation.tool_name,
802
+ **observation.metadata,
803
+ },
804
+ )
805
+ continue
806
+
807
+ if observation.kind == "retriever_query":
808
+ event_type = "retriever.queried" if observation.status == "running" else "retriever.responded"
809
+ if observation.status == "failed":
810
+ event_type = "retriever.failed"
811
+ record_event(
812
+ session,
813
+ run,
814
+ actor="sdk",
815
+ event_type=event_type,
816
+ summary=observation.input_summary or f"Retriever query",
817
+ status=observation.status or "observed",
818
+ step_id=observation.step_id,
819
+ parent_step_id=observation.parent_step_id,
820
+ duration_ms=observation.duration_ms,
821
+ error_code=observation.error_code,
822
+ payload_hash=stable_hash(observation.metadata),
823
+ payload_full=observation.metadata if capture_full else None,
824
+ metadata=observation.metadata,
825
+ )
826
+ continue
827
+
828
+ if observation.kind == "agent_action":
829
+ event_type = "agent.action" if observation.status == "running" else "agent.finish"
830
+ if observation.status == "failed":
831
+ event_type = "agent.failed"
832
+ record_event(
833
+ session,
834
+ run,
835
+ actor="sdk",
836
+ event_type=event_type,
837
+ summary=observation.input_summary or f"Agent action",
838
+ status=observation.status or "observed",
839
+ step_id=observation.step_id,
840
+ parent_step_id=observation.parent_step_id,
841
+ tool_name=observation.tool_name,
842
+ duration_ms=observation.duration_ms,
843
+ error_code=observation.error_code,
844
+ payload_hash=stable_hash(observation.metadata),
845
+ payload_full=observation.metadata if capture_full else None,
846
+ metadata={
847
+ "tool_name": observation.tool_name,
848
+ **observation.metadata,
849
+ },
850
+ )
851
+ continue
852
+
853
+ session.commit()
854
+ return {"status": "ok", "recorded": len(payload.observations)}
855
+
856
+
857
+ @router.post("/runs/{run_id}/finish")
858
+ def finish_run(
859
+ run_id: str,
860
+ payload: FinishRunRequest,
861
+ session: Session = Depends(get_session),
862
+ ) -> dict[str, Any]:
863
+ run = _get_run_or_404(session, run_id)
864
+ final_summary = summarize_value(payload.final_output) if payload.final_output is not None else "Run finished without final output."
865
+ run.final_output_summary = final_summary
866
+ run.status = "completed"
867
+ run.ended_at = utcnow()
868
+ completed_event = record_event(
869
+ session,
870
+ run,
871
+ actor="proxy",
872
+ event_type="run.completed",
873
+ summary=final_summary,
874
+ status="completed",
875
+ payload_hash=stable_hash(payload.final_output),
876
+ )
877
+ for artifact_input in payload.artifacts:
878
+ artifact = create_artifact(
879
+ session,
880
+ run,
881
+ source_event_id=completed_event.id,
882
+ kind=artifact_input.kind,
883
+ label=artifact_input.label,
884
+ summary=artifact_input.summary,
885
+ content_hash=stable_hash({"kind": artifact_input.kind, "label": artifact_input.label, "summary": artifact_input.summary}),
886
+ metadata=artifact_input.metadata,
887
+ )
888
+ run.artifact_count += 1
889
+ record_event(
890
+ session,
891
+ run,
892
+ actor="proxy",
893
+ event_type="artifact.created",
894
+ summary=artifact.summary,
895
+ status="created",
896
+ artifact_refs=[artifact.id],
897
+ metadata={"artifact_label": artifact.label},
898
+ )
899
+ derivation = derive_explanation(session, run)
900
+ session.commit()
901
+ return {"status": "completed", "run_id": run.id, "decisions": len(derivation["decisions"])}
902
+
903
+
904
+ @router.post("/runs/{run_id}/fail")
905
+ def fail_run(
906
+ run_id: str,
907
+ payload: FailRunRequest,
908
+ session: Session = Depends(get_session),
909
+ ) -> dict[str, Any]:
910
+ run = _get_run_or_404(session, run_id)
911
+ run.status = "failed"
912
+ run.ended_at = utcnow()
913
+ run.error_count += 1
914
+ run.final_output_summary = payload.error_summary
915
+ record_event(
916
+ session,
917
+ run,
918
+ actor="proxy",
919
+ event_type="run.failed",
920
+ summary=payload.error_summary,
921
+ status="failed",
922
+ error_code=payload.error_code,
923
+ payload_hash=stable_hash(payload.error_summary),
924
+ )
925
+ derive_explanation(session, run)
926
+ session.commit()
927
+ return {"status": "failed", "run_id": run.id}
928
+
929
+
930
+ @router.get("/runs/{run_id}/analytics", response_model=RunAnalyticsResponse)
931
+ def get_run_analytics(
932
+ run_id: str,
933
+ request: Request,
934
+ same_agent_only: bool = Query(default=False),
935
+ status: str | None = Query(default=None, pattern="^(completed|failed|running)$"),
936
+ shared_tools_only: bool = Query(default=False),
937
+ min_score: float = Query(default=0.15, ge=0.0, le=1.0),
938
+ limit: int = Query(default=5, ge=1, le=20),
939
+ session: Session = Depends(get_session),
940
+ ) -> RunAnalyticsResponse:
941
+ run = _get_run_or_404(session, run_id)
942
+ events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
943
+ tool_chain = build_tool_chain(events)
944
+ tool_graph = build_tool_graph(tool_chain)
945
+ settings = request.app.state.settings
946
+ return RunAnalyticsResponse(
947
+ run_id=run_id,
948
+ tool_graph=GraphResponse(run_id=run_id, view="tool_chain", nodes=tool_graph["nodes"], edges=tool_graph["edges"]),
949
+ tool_metrics=build_tool_metrics(events, tool_chain, settings),
950
+ similar_runs=build_similar_runs(
951
+ session,
952
+ run,
953
+ tool_chain,
954
+ limit=limit,
955
+ same_agent_only=same_agent_only,
956
+ status_filter=status,
957
+ shared_tools_only=shared_tools_only,
958
+ min_score=min_score,
959
+ ),
960
+ )
961
+
962
+
963
+ @router.get("/runs/compare", response_model=RunCompareResponse)
964
+ def get_run_compare(
965
+ left_run_id: str,
966
+ right_run_id: str,
967
+ session: Session = Depends(get_session),
968
+ ) -> RunCompareResponse:
969
+ left_run = _get_run_or_404(session, left_run_id)
970
+ right_run = _get_run_or_404(session, right_run_id)
971
+
972
+ if not left_run.explanation_cache and left_run.status in {"completed", "failed"}:
973
+ derive_explanation(session, left_run)
974
+ if not right_run.explanation_cache and right_run.status in {"completed", "failed"}:
975
+ derive_explanation(session, right_run)
976
+ session.flush()
977
+
978
+ left_events = list(session.scalars(select(Event).where(Event.run_id == left_run_id).order_by(Event.seq)))
979
+ right_events = list(session.scalars(select(Event).where(Event.run_id == right_run_id).order_by(Event.seq)))
980
+ left_artifacts = list(session.scalars(select(Artifact).where(Artifact.run_id == left_run_id).order_by(Artifact.created_at)))
981
+ right_artifacts = list(session.scalars(select(Artifact).where(Artifact.run_id == right_run_id).order_by(Artifact.created_at)))
982
+ left_decisions = list(session.scalars(select(Decision).where(Decision.run_id == left_run_id).order_by(Decision.seq)))
983
+ right_decisions = list(session.scalars(select(Decision).where(Decision.run_id == right_run_id).order_by(Decision.seq)))
984
+
985
+ comparison = compare_runs(
986
+ left_run=left_run,
987
+ right_run=right_run,
988
+ left_events=left_events,
989
+ right_events=right_events,
990
+ left_artifacts=left_artifacts,
991
+ right_artifacts=right_artifacts,
992
+ left_decisions=left_decisions,
993
+ right_decisions=right_decisions,
994
+ )
995
+ return RunCompareResponse(
996
+ left_run=_serialize_run_overview(left_run, left_events),
997
+ right_run=_serialize_run_overview(right_run, right_events),
998
+ overview_diff=comparison["overview_diff"],
999
+ divergence=comparison["divergence"],
1000
+ aligned_timeline=comparison["aligned_timeline"],
1001
+ tool_diff=comparison["tool_diff"],
1002
+ command_diff=comparison["command_diff"],
1003
+ file_diff=comparison["file_diff"],
1004
+ artifact_diff=comparison["artifact_diff"],
1005
+ decision_diff=comparison["decision_diff"],
1006
+ root_cause=comparison["root_cause"],
1007
+ evidence={
1008
+ "left": {str(event.id): _serialize_event(event) for event in left_events},
1009
+ "right": {str(event.id): _serialize_event(event) for event in right_events},
1010
+ },
1011
+ )
1012
+
1013
+
1014
+ @router.get("/runs/{run_id}", response_model=RunOverview)
1015
+ def get_run(run_id: str, session: Session = Depends(get_session)) -> RunOverview:
1016
+ run = _get_run_or_404(session, run_id)
1017
+ events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
1018
+ return _serialize_run_overview(run, events)
1019
+
1020
+
1021
+ @router.get("/runs/{run_id}/timeline", response_model=TimelineResponse)
1022
+ def get_timeline(run_id: str, session: Session = Depends(get_session)) -> TimelineResponse:
1023
+ _get_run_or_404(session, run_id)
1024
+ events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
1025
+ groups = [_serialize_timeline_group(group) for group in build_timeline_groups(events)]
1026
+ return TimelineResponse(run_id=run_id, groups=groups)
1027
+
1028
+
1029
+ @router.get("/runs/{run_id}/graph", response_model=GraphResponse)
1030
+ def get_graph(
1031
+ run_id: str,
1032
+ view: str = Query(default="execution", pattern="^(execution|decisions)$"),
1033
+ session: Session = Depends(get_session),
1034
+ ) -> GraphResponse:
1035
+ _get_run_or_404(session, run_id)
1036
+ if view == "execution":
1037
+ events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
1038
+ artifacts = list(session.scalars(select(Artifact).where(Artifact.run_id == run_id).order_by(Artifact.created_at)))
1039
+ graph = build_execution_graph(events, artifacts)
1040
+ else:
1041
+ decisions = list(session.scalars(select(Decision).where(Decision.run_id == run_id).order_by(Decision.seq)))
1042
+ graph = build_decision_graph(decisions)
1043
+ return GraphResponse(run_id=run_id, view=view, nodes=graph["nodes"], edges=graph["edges"])
1044
+
1045
+
1046
+ @router.get("/runs/{run_id}/explanation", response_model=ExplanationResponse)
1047
+ def get_explanation(run_id: str, session: Session = Depends(get_session)) -> ExplanationResponse:
1048
+ run = _get_run_or_404(session, run_id)
1049
+ if not run.explanation_cache and run.status in {"completed", "failed"}:
1050
+ derive_explanation(session, run)
1051
+ session.commit()
1052
+
1053
+ decisions = list(session.scalars(select(Decision).where(Decision.run_id == run_id).order_by(Decision.seq)))
1054
+ events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
1055
+ evidence = {str(event.id): _serialize_event(event) for event in events}
1056
+ return ExplanationResponse(
1057
+ run_id=run_id,
1058
+ narrative=run.explanation_cache.get("narrative", []),
1059
+ decisions=[_serialize_decision(decision) for decision in decisions],
1060
+ flags=run.flag_cache,
1061
+ evidence=evidence,
1062
+ )
1063
+
1064
+
1065
+ @router.get("/runs/{run_id}/artifacts", response_model=list[ArtifactOut])
1066
+ def get_artifacts(run_id: str, session: Session = Depends(get_session)) -> list[ArtifactOut]:
1067
+ _get_run_or_404(session, run_id)
1068
+ artifacts = list(session.scalars(select(Artifact).where(Artifact.run_id == run_id).order_by(Artifact.created_at)))
1069
+ return [_serialize_artifact(artifact) for artifact in artifacts]
1070
+
1071
+
1072
+ @router.get("/runs/{run_id}/audit")
1073
+ def get_run_audit(run_id: str, session: Session = Depends(get_session)) -> dict[str, Any]:
1074
+ """Return a structured audit report for a run based on guardrail events."""
1075
+ run = _get_run_or_404(session, run_id)
1076
+ events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
1077
+
1078
+ GUARDRAIL_TYPES = {"guardrail.input_checked", "guardrail.output_checked", "guardrail.blocked"}
1079
+ guardrail_events: list[dict[str, Any]] = []
1080
+ pii_hit_count = 0
1081
+ injection_hit_count = 0
1082
+ secret_hit_count = 0
1083
+ block_count = 0
1084
+
1085
+ for event in events:
1086
+ if event.type not in GUARDRAIL_TYPES:
1087
+ continue
1088
+
1089
+ metadata = event.event_metadata or {}
1090
+ findings: list[dict[str, Any]] = metadata.get("findings") or []
1091
+
1092
+ for finding in findings:
1093
+ kind = finding.get("kind", "")
1094
+ if kind == "pii":
1095
+ pii_hit_count += 1
1096
+ elif kind == "prompt_injection":
1097
+ injection_hit_count += 1
1098
+ elif kind == "secret":
1099
+ secret_hit_count += 1
1100
+
1101
+ if event.type == "guardrail.blocked":
1102
+ block_count += 1
1103
+
1104
+ scope = "block" if event.type == "guardrail.blocked" else ("input" if "input" in event.type else "output")
1105
+ severities = [f.get("severity", "medium") for f in findings]
1106
+ max_severity = "high" if "high" in severities else ("medium" if "medium" in severities else "low")
1107
+
1108
+ guardrail_events.append({
1109
+ "event_id": event.id,
1110
+ "seq": event.seq,
1111
+ "timestamp": event.timestamp.isoformat() if event.timestamp else None,
1112
+ "type": event.type,
1113
+ "scope": scope,
1114
+ "summary": event.summary,
1115
+ "severity": max_severity if findings else "info",
1116
+ "finding_count": len(findings),
1117
+ "findings": findings,
1118
+ "provider": event.provider,
1119
+ "model": event.model,
1120
+ })
1121
+
1122
+ total_issues = pii_hit_count + injection_hit_count + secret_hit_count + block_count
1123
+ if total_issues == 0:
1124
+ security_score = 100
1125
+ else:
1126
+ penalty = min(100, (block_count * 20) + (injection_hit_count * 10) + (secret_hit_count * 8) + (pii_hit_count * 3))
1127
+ security_score = max(0, 100 - penalty)
1128
+
1129
+ return {
1130
+ "run_id": run_id,
1131
+ "security_score": security_score,
1132
+ "pii_hit_count": pii_hit_count,
1133
+ "injection_hit_count": injection_hit_count,
1134
+ "secret_hit_count": secret_hit_count,
1135
+ "block_count": block_count,
1136
+ "guardrail_event_count": len(guardrail_events),
1137
+ "guardrail_events": guardrail_events,
1138
+ "flags": run.flag_cache or [],
1139
+ }
1140
+
1141
+
1142
+
1143
+ @router.get("/runs/{run_id}/events/stream")
1144
+ async def stream_events(
1145
+ run_id: str,
1146
+ request: Request,
1147
+ session: Session = Depends(get_session),
1148
+ ):
1149
+ _get_run_or_404(session, run_id)
1150
+ from trace_agent_server.services.events import _register_sse_queue, _unregister_sse_queue
1151
+
1152
+ queue: asyncio.Queue = asyncio.Queue(maxsize=100)
1153
+ _register_sse_queue(run_id, queue)
1154
+
1155
+ async def event_generator():
1156
+ try:
1157
+ yield f"data: {json.dumps({'type': 'connected', 'run_id': run_id})}\n\n"
1158
+ while True:
1159
+ try:
1160
+ event_data = await asyncio.wait_for(queue.get(), timeout=15.0)
1161
+ yield f"data: {json.dumps(event_data)}\n\n"
1162
+ except asyncio.TimeoutError:
1163
+ yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n"
1164
+ finally:
1165
+ _unregister_sse_queue(run_id, queue)
1166
+
1167
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
1168
+
1169
+
1170
+ @router.get("/dashboard", response_model=DashboardResponse)
1171
+ def get_dashboard(
1172
+ period: str = Query(default="24h", pattern="^(24h|7d|30d)$"),
1173
+ session: Session = Depends(get_session),
1174
+ ) -> DashboardResponse:
1175
+ from trace_agent_server.services.dashboard import build_dashboard
1176
+ return build_dashboard(session, period)
1177
+
1178
+
1179
+ @router.get("/runs/{run_id}/events", response_model=list[EventOut])
1180
+ def list_run_events(
1181
+ run_id: str,
1182
+ type: str | None = None,
1183
+ status: str | None = None,
1184
+ actor: str | None = None,
1185
+ tool_name: str | None = None,
1186
+ step_id: str | None = None,
1187
+ limit: int = Query(default=100, ge=1, le=500),
1188
+ offset: int = Query(default=0, ge=0),
1189
+ session: Session = Depends(get_session),
1190
+ ) -> list[EventOut]:
1191
+ _get_run_or_404(session, run_id)
1192
+ query = select(Event).where(Event.run_id == run_id)
1193
+ if type:
1194
+ query = query.where(Event.type == type)
1195
+ if status:
1196
+ query = query.where(Event.status == status)
1197
+ if actor:
1198
+ query = query.where(Event.actor == actor)
1199
+ if tool_name:
1200
+ query = query.where(Event.tool_name == tool_name)
1201
+ if step_id:
1202
+ query = query.where(Event.step_id == step_id)
1203
+ events = list(session.scalars(query.order_by(Event.seq).offset(offset).limit(limit)))
1204
+ return [_serialize_event(e) for e in events]
1205
+
1206
+
1207
+ @router.get("/runs/{run_id}/turns", response_model=list[TurnOut])
1208
+ def list_run_turns(
1209
+ run_id: str,
1210
+ session: Session = Depends(get_session),
1211
+ ) -> list[TurnOut]:
1212
+ _get_run_or_404(session, run_id)
1213
+ turns = list(session.scalars(select(Turn).where(Turn.run_id == run_id).order_by(Turn.seq)))
1214
+ return [TurnOut.model_validate(t) for t in turns]