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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|