activegraph 1.0.0rc2__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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Reference provider: Anthropic.
|
|
2
|
+
|
|
3
|
+
CONTRACT v0.6 #4. The SDK is imported lazily so the rest of the LLM
|
|
4
|
+
package works without `pip install anthropic`. API key comes from
|
|
5
|
+
ANTHROPIC_API_KEY — never from code, never from a checked-in config.
|
|
6
|
+
|
|
7
|
+
What this provider does:
|
|
8
|
+
* `complete()` — single non-streaming `messages.create` call.
|
|
9
|
+
Structured output is handled by the wrapper, not the provider —
|
|
10
|
+
we just hand back raw text plus token counts.
|
|
11
|
+
* `count_tokens()` — Anthropic's official `count_tokens` API.
|
|
12
|
+
Network roundtrip; the runtime only calls it when
|
|
13
|
+
`budget.max_cost_usd` is set AND no cached response was found
|
|
14
|
+
(decision-4 adjustment).
|
|
15
|
+
* `estimate_cost()` — table-driven, USD Decimals. Pricing as of
|
|
16
|
+
the model-family rates current in 2026; override via the
|
|
17
|
+
`pricing=` constructor kwarg to keep it accurate over time.
|
|
18
|
+
|
|
19
|
+
No tool-use, no streaming, no multi-message orchestration. v0.6
|
|
20
|
+
keeps the surface narrow on purpose.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
from decimal import Decimal
|
|
29
|
+
from typing import Any, Mapping, Optional
|
|
30
|
+
|
|
31
|
+
from activegraph.llm.errors import LLMBehaviorError
|
|
32
|
+
from activegraph.llm.provider import LLMProvider
|
|
33
|
+
from activegraph.llm.types import LLMMessage, LLMResponse, ToolCall
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Per-million-token pricing in USD. Tracks the rates of the Claude 4.x
|
|
37
|
+
# family available in May 2026. Provide your own `pricing=` to override.
|
|
38
|
+
_DEFAULT_PRICING: dict[str, dict[str, str]] = {
|
|
39
|
+
"claude-opus-4": {"input": "15", "output": "75"},
|
|
40
|
+
"claude-sonnet-4": {"input": "3", "output": "15"},
|
|
41
|
+
"claude-haiku-4-5": {"input": "1", "output": "5"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pricing_for(model: str, pricing: Mapping[str, Mapping[str, str]]) -> tuple[Decimal, Decimal]:
|
|
46
|
+
"""Lookup by longest matching family prefix.
|
|
47
|
+
|
|
48
|
+
`claude-sonnet-4-6` resolves to the `claude-sonnet-4` family entry.
|
|
49
|
+
Unknown models fall back to sonnet-4 pricing and emit a warning
|
|
50
|
+
via the returned `Decimal` (caller can detect by comparing to
|
|
51
|
+
family default).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
best_key: Optional[str] = None
|
|
55
|
+
for key in pricing:
|
|
56
|
+
if model.startswith(key) and (best_key is None or len(key) > len(best_key)):
|
|
57
|
+
best_key = key
|
|
58
|
+
if best_key is None:
|
|
59
|
+
best_key = "claude-sonnet-4"
|
|
60
|
+
entry = pricing[best_key]
|
|
61
|
+
return Decimal(str(entry["input"])), Decimal(str(entry["output"]))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AnthropicProvider(LLMProvider):
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
api_key_env: str = "ANTHROPIC_API_KEY",
|
|
69
|
+
client: Any = None,
|
|
70
|
+
pricing: Optional[Mapping[str, Mapping[str, str]]] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._api_key_env = api_key_env
|
|
73
|
+
self._client_override = client
|
|
74
|
+
self._pricing: dict[str, dict[str, str]] = dict(pricing or _DEFAULT_PRICING)
|
|
75
|
+
self._client_cached: Any = None
|
|
76
|
+
|
|
77
|
+
# ---- client lazy-load ----
|
|
78
|
+
|
|
79
|
+
def _client(self) -> Any:
|
|
80
|
+
if self._client_override is not None:
|
|
81
|
+
return self._client_override
|
|
82
|
+
if self._client_cached is not None:
|
|
83
|
+
return self._client_cached
|
|
84
|
+
try:
|
|
85
|
+
from anthropic import Anthropic # type: ignore
|
|
86
|
+
except ImportError as e:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"AnthropicProvider requires the `anthropic` SDK. "
|
|
89
|
+
"Install with `pip install activegraph[llm]` "
|
|
90
|
+
"or `pip install anthropic`."
|
|
91
|
+
) from e
|
|
92
|
+
import os
|
|
93
|
+
|
|
94
|
+
if os.environ.get(self._api_key_env) is None:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"AnthropicProvider needs {self._api_key_env} in the environment."
|
|
97
|
+
)
|
|
98
|
+
self._client_cached = Anthropic()
|
|
99
|
+
return self._client_cached
|
|
100
|
+
|
|
101
|
+
# ---- LLMProvider methods ----
|
|
102
|
+
|
|
103
|
+
def complete(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
system: str,
|
|
107
|
+
messages: list[LLMMessage],
|
|
108
|
+
model: str,
|
|
109
|
+
max_tokens: int,
|
|
110
|
+
temperature: float,
|
|
111
|
+
top_p: float,
|
|
112
|
+
output_schema: Optional[type],
|
|
113
|
+
timeout_seconds: float,
|
|
114
|
+
tools: Optional[list[dict[str, Any]]] = None,
|
|
115
|
+
) -> LLMResponse:
|
|
116
|
+
client = self._client()
|
|
117
|
+
kwargs: dict[str, Any] = {
|
|
118
|
+
"model": model,
|
|
119
|
+
"max_tokens": int(max_tokens),
|
|
120
|
+
"messages": [_message_to_anthropic(m) for m in messages],
|
|
121
|
+
"temperature": float(temperature),
|
|
122
|
+
}
|
|
123
|
+
if system:
|
|
124
|
+
kwargs["system"] = system
|
|
125
|
+
# top_p of 1.0 is the model default; only forward when narrowing.
|
|
126
|
+
if top_p < 1.0:
|
|
127
|
+
kwargs["top_p"] = float(top_p)
|
|
128
|
+
if tools:
|
|
129
|
+
# Anthropic's tools shape: {"name", "description", "input_schema"}.
|
|
130
|
+
# Our Tool.to_definition() already emits this shape.
|
|
131
|
+
kwargs["tools"] = list(tools)
|
|
132
|
+
|
|
133
|
+
t0 = time.monotonic()
|
|
134
|
+
try:
|
|
135
|
+
raw = client.messages.create(timeout=timeout_seconds, **kwargs)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
# Map provider exceptions to reason codes per CONTRACT v0.6 #11.
|
|
138
|
+
reason = _classify_provider_exception(e)
|
|
139
|
+
extras: dict[str, Any] = {
|
|
140
|
+
"model": model,
|
|
141
|
+
"exception_type": type(e).__name__,
|
|
142
|
+
"message": str(e),
|
|
143
|
+
}
|
|
144
|
+
ra = _retry_after_seconds(e)
|
|
145
|
+
if ra is not None:
|
|
146
|
+
extras["retry_after_seconds"] = ra
|
|
147
|
+
raise LLMBehaviorError(reason, str(e), payload_extras=extras) from e
|
|
148
|
+
latency = time.monotonic() - t0
|
|
149
|
+
|
|
150
|
+
text = _extract_text(raw)
|
|
151
|
+
tool_calls = _extract_tool_calls(raw)
|
|
152
|
+
parsed: Any = None
|
|
153
|
+
# If the model returned tool_use blocks the loop isn't done yet
|
|
154
|
+
# — the runtime will dispatch the tools and re-call. Parsing
|
|
155
|
+
# structured output happens on the final turn, not mid-loop.
|
|
156
|
+
if output_schema is not None and not tool_calls:
|
|
157
|
+
parsed = _parse_structured(text, output_schema)
|
|
158
|
+
|
|
159
|
+
in_tok = int(getattr(raw.usage, "input_tokens", 0) or 0)
|
|
160
|
+
out_tok = int(getattr(raw.usage, "output_tokens", 0) or 0)
|
|
161
|
+
cost = self.estimate_cost(
|
|
162
|
+
input_tokens=in_tok, output_tokens=out_tok, model=model
|
|
163
|
+
)
|
|
164
|
+
return LLMResponse(
|
|
165
|
+
raw_text=text,
|
|
166
|
+
parsed=parsed,
|
|
167
|
+
input_tokens=in_tok,
|
|
168
|
+
output_tokens=out_tok,
|
|
169
|
+
cost_usd=cost,
|
|
170
|
+
latency_seconds=latency,
|
|
171
|
+
model=getattr(raw, "model", model),
|
|
172
|
+
finish_reason=str(getattr(raw, "stop_reason", "end_turn") or "end_turn"),
|
|
173
|
+
seed=None, # Anthropic messages API has no seed parameter.
|
|
174
|
+
cache_hit=False,
|
|
175
|
+
tool_calls=tool_calls or None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def estimate_cost(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
input_tokens: int,
|
|
182
|
+
output_tokens: int,
|
|
183
|
+
model: str,
|
|
184
|
+
) -> Decimal:
|
|
185
|
+
in_price, out_price = _pricing_for(model, self._pricing)
|
|
186
|
+
million = Decimal("1000000")
|
|
187
|
+
return (Decimal(input_tokens) * in_price / million) + (
|
|
188
|
+
Decimal(output_tokens) * out_price / million
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def count_tokens(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
system: str,
|
|
195
|
+
messages: list[LLMMessage],
|
|
196
|
+
model: str,
|
|
197
|
+
) -> int:
|
|
198
|
+
client = self._client()
|
|
199
|
+
kwargs: dict[str, Any] = {
|
|
200
|
+
"model": model,
|
|
201
|
+
"messages": [m.to_dict() for m in messages],
|
|
202
|
+
}
|
|
203
|
+
if system:
|
|
204
|
+
kwargs["system"] = system
|
|
205
|
+
result = client.messages.count_tokens(**kwargs)
|
|
206
|
+
return int(getattr(result, "input_tokens", 0) or 0)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---- helpers ---------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_text(raw: Any) -> str:
|
|
213
|
+
"""Concatenate text from a `Message.content` block list."""
|
|
214
|
+
content = getattr(raw, "content", None)
|
|
215
|
+
if content is None:
|
|
216
|
+
return ""
|
|
217
|
+
parts: list[str] = []
|
|
218
|
+
for block in content:
|
|
219
|
+
text = getattr(block, "text", None)
|
|
220
|
+
if isinstance(text, str):
|
|
221
|
+
parts.append(text)
|
|
222
|
+
return "".join(parts)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _extract_tool_calls(raw: Any) -> list[ToolCall]:
|
|
226
|
+
"""Extract `tool_use` blocks from a `Message.content` list. v0.7."""
|
|
227
|
+
content = getattr(raw, "content", None)
|
|
228
|
+
if content is None:
|
|
229
|
+
return []
|
|
230
|
+
out: list[ToolCall] = []
|
|
231
|
+
for block in content:
|
|
232
|
+
block_type = getattr(block, "type", None)
|
|
233
|
+
if block_type != "tool_use":
|
|
234
|
+
continue
|
|
235
|
+
call_id = getattr(block, "id", None) or ""
|
|
236
|
+
name = getattr(block, "name", None) or ""
|
|
237
|
+
args = getattr(block, "input", None) or {}
|
|
238
|
+
if isinstance(args, str):
|
|
239
|
+
try:
|
|
240
|
+
args = json.loads(args)
|
|
241
|
+
except Exception:
|
|
242
|
+
args = {"_raw": args}
|
|
243
|
+
out.append(ToolCall(id=call_id, name=name, args=dict(args)))
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _message_to_anthropic(m: LLMMessage) -> dict[str, Any]:
|
|
248
|
+
"""Convert an LLMMessage to Anthropic message-list shape.
|
|
249
|
+
|
|
250
|
+
For role="tool", Anthropic wants a "user" message with a
|
|
251
|
+
`tool_result` content block. For role in {"user","assistant"} the
|
|
252
|
+
standard {role, content: str} shape works.
|
|
253
|
+
"""
|
|
254
|
+
if m.role == "tool":
|
|
255
|
+
return {
|
|
256
|
+
"role": "user",
|
|
257
|
+
"content": [
|
|
258
|
+
{
|
|
259
|
+
"type": "tool_result",
|
|
260
|
+
"tool_use_id": m.tool_use_id or "",
|
|
261
|
+
"content": m.content,
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
}
|
|
265
|
+
return {"role": m.role, "content": m.content}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", re.DOTALL)
|
|
269
|
+
_BRACE_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _parse_structured(text: str, schema: type) -> Any:
|
|
273
|
+
"""Parse JSON out of an LLM response and validate against `schema`.
|
|
274
|
+
|
|
275
|
+
Strategy: try direct JSON first; on failure, extract a fenced
|
|
276
|
+
```json``` block; on failure, grab the first {...}/[...] span.
|
|
277
|
+
Two distinct failure modes flow back as `LLMBehaviorError`:
|
|
278
|
+
|
|
279
|
+
reason=llm.parse_error — no JSON found / json.loads failed
|
|
280
|
+
reason=llm.schema_violation — JSON found but Pydantic rejected it
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
candidate = text.strip()
|
|
284
|
+
obj: Any = None
|
|
285
|
+
parse_err: Optional[Exception] = None
|
|
286
|
+
try:
|
|
287
|
+
obj = json.loads(candidate)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
parse_err = e
|
|
290
|
+
if obj is None:
|
|
291
|
+
m = _FENCED_JSON_RE.search(text) or _BRACE_RE.search(text)
|
|
292
|
+
if m:
|
|
293
|
+
try:
|
|
294
|
+
obj = json.loads(m.group(1))
|
|
295
|
+
parse_err = None
|
|
296
|
+
except Exception as e:
|
|
297
|
+
parse_err = e
|
|
298
|
+
if obj is None:
|
|
299
|
+
raise LLMBehaviorError(
|
|
300
|
+
"llm.parse_error",
|
|
301
|
+
f"no JSON found in response: {parse_err}",
|
|
302
|
+
payload_extras={"raw_text": text, "underlying": str(parse_err)},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
return schema.model_validate(obj)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
raise LLMBehaviorError(
|
|
309
|
+
"llm.schema_violation",
|
|
310
|
+
f"response did not match schema {schema.__name__}: {e}",
|
|
311
|
+
payload_extras={
|
|
312
|
+
"raw_text": text,
|
|
313
|
+
"schema": schema.__name__,
|
|
314
|
+
"validation_errors": str(e),
|
|
315
|
+
},
|
|
316
|
+
) from e
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _classify_provider_exception(e: Exception) -> str:
|
|
320
|
+
name = type(e).__name__.lower()
|
|
321
|
+
if "ratelimit" in name or "429" in str(e):
|
|
322
|
+
return "llm.rate_limited"
|
|
323
|
+
if "timeout" in name or "connect" in name:
|
|
324
|
+
return "llm.network_error"
|
|
325
|
+
return "llm.network_error"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _retry_after_seconds(e: Exception) -> Optional[float]:
|
|
329
|
+
response = getattr(e, "response", None)
|
|
330
|
+
headers = getattr(response, "headers", None) if response is not None else None
|
|
331
|
+
if headers is None:
|
|
332
|
+
return None
|
|
333
|
+
ra = headers.get("retry-after") if hasattr(headers, "get") else None
|
|
334
|
+
if ra is None:
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
return float(ra)
|
|
338
|
+
except (TypeError, ValueError):
|
|
339
|
+
return None
|
activegraph/llm/cache.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Replay LLM cache.
|
|
2
|
+
|
|
3
|
+
CONTRACT v0.6 #8. The cache is keyed by prompt hash (content-match),
|
|
4
|
+
not by event id — that's what lets a fork's regenerated prompts hit
|
|
5
|
+
the same recorded responses. The originating `llm.requested` event id
|
|
6
|
+
is stored alongside the cached response for trace lineage but is not
|
|
7
|
+
the lookup key.
|
|
8
|
+
|
|
9
|
+
Population paths:
|
|
10
|
+
|
|
11
|
+
* `LLMCache.from_events(events)` — used at `Runtime.load(...,
|
|
12
|
+
replay_llm_cache=True)` and `runtime.fork(...,
|
|
13
|
+
replay_llm_cache=True)`. Walks the recorded log and harvests every
|
|
14
|
+
`llm.responded` event whose preceding `llm.requested` carries a
|
|
15
|
+
`prompt_hash`.
|
|
16
|
+
|
|
17
|
+
* `cache.record(prompt_hash, response, requesting_event_id)` —
|
|
18
|
+
called inline from the LLM-behavior invocation path so that
|
|
19
|
+
same-run repeat prompts hit the cache too (cheap insurance).
|
|
20
|
+
|
|
21
|
+
`replay_strict=True` semantics layer on top: if a recorded
|
|
22
|
+
`llm.responded` event references a `prompt_hash` that the live
|
|
23
|
+
re-assembled prompt does NOT produce, the runtime raises
|
|
24
|
+
`ReplayDivergenceError(event_id=<llm.requested id>, expected=<recorded
|
|
25
|
+
hash>, actual=<rebuilt hash>)` — same divergence-pinning pattern as
|
|
26
|
+
the existing event-stream comparison. See decision-2 adjustment.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from decimal import Decimal
|
|
33
|
+
from typing import Iterable, Optional
|
|
34
|
+
|
|
35
|
+
from activegraph.core.event import Event
|
|
36
|
+
from activegraph.llm.types import LLMResponse
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CachedEntry:
|
|
41
|
+
response: LLMResponse
|
|
42
|
+
requested_event_id: Optional[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMCache:
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._by_hash: dict[str, CachedEntry] = {}
|
|
48
|
+
|
|
49
|
+
# ---- read ----
|
|
50
|
+
|
|
51
|
+
def get(self, prompt_hash: str) -> Optional[LLMResponse]:
|
|
52
|
+
entry = self._by_hash.get(prompt_hash)
|
|
53
|
+
if entry is None:
|
|
54
|
+
return None
|
|
55
|
+
# Mark cache_hit on a copy so we don't mutate the stored
|
|
56
|
+
# response (a second hit on the same hash should also report
|
|
57
|
+
# cache_hit=True).
|
|
58
|
+
r = entry.response
|
|
59
|
+
return LLMResponse(
|
|
60
|
+
raw_text=r.raw_text,
|
|
61
|
+
parsed=r.parsed,
|
|
62
|
+
input_tokens=r.input_tokens,
|
|
63
|
+
output_tokens=r.output_tokens,
|
|
64
|
+
cost_usd=r.cost_usd,
|
|
65
|
+
latency_seconds=r.latency_seconds,
|
|
66
|
+
model=r.model,
|
|
67
|
+
finish_reason=r.finish_reason,
|
|
68
|
+
seed=r.seed,
|
|
69
|
+
cache_hit=True,
|
|
70
|
+
provider_meta=dict(r.provider_meta),
|
|
71
|
+
# v0.7: tool_calls must round-trip so the turn loop sees
|
|
72
|
+
# the same shape live vs cached.
|
|
73
|
+
tool_calls=list(r.tool_calls) if r.tool_calls else None,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def has(self, prompt_hash: str) -> bool:
|
|
77
|
+
return prompt_hash in self._by_hash
|
|
78
|
+
|
|
79
|
+
def __len__(self) -> int:
|
|
80
|
+
return len(self._by_hash)
|
|
81
|
+
|
|
82
|
+
# ---- write ----
|
|
83
|
+
|
|
84
|
+
def record(
|
|
85
|
+
self,
|
|
86
|
+
prompt_hash: str,
|
|
87
|
+
response: LLMResponse,
|
|
88
|
+
*,
|
|
89
|
+
requesting_event_id: Optional[str] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
# Store an un-flagged copy so subsequent `get()` calls
|
|
92
|
+
# consistently set cache_hit=True.
|
|
93
|
+
# v0.7: tolerate test-provider responses without tool_calls.
|
|
94
|
+
tool_calls = getattr(response, "tool_calls", None)
|
|
95
|
+
clean = LLMResponse(
|
|
96
|
+
raw_text=response.raw_text,
|
|
97
|
+
parsed=response.parsed,
|
|
98
|
+
input_tokens=response.input_tokens,
|
|
99
|
+
output_tokens=response.output_tokens,
|
|
100
|
+
cost_usd=response.cost_usd,
|
|
101
|
+
latency_seconds=response.latency_seconds,
|
|
102
|
+
model=response.model,
|
|
103
|
+
finish_reason=response.finish_reason,
|
|
104
|
+
seed=response.seed,
|
|
105
|
+
cache_hit=False,
|
|
106
|
+
provider_meta=dict(response.provider_meta),
|
|
107
|
+
tool_calls=list(tool_calls) if tool_calls else None,
|
|
108
|
+
)
|
|
109
|
+
self._by_hash[prompt_hash] = CachedEntry(
|
|
110
|
+
response=clean, requested_event_id=requesting_event_id
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# ---- bulk-load from a recorded event log ----
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_events(cls, events: Iterable[Event]) -> "LLMCache":
|
|
117
|
+
"""Walk the log and harvest every `llm.responded` whose
|
|
118
|
+
preceding `llm.requested` carries a `prompt_hash`.
|
|
119
|
+
|
|
120
|
+
The pairing rule: an `llm.responded.caused_by` points at its
|
|
121
|
+
`llm.requested.id`. The `prompt_hash` is on the `llm.requested`
|
|
122
|
+
payload.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
cache = cls()
|
|
126
|
+
events_list = list(events)
|
|
127
|
+
by_id: dict[str, Event] = {e.id: e for e in events_list}
|
|
128
|
+
for e in events_list:
|
|
129
|
+
if e.type != "llm.responded":
|
|
130
|
+
continue
|
|
131
|
+
request_id = e.caused_by
|
|
132
|
+
if request_id is None:
|
|
133
|
+
continue
|
|
134
|
+
request = by_id.get(request_id)
|
|
135
|
+
if request is None or request.type != "llm.requested":
|
|
136
|
+
continue
|
|
137
|
+
prompt_hash = request.payload.get("prompt_hash")
|
|
138
|
+
if not prompt_hash:
|
|
139
|
+
continue
|
|
140
|
+
response = _response_from_event_payload(e.payload)
|
|
141
|
+
cache.record(
|
|
142
|
+
prompt_hash, response, requesting_event_id=request_id
|
|
143
|
+
)
|
|
144
|
+
return cache
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _response_from_event_payload(payload: dict) -> LLMResponse:
|
|
148
|
+
from activegraph.llm.types import ToolCall
|
|
149
|
+
|
|
150
|
+
cost = payload.get("cost_usd", "0")
|
|
151
|
+
if not isinstance(cost, Decimal):
|
|
152
|
+
cost = Decimal(str(cost))
|
|
153
|
+
# v0.7: tool_calls round-trip through the event payload as plain
|
|
154
|
+
# dicts; hydrate back into ToolCall instances so the runtime's
|
|
155
|
+
# turn loop sees a uniform shape.
|
|
156
|
+
tc_payload = payload.get("tool_calls")
|
|
157
|
+
tool_calls = None
|
|
158
|
+
if tc_payload:
|
|
159
|
+
tool_calls = [
|
|
160
|
+
ToolCall(
|
|
161
|
+
id=tc.get("id", ""),
|
|
162
|
+
name=tc.get("name", ""),
|
|
163
|
+
args=dict(tc.get("args", {})),
|
|
164
|
+
)
|
|
165
|
+
for tc in tc_payload
|
|
166
|
+
]
|
|
167
|
+
return LLMResponse(
|
|
168
|
+
raw_text=payload.get("raw_text", ""),
|
|
169
|
+
parsed=payload.get("parsed"),
|
|
170
|
+
input_tokens=int(payload.get("input_tokens", 0) or 0),
|
|
171
|
+
output_tokens=int(payload.get("output_tokens", 0) or 0),
|
|
172
|
+
cost_usd=cost,
|
|
173
|
+
latency_seconds=float(payload.get("latency_seconds", 0.0) or 0.0),
|
|
174
|
+
model=payload.get("model", "?"),
|
|
175
|
+
finish_reason=payload.get("finish_reason", "?"),
|
|
176
|
+
seed=payload.get("seed"),
|
|
177
|
+
cache_hit=False,
|
|
178
|
+
provider_meta=dict(payload.get("provider_meta", {}) or {}),
|
|
179
|
+
tool_calls=tool_calls,
|
|
180
|
+
)
|