reactifact 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/tools.py ADDED
@@ -0,0 +1,111 @@
1
+ """Tools: the \"function + schema\" contract for LLM agents (§46–47, §68).
2
+
3
+ `@tool` turns an async function into a `FunctionTool` (name, description and JSON
4
+ argument schema are derived from the signature). Tool usage — see `llm_agent.LLMAgent`:
5
+ the \"which tool and with which arguments\" decision loop is driven by the LLM, the
6
+ framework executes.
7
+
8
+ Tools are capabilities; they are not part of consume/produce: those bind artifacts.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ from abc import ABC, abstractmethod
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field, create_model
19
+
20
+
21
+ class ToolOutput(BaseModel):
22
+ """Tool execution result. `error` is set on failure (not an exception)."""
23
+
24
+ text: str = ""
25
+ data: dict[str, Any] = Field(default_factory=dict)
26
+ error: str = ""
27
+
28
+
29
+ class Tool(ABC):
30
+ """Contract for an external operation: \"Do this operation\" (§46).
31
+
32
+ For an LLM agent (`LLMAgent`) a tool must provide a JSON schema of its
33
+ arguments (`schema`), from which the model picks the operation and arguments.
34
+ `@tool` builds it from the signature automatically.
35
+ """
36
+
37
+ name: str
38
+ description: str = ""
39
+ destructive: bool = False
40
+ schema: dict[str, Any] = {}
41
+
42
+ @abstractmethod
43
+ async def execute(self, args: dict[str, Any]) -> ToolOutput:
44
+ """Execute the operation; failure is an exception or `ToolOutput(error=...)`."""
45
+ ...
46
+
47
+
48
+ def _args_model(fn: Callable[..., Any]) -> type[BaseModel]:
49
+ """Builds a Pydantic model of arguments from the function signature (→ JSON schema)."""
50
+ fields: dict[str, Any] = {}
51
+ for name, param in inspect.signature(fn).parameters.items():
52
+ if param.kind not in (
53
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
54
+ inspect.Parameter.KEYWORD_ONLY,
55
+ ):
56
+ continue
57
+ annotation = (
58
+ param.annotation if param.annotation is not inspect.Parameter.empty else Any
59
+ )
60
+ if param.default is inspect.Parameter.empty:
61
+ fields[name] = (annotation, ...) # required argument
62
+ else:
63
+ fields[name] = (annotation, param.default)
64
+ return create_model(f"args_{fn.__name__}", **fields)
65
+
66
+
67
+ class FunctionTool(Tool):
68
+ """Tool from a plain async function: schema is derived from the signature."""
69
+
70
+ def __init__(
71
+ self,
72
+ fn: Callable[..., Any],
73
+ *,
74
+ name: str | None = None,
75
+ destructive: bool = False,
76
+ description: str | None = None,
77
+ ):
78
+ self._fn = fn
79
+ self.args_model = _args_model(fn)
80
+ self.name = name or fn.__name__
81
+ self.destructive = destructive
82
+ self.description = description or (fn.__doc__ or "").strip() or fn.__name__
83
+ self.schema = self.args_model.model_json_schema()
84
+
85
+ async def execute(self, args: dict[str, Any]) -> ToolOutput:
86
+ validated = self.args_model(**args)
87
+ result = await self._fn(**validated.model_dump())
88
+ if isinstance(result, ToolOutput):
89
+ return result
90
+ if isinstance(result, str):
91
+ return ToolOutput(text=result)
92
+ if isinstance(result, dict):
93
+ return ToolOutput(data=result)
94
+ return ToolOutput(text=str(result))
95
+
96
+
97
+ def tool(
98
+ fn: Callable[..., Any] | None = None,
99
+ *,
100
+ name: str | None = None,
101
+ destructive: bool = False,
102
+ description: str | None = None,
103
+ ) -> Any:
104
+ """Decorator: turns an async function into a `FunctionTool` (schema from the signature)."""
105
+
106
+ def wrap(f: Callable[..., Any]) -> FunctionTool:
107
+ return FunctionTool(
108
+ f, name=name, destructive=destructive, description=description
109
+ )
110
+
111
+ return wrap(fn) if fn is not None else wrap
@@ -0,0 +1,29 @@
1
+ """Observability: traces of runs (§54).
2
+
3
+ Package: models (`models`), delivery (`tracer`), storage (`store`), UI
4
+ (`web` + `templates/traces.html`). The public API is re-exported here —
5
+ but without `web`, so that importing the package does not pull in FastAPI.
6
+ """
7
+
8
+ from .langfuse import LangfuseTracer
9
+ from .models import AgentSpan, ArtifactRef, LLMCall, RelationRef, RunTrace
10
+ from .postgres import PostgresStore
11
+ from .store import TraceReader, TraceSink, TraceStore
12
+ from .tracer import CompositeTracer, RecordingLLM, RunTracer, Tracer
13
+
14
+ __all__ = [
15
+ "AgentSpan",
16
+ "ArtifactRef",
17
+ "CompositeTracer",
18
+ "LLMCall",
19
+ "LangfuseTracer",
20
+ "PostgresStore",
21
+ "RecordingLLM",
22
+ "RelationRef",
23
+ "RunTrace",
24
+ "RunTracer",
25
+ "TraceReader",
26
+ "TraceSink",
27
+ "TraceStore",
28
+ "Tracer",
29
+ ]
@@ -0,0 +1,125 @@
1
+ """Langfuse sink: pushes `RunTrace` to Langfuse via the HTTP Public API.
2
+
3
+ Mapping: RunTrace → trace (`POST /api/public/traces`), AgentSpan → SPAN observation,
4
+ LLMCall → GENERATION observation (`POST /api/public/observations`). Authentication is
5
+ Basic (public_key:secret_key). Only `on_turn_end`; the sink requires network — Langfuse
6
+ is external.
7
+
8
+ Uses httpx (base dependency). For tests you can inject `client`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ from .models import RunTrace
18
+ from .tracer import Tracer
19
+
20
+
21
+ def _iso(value: datetime | None) -> str | None:
22
+ return value.isoformat() if value is not None else None
23
+
24
+
25
+ def _type_summary(refs: list[Any]) -> dict[str, int]:
26
+ summary: dict[str, int] = {}
27
+ for ref in refs:
28
+ kind = getattr(ref, "data_type", None) or type(ref).__name__
29
+ summary[kind] = summary.get(kind, 0) + 1
30
+ return summary
31
+
32
+
33
+ class LangfuseTracer(Tracer):
34
+ """Observer that exports traces to Langfuse."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ public_key: str,
40
+ secret_key: str,
41
+ host: str = "https://cloud.langfuse.com",
42
+ api_url: str | None = None,
43
+ client: Any | None = None,
44
+ ):
45
+ self._base = (api_url or host).rstrip("/") + "/api/public"
46
+ token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
47
+ self._headers = {"Authorization": f"Basic {token}"}
48
+ if client is not None:
49
+ self._client = client
50
+ else:
51
+ import httpx
52
+
53
+ self._client = httpx.AsyncClient(headers=self._headers)
54
+
55
+ async def on_turn_end(self, trace: RunTrace) -> None:
56
+ await self._post(
57
+ "/traces",
58
+ {
59
+ "id": trace.id,
60
+ "name": "reactifact run",
61
+ "timestamp": _iso(trace.started_at),
62
+ "sessionId": trace.session_id or None,
63
+ "metadata": {
64
+ "duration_ms": trace.duration_ms,
65
+ "outcome": trace.outcome,
66
+ "spans": len(trace.spans),
67
+ },
68
+ },
69
+ )
70
+ for span in trace.spans:
71
+ await self._post(
72
+ "/observations",
73
+ {
74
+ "id": f"{trace.id}:span:{span.agent}",
75
+ "traceId": trace.id,
76
+ "name": span.agent,
77
+ "type": "SPAN",
78
+ "startTime": _iso(span.started_at),
79
+ # Meaningful input/output for the Langfuse UI: what the agent
80
+ # received (reads) and what it produced (writes), with
81
+ # per-type counts that mirror the trace dashboard grouping.
82
+ "input": {
83
+ "reads": [r.model_dump() for r in span.reads],
84
+ "read_summary": _type_summary(span.reads),
85
+ },
86
+ "output": {
87
+ "writes": [w.model_dump() for w in span.writes],
88
+ "write_summary": _type_summary(span.writes),
89
+ },
90
+ "metadata": {
91
+ "event_type": span.event_type,
92
+ "latency_ms": span.latency_ms,
93
+ "error": span.error,
94
+ "reads": [r.model_dump() for r in span.reads],
95
+ "writes": [w.model_dump() for w in span.writes],
96
+ },
97
+ },
98
+ )
99
+ for call in span.llm_calls:
100
+ await self._post(
101
+ "/observations",
102
+ {
103
+ "id": f"{trace.id}:llm:{call.agent}:{span.agent}",
104
+ "traceId": trace.id,
105
+ "name": f"llm:{call.model or call.provider}",
106
+ "type": "GENERATION",
107
+ "model": call.model or None,
108
+ "input": {"messages": call.messages},
109
+ "output": call.response or None,
110
+ "usage": {
111
+ "input": call.prompt_tokens,
112
+ "output": call.completion_tokens,
113
+ "unit": "TOKENS",
114
+ },
115
+ "metadata": {
116
+ "agent": call.agent,
117
+ "provider": call.provider,
118
+ "latency_ms": call.latency_ms,
119
+ "error": call.error,
120
+ },
121
+ },
122
+ )
123
+
124
+ async def _post(self, path: str, payload: dict[str, Any]) -> None:
125
+ await self._client.post(self._base + path, json=payload)
@@ -0,0 +1,93 @@
1
+ """Trace models (§54).
2
+
3
+ `RunTrace` is a run: metadata + agent spans. Each `AgentSpan` carries
4
+ `reads`/`writes` as `ArtifactRef` (with type and truncated content) and a list
5
+ of `LLMCall` (prompt/response/tokens). `RunTrace.llm_calls` is a flat projection
6
+ of all run model calls.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from datetime import UTC, datetime
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class ArtifactRef(BaseModel):
19
+ """Reference to an artifact with data for inspection (§54).
20
+
21
+ `data` is truncated JSON (string), so as not to drag the entire content into the trace.
22
+ """
23
+
24
+ artifact_id: str
25
+ version: int = 0
26
+ op_type: str = ""
27
+ data_type: str = ""
28
+ data: str | None = None
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return self.model_dump()
32
+
33
+
34
+ class LLMCall(BaseModel):
35
+ """One model call: request, response, tokens, latency (§54)."""
36
+
37
+ agent: str = ""
38
+ provider: str = ""
39
+ model: str = ""
40
+ messages: list[dict[str, Any]] = Field(default_factory=list)
41
+ response: str = ""
42
+ prompt_tokens: int = 0
43
+ completion_tokens: int = 0
44
+ latency_ms: float = 0.0
45
+ error: str | None = None
46
+
47
+
48
+ class RelationRef(BaseModel):
49
+ """A provenance edge recorded for a span (`source —relation→ target`, §34)."""
50
+
51
+ source_id: str
52
+ relation: str
53
+ target_id: str
54
+ source_type: str = ""
55
+ target_type: str = ""
56
+
57
+
58
+ class AgentSpan(BaseModel):
59
+ """A single agent execution within a run (§54)."""
60
+
61
+ agent: str
62
+ event_type: str = ""
63
+ reads: list[ArtifactRef] = Field(default_factory=list)
64
+ writes: list[ArtifactRef] = Field(default_factory=list)
65
+ relations: list[RelationRef] = Field(default_factory=list)
66
+ llm_calls: list[LLMCall] = Field(default_factory=list)
67
+ latency_ms: float = 0.0
68
+ error: str | None = None
69
+ started_at: datetime | None = None
70
+
71
+
72
+ class RunTrace(BaseModel):
73
+ """Trace of a single run (turn): agent executions + outcome."""
74
+
75
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
76
+ session_id: str = ""
77
+ started_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
78
+ duration_ms: float = 0.0
79
+ outcome: str = ""
80
+ spans: list[AgentSpan] = Field(default_factory=list)
81
+
82
+ @property
83
+ def llm_calls(self) -> list[LLMCall]:
84
+ calls: list[LLMCall] = []
85
+ for span in self.spans:
86
+ calls.extend(span.llm_calls)
87
+ return calls
88
+
89
+ def add_span(self, span: AgentSpan) -> None:
90
+ self.spans.append(span)
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return self.model_dump(mode="json")
@@ -0,0 +1,220 @@
1
+ # mypy: ignore-errors
2
+ """PostgreSQL trace sink: pushes `RunTrace` to PG and can serve it back.
3
+
4
+ Requires the ``pg`` extra (psycopg async): imported lazily, so the `tracing`
5
+ package works without it. Schema mirrors the SQLite sink: `runs`/`spans` tables
6
+ with reads/writes/relations/llm_calls as jsonb.
7
+
8
+ Both write (`export`) and read (`query`/`get`) are async — this is what lets the
9
+ web dashboard in `create_trace_router` read from Postgres directly.
10
+
11
+ Connections are short-lived (opened per operation via
12
+ `psycopg.AsyncConnection`), so the store is not bound to any event loop and can
13
+ be shared across requests.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import UTC
19
+ from typing import Any
20
+
21
+ from .models import AgentSpan, ArtifactRef, LLMCall, RelationRef, RunTrace
22
+
23
+
24
+ class PostgresStore:
25
+ """Postgres trace sink with async write + read. Requires the `pg` extra."""
26
+
27
+ def __init__(self, dsn: str):
28
+ from .._extras import require_extra
29
+
30
+ self._psycopg = require_extra("PostgresStore", "psycopg", "pg")
31
+ self.dsn = dsn
32
+ self._schema_ready = False
33
+
34
+ async def _ensure_schema(self) -> None:
35
+ if self._schema_ready:
36
+ return
37
+ conn = await self._psycopg.AsyncConnection.connect(self.dsn)
38
+ try:
39
+ async with conn.cursor() as cur:
40
+ await cur.execute(
41
+ """
42
+ CREATE TABLE IF NOT EXISTS runs (
43
+ id TEXT PRIMARY KEY,
44
+ session_id TEXT NOT NULL DEFAULT '',
45
+ started_at TIMESTAMPTZ NOT NULL,
46
+ duration_ms REAL NOT NULL,
47
+ outcome TEXT NOT NULL
48
+ )
49
+ """
50
+ )
51
+ await cur.execute(
52
+ """
53
+ CREATE TABLE IF NOT EXISTS spans (
54
+ id BIGSERIAL PRIMARY KEY,
55
+ run_id TEXT NOT NULL REFERENCES runs(id),
56
+ agent TEXT NOT NULL,
57
+ event_type TEXT NOT NULL DEFAULT '',
58
+ latency_ms REAL NOT NULL DEFAULT 0,
59
+ error TEXT,
60
+ reads JSONB NOT NULL DEFAULT '[]',
61
+ writes JSONB NOT NULL DEFAULT '[]',
62
+ relations JSONB NOT NULL DEFAULT '[]',
63
+ llm_calls JSONB NOT NULL DEFAULT '[]'
64
+ )
65
+ """
66
+ )
67
+ await cur.execute(
68
+ "CREATE INDEX IF NOT EXISTS idx_spans_run ON spans(run_id)",
69
+ )
70
+ await conn.commit()
71
+ finally:
72
+ await conn.close()
73
+ self._schema_ready = True
74
+
75
+ async def export(self, trace: RunTrace) -> None:
76
+
77
+ import psycopg.types.json
78
+
79
+ await self._ensure_schema()
80
+ started = trace.started_at
81
+ if started is not None and started.tzinfo is None:
82
+ started = started.replace(tzinfo=UTC)
83
+ conn = await self._psycopg.AsyncConnection.connect(self.dsn)
84
+ try:
85
+ async with conn.cursor() as cur:
86
+ await cur.execute(
87
+ "INSERT INTO runs (id, session_id, started_at, duration_ms, outcome) "
88
+ "VALUES (%s, %s, %s, %s, %s)",
89
+ (
90
+ trace.id,
91
+ trace.session_id,
92
+ started,
93
+ trace.duration_ms,
94
+ trace.outcome,
95
+ ),
96
+ )
97
+ for span in trace.spans:
98
+ await cur.execute(
99
+ "INSERT INTO spans (run_id, agent, event_type, latency_ms, error, "
100
+ "reads, writes, relations, llm_calls) "
101
+ "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
102
+ (
103
+ trace.id,
104
+ span.agent,
105
+ span.event_type,
106
+ span.latency_ms,
107
+ span.error,
108
+ psycopg.types.json.Jsonb(
109
+ [r.model_dump(mode="json") for r in span.reads]
110
+ ),
111
+ psycopg.types.json.Jsonb(
112
+ [w.model_dump(mode="json") for w in span.writes]
113
+ ),
114
+ psycopg.types.json.Jsonb(
115
+ [r.model_dump(mode="json") for r in span.relations]
116
+ ),
117
+ psycopg.types.json.Jsonb(
118
+ [c.model_dump(mode="json") for c in span.llm_calls]
119
+ ),
120
+ ),
121
+ )
122
+ await conn.commit()
123
+ finally:
124
+ await conn.close()
125
+
126
+ async def query(
127
+ self,
128
+ *,
129
+ session_id: str | None = None,
130
+ outcome: str | None = None,
131
+ limit: int = 50,
132
+ offset: int = 0,
133
+ ) -> dict[str, Any]:
134
+ await self._ensure_schema()
135
+ where: list[str] = []
136
+ args: list[Any] = []
137
+ if session_id is not None:
138
+ where.append("session_id = %s")
139
+ args.append(session_id)
140
+ if outcome is not None:
141
+ where.append("outcome = %s")
142
+ args.append(outcome)
143
+ where_sql = (" WHERE " + " AND ".join(where)) if where else ""
144
+
145
+ conn = await self._psycopg.AsyncConnection.connect(self.dsn)
146
+ try:
147
+ async with conn.cursor() as cur:
148
+ await cur.execute(f"SELECT COUNT(*) FROM runs{where_sql}", tuple(args))
149
+ total = (await cur.fetchone())[0]
150
+ await cur.execute(
151
+ f"SELECT id, session_id, started_at, duration_ms, outcome, "
152
+ f"(SELECT COUNT(*) FROM spans WHERE run_id = runs.id) AS spans_count "
153
+ f"FROM runs{where_sql} ORDER BY started_at DESC LIMIT %s OFFSET %s",
154
+ (*args, limit, offset),
155
+ )
156
+ rows = await cur.fetchall()
157
+ finally:
158
+ await conn.close()
159
+
160
+ items = [
161
+ {
162
+ "id": r[0],
163
+ "session_id": r[1],
164
+ "started_at": r[2],
165
+ "duration_ms": round(r[3], 1),
166
+ "outcome": r[4],
167
+ "spans": r[5],
168
+ }
169
+ for r in rows
170
+ ]
171
+ return {"items": items, "total": total}
172
+
173
+ async def get(self, trace_id: str) -> RunTrace | None:
174
+ from datetime import UTC
175
+
176
+ await self._ensure_schema()
177
+ conn = await self._psycopg.AsyncConnection.connect(self.dsn)
178
+ try:
179
+ async with conn.cursor() as cur:
180
+ await cur.execute(
181
+ "SELECT id, session_id, started_at, duration_ms, outcome "
182
+ "FROM runs WHERE id = %s",
183
+ (trace_id,),
184
+ )
185
+ row = await cur.fetchone()
186
+ if row is None:
187
+ return None
188
+ await cur.execute(
189
+ "SELECT agent, event_type, latency_ms, error, reads, writes, "
190
+ "relations, llm_calls FROM spans WHERE run_id = %s ORDER BY id",
191
+ (trace_id,),
192
+ )
193
+ span_rows = await cur.fetchall()
194
+ finally:
195
+ await conn.close()
196
+
197
+ spans = [
198
+ AgentSpan(
199
+ agent=r[0],
200
+ event_type=r[1],
201
+ latency_ms=r[2],
202
+ error=r[3],
203
+ reads=[ArtifactRef(**d) for d in r[4]],
204
+ writes=[ArtifactRef(**d) for d in r[5]],
205
+ relations=[RelationRef(**d) for d in r[6]],
206
+ llm_calls=[LLMCall(**d) for d in r[7]],
207
+ )
208
+ for r in span_rows
209
+ ]
210
+ started = row[2]
211
+ if started.tzinfo is None:
212
+ started = started.replace(tzinfo=UTC)
213
+ return RunTrace(
214
+ id=row[0],
215
+ session_id=row[1],
216
+ started_at=started,
217
+ duration_ms=row[3],
218
+ outcome=row[4],
219
+ spans=spans,
220
+ )