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,66 @@
|
|
|
1
|
+
"""The `LLMProvider` Protocol every provider implements.
|
|
2
|
+
|
|
3
|
+
CONTRACT v0.6 #3, extended in v0.7. Narrow, explicit, keyword-only.
|
|
4
|
+
Shipped reference implementation is `AnthropicProvider`. Tests use
|
|
5
|
+
`RecordedLLMProvider` + `RecordingLLMProvider`. The demo ships its
|
|
6
|
+
own scripted provider.
|
|
7
|
+
|
|
8
|
+
A provider does three things:
|
|
9
|
+
* `complete()`: run a single non-streaming completion. v0.7 adds
|
|
10
|
+
an optional `tools=` parameter; when non-empty, the model is
|
|
11
|
+
allowed to return tool_use blocks in the response.
|
|
12
|
+
* `estimate_cost()`: turn token counts into USD (Decimal).
|
|
13
|
+
* `count_tokens()`: provider-official input token count for the
|
|
14
|
+
prompt that's about to be sent. Used for pre-call budget gating
|
|
15
|
+
when `budget.max_cost_usd` is set; otherwise skipped (see
|
|
16
|
+
CONTRACT v0.6 #4 / decision 10).
|
|
17
|
+
|
|
18
|
+
No streaming, no multi-model orchestration — those are deferred to
|
|
19
|
+
v0.8+. Tool use IS in v0.7, but the loop is orchestrated by the
|
|
20
|
+
runtime, not the provider: provider returns `tool_calls`, runtime
|
|
21
|
+
invokes the tool, runtime re-calls `complete()` with the result
|
|
22
|
+
echoed back as a `role="tool"` message.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from typing import Any, Optional, Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from activegraph.llm.types import LLMMessage, LLMResponse
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class LLMProvider(Protocol):
|
|
35
|
+
def complete(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
system: str,
|
|
39
|
+
messages: list[LLMMessage],
|
|
40
|
+
model: str,
|
|
41
|
+
max_tokens: int,
|
|
42
|
+
temperature: float,
|
|
43
|
+
top_p: float,
|
|
44
|
+
output_schema: Optional[type],
|
|
45
|
+
timeout_seconds: float,
|
|
46
|
+
tools: Optional[list[dict[str, Any]]] = None,
|
|
47
|
+
) -> LLMResponse:
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def estimate_cost(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
input_tokens: int,
|
|
54
|
+
output_tokens: int,
|
|
55
|
+
model: str,
|
|
56
|
+
) -> Decimal:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def count_tokens(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
system: str,
|
|
63
|
+
messages: list[LLMMessage],
|
|
64
|
+
model: str,
|
|
65
|
+
) -> int:
|
|
66
|
+
...
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Fixture-based LLM providers for tests.
|
|
2
|
+
|
|
3
|
+
CONTRACT v0.6 #12, plus decision-3 adjustment for `recorded_at`:
|
|
4
|
+
|
|
5
|
+
RecordedLLMProvider — looks up fixtures by prompt hash. Tests run
|
|
6
|
+
against this. Raises if a fixture is
|
|
7
|
+
missing (so tests fail loud rather than
|
|
8
|
+
regressing into live calls).
|
|
9
|
+
|
|
10
|
+
RecordingLLMProvider — wraps another provider, mirrors every call
|
|
11
|
+
to disk as a fixture file. Use once with
|
|
12
|
+
`--record` to seed fixtures, then commit
|
|
13
|
+
them. Marked with `@pytest.mark.records_llm`
|
|
14
|
+
so they don't run in CI without explicit
|
|
15
|
+
opt-in.
|
|
16
|
+
|
|
17
|
+
Fixture file layout (`tests/fixtures/llm/<sha256_hex>.json`):
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"prompt_hash": "<sha256_hex>",
|
|
21
|
+
"recorded_at": "2026-05-15T10:32:01Z", # outside the hash
|
|
22
|
+
"model": "claude-sonnet-4-5",
|
|
23
|
+
"prompt": { # only this hashes
|
|
24
|
+
"model": ..., "system": ..., "messages": [...],
|
|
25
|
+
"output_schema_name": ..., "output_schema_json": {...},
|
|
26
|
+
"max_tokens": ..., "temperature": ..., "top_p": ...,
|
|
27
|
+
"deterministic": ...
|
|
28
|
+
},
|
|
29
|
+
"response": {
|
|
30
|
+
"raw_text": "...", "parsed": {...},
|
|
31
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
32
|
+
"cost_usd": "0.001", "latency_seconds": 0.0,
|
|
33
|
+
"model": "...", "finish_reason": "end_turn",
|
|
34
|
+
"seed": null, "provider_meta": {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
`recorded_at` is intentionally OUTSIDE the hashed `prompt` payload so
|
|
39
|
+
it doesn't perturb lookups but stays available for future debugging
|
|
40
|
+
when fixtures drift.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import hashlib
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from decimal import Decimal
|
|
50
|
+
from typing import Any, Optional
|
|
51
|
+
|
|
52
|
+
from activegraph.llm.errors import LLMBehaviorError
|
|
53
|
+
from activegraph.llm.provider import LLMProvider
|
|
54
|
+
from activegraph.llm.types import LLMMessage, LLMResponse
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _now_iso() -> str:
|
|
58
|
+
return (
|
|
59
|
+
datetime.now(tz=timezone.utc)
|
|
60
|
+
.isoformat(timespec="seconds")
|
|
61
|
+
.replace("+00:00", "Z")
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _canonical_prompt_payload(
|
|
66
|
+
*,
|
|
67
|
+
model: str,
|
|
68
|
+
system: str,
|
|
69
|
+
messages: list[LLMMessage],
|
|
70
|
+
output_schema: Optional[type],
|
|
71
|
+
max_tokens: int,
|
|
72
|
+
temperature: float,
|
|
73
|
+
top_p: float,
|
|
74
|
+
deterministic: bool,
|
|
75
|
+
tools: Optional[list[dict[str, Any]]] = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
from activegraph.llm.prompt import schema_to_json
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"model": model,
|
|
81
|
+
"system": system,
|
|
82
|
+
"messages": [m.to_dict() for m in messages],
|
|
83
|
+
"output_schema_name": (
|
|
84
|
+
getattr(output_schema, "__name__", None) if output_schema else None
|
|
85
|
+
),
|
|
86
|
+
"output_schema_json": schema_to_json(output_schema),
|
|
87
|
+
"max_tokens": int(max_tokens),
|
|
88
|
+
"temperature": float(temperature),
|
|
89
|
+
"top_p": float(top_p),
|
|
90
|
+
"deterministic": bool(deterministic),
|
|
91
|
+
# v0.7: tool definitions contribute to the prompt hash so a
|
|
92
|
+
# behavior gaining or losing a tool produces a different key.
|
|
93
|
+
"tools": list(tools) if tools else None,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _hash_payload(payload: dict[str, Any]) -> str:
|
|
98
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
99
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------- RecordedLLMProvider ---------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RecordedLLMProvider(LLMProvider):
|
|
106
|
+
"""Reads fixtures from a directory keyed by prompt hash.
|
|
107
|
+
|
|
108
|
+
Tests construct one of these instead of an `AnthropicProvider`.
|
|
109
|
+
Missing fixtures raise so the test fails loud — there is no
|
|
110
|
+
silent fallthrough to a real call.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, fixtures_dir: str) -> None:
|
|
114
|
+
self._dir = fixtures_dir
|
|
115
|
+
|
|
116
|
+
# ---- LLMProvider methods ----
|
|
117
|
+
|
|
118
|
+
def complete(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
system: str,
|
|
122
|
+
messages: list[LLMMessage],
|
|
123
|
+
model: str,
|
|
124
|
+
max_tokens: int,
|
|
125
|
+
temperature: float,
|
|
126
|
+
top_p: float,
|
|
127
|
+
output_schema: Optional[type],
|
|
128
|
+
timeout_seconds: float,
|
|
129
|
+
tools: Optional[list[dict[str, Any]]] = None,
|
|
130
|
+
) -> LLMResponse:
|
|
131
|
+
payload = _canonical_prompt_payload(
|
|
132
|
+
model=model,
|
|
133
|
+
system=system,
|
|
134
|
+
messages=messages,
|
|
135
|
+
output_schema=output_schema,
|
|
136
|
+
max_tokens=max_tokens,
|
|
137
|
+
temperature=temperature,
|
|
138
|
+
top_p=top_p,
|
|
139
|
+
deterministic=(temperature == 0.0 and top_p == 1.0),
|
|
140
|
+
tools=tools,
|
|
141
|
+
)
|
|
142
|
+
prompt_hash = _hash_payload(payload)
|
|
143
|
+
path = os.path.join(self._dir, f"{prompt_hash}.json")
|
|
144
|
+
if not os.path.exists(path):
|
|
145
|
+
raise LLMBehaviorError(
|
|
146
|
+
"llm.fixture_missing",
|
|
147
|
+
f"no recorded fixture for prompt_hash={prompt_hash} in {self._dir}",
|
|
148
|
+
payload_extras={"prompt_hash": prompt_hash, "fixtures_dir": self._dir},
|
|
149
|
+
)
|
|
150
|
+
with open(path, "r") as f:
|
|
151
|
+
data = json.load(f)
|
|
152
|
+
return _response_from_fixture(data["response"], output_schema)
|
|
153
|
+
|
|
154
|
+
def estimate_cost(
|
|
155
|
+
self, *, input_tokens: int, output_tokens: int, model: str
|
|
156
|
+
) -> Decimal:
|
|
157
|
+
# Tests don't care about real pricing; just return zero.
|
|
158
|
+
return Decimal("0")
|
|
159
|
+
|
|
160
|
+
def count_tokens(
|
|
161
|
+
self, *, system: str, messages: list[LLMMessage], model: str
|
|
162
|
+
) -> int:
|
|
163
|
+
total = len(system) + sum(len(m.content) for m in messages)
|
|
164
|
+
return max(1, total // 4)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _response_from_fixture(
|
|
168
|
+
rdata: dict[str, Any], output_schema: Optional[type]
|
|
169
|
+
) -> LLMResponse:
|
|
170
|
+
parsed_raw = rdata.get("parsed")
|
|
171
|
+
parsed: Any = None
|
|
172
|
+
if parsed_raw is not None and output_schema is not None:
|
|
173
|
+
parsed = output_schema.model_validate(parsed_raw)
|
|
174
|
+
elif parsed_raw is not None:
|
|
175
|
+
parsed = parsed_raw
|
|
176
|
+
cost = rdata.get("cost_usd", "0")
|
|
177
|
+
if not isinstance(cost, Decimal):
|
|
178
|
+
cost = Decimal(str(cost))
|
|
179
|
+
return LLMResponse(
|
|
180
|
+
raw_text=rdata.get("raw_text", ""),
|
|
181
|
+
parsed=parsed,
|
|
182
|
+
input_tokens=int(rdata.get("input_tokens", 0) or 0),
|
|
183
|
+
output_tokens=int(rdata.get("output_tokens", 0) or 0),
|
|
184
|
+
cost_usd=cost,
|
|
185
|
+
latency_seconds=float(rdata.get("latency_seconds", 0.0) or 0.0),
|
|
186
|
+
model=rdata.get("model", "?"),
|
|
187
|
+
finish_reason=rdata.get("finish_reason", "end_turn"),
|
|
188
|
+
seed=rdata.get("seed"),
|
|
189
|
+
cache_hit=False,
|
|
190
|
+
provider_meta=dict(rdata.get("provider_meta", {}) or {}),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------- RecordingLLMProvider --------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RecordingLLMProvider(LLMProvider):
|
|
198
|
+
"""Wraps a real provider and persists responses to fixtures.
|
|
199
|
+
|
|
200
|
+
Use this once (with `--record` opt-in) to seed `tests/fixtures/llm`
|
|
201
|
+
against the live Anthropic API, then commit the fixtures and run
|
|
202
|
+
tests against `RecordedLLMProvider` thereafter.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, inner: LLMProvider, fixtures_dir: str) -> None:
|
|
206
|
+
self._inner = inner
|
|
207
|
+
self._dir = fixtures_dir
|
|
208
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
def complete(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
system: str,
|
|
214
|
+
messages: list[LLMMessage],
|
|
215
|
+
model: str,
|
|
216
|
+
max_tokens: int,
|
|
217
|
+
temperature: float,
|
|
218
|
+
top_p: float,
|
|
219
|
+
output_schema: Optional[type],
|
|
220
|
+
timeout_seconds: float,
|
|
221
|
+
tools: Optional[list[dict[str, Any]]] = None,
|
|
222
|
+
) -> LLMResponse:
|
|
223
|
+
response = self._inner.complete(
|
|
224
|
+
system=system,
|
|
225
|
+
messages=messages,
|
|
226
|
+
model=model,
|
|
227
|
+
max_tokens=max_tokens,
|
|
228
|
+
temperature=temperature,
|
|
229
|
+
top_p=top_p,
|
|
230
|
+
output_schema=output_schema,
|
|
231
|
+
timeout_seconds=timeout_seconds,
|
|
232
|
+
tools=tools,
|
|
233
|
+
)
|
|
234
|
+
payload = _canonical_prompt_payload(
|
|
235
|
+
model=model,
|
|
236
|
+
system=system,
|
|
237
|
+
messages=messages,
|
|
238
|
+
output_schema=output_schema,
|
|
239
|
+
max_tokens=max_tokens,
|
|
240
|
+
temperature=temperature,
|
|
241
|
+
top_p=top_p,
|
|
242
|
+
deterministic=(temperature == 0.0 and top_p == 1.0),
|
|
243
|
+
tools=tools,
|
|
244
|
+
)
|
|
245
|
+
prompt_hash = _hash_payload(payload)
|
|
246
|
+
fixture = {
|
|
247
|
+
"prompt_hash": prompt_hash,
|
|
248
|
+
"recorded_at": _now_iso(),
|
|
249
|
+
"model": model,
|
|
250
|
+
"prompt": payload,
|
|
251
|
+
"response": response.to_dict(),
|
|
252
|
+
}
|
|
253
|
+
path = os.path.join(self._dir, f"{prompt_hash}.json")
|
|
254
|
+
with open(path, "w") as f:
|
|
255
|
+
json.dump(fixture, f, indent=2, sort_keys=True)
|
|
256
|
+
return response
|
|
257
|
+
|
|
258
|
+
def estimate_cost(
|
|
259
|
+
self, *, input_tokens: int, output_tokens: int, model: str
|
|
260
|
+
) -> Decimal:
|
|
261
|
+
return self._inner.estimate_cost(
|
|
262
|
+
input_tokens=input_tokens, output_tokens=output_tokens, model=model
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def count_tokens(
|
|
266
|
+
self, *, system: str, messages: list[LLMMessage], model: str
|
|
267
|
+
) -> int:
|
|
268
|
+
return self._inner.count_tokens(
|
|
269
|
+
system=system, messages=messages, model=model
|
|
270
|
+
)
|
activegraph/llm/types.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""LLM data types. Locked in v0.6, extended in v0.7.
|
|
2
|
+
|
|
3
|
+
These shapes are part of the public contract:
|
|
4
|
+
|
|
5
|
+
LLMMessage — single role-tagged message in the conversation history.
|
|
6
|
+
v0.7 adds the "tool" role and `tool_use_id` so the
|
|
7
|
+
LLM ↔ tool turn loop can echo results back.
|
|
8
|
+
ToolCall — a single tool-call request returned by the model
|
|
9
|
+
inside an `LLMResponse.tool_calls`. v0.7 addition.
|
|
10
|
+
LLMResponse — what every provider's `complete()` returns. Carries
|
|
11
|
+
raw text, parsed structured output (if a schema was
|
|
12
|
+
requested), token counts, cost, latency, model id,
|
|
13
|
+
finish reason, a `cache_hit` flag, and (v0.7) an
|
|
14
|
+
optional list of `tool_calls`.
|
|
15
|
+
|
|
16
|
+
Anything provider-specific (Anthropic stop reasons, retry-after
|
|
17
|
+
seconds, etc.) goes into the `provider_meta` dict so the contract
|
|
18
|
+
stays narrow.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from decimal import Decimal
|
|
25
|
+
from typing import Any, Literal, Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# v0.7: the assistant can return tool_use blocks; the user echoes results
|
|
29
|
+
# back as a "tool" message. Anthropic uses content blocks with a
|
|
30
|
+
# `tool_use_id`; we flatten to a string content + carry the id alongside.
|
|
31
|
+
Role = Literal["user", "assistant", "tool"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LLMMessage:
|
|
36
|
+
"""One message in a chat-style prompt.
|
|
37
|
+
|
|
38
|
+
Anthropic's `system` prompt is conventionally separate from the
|
|
39
|
+
`messages` list, so we keep `system` out of this dataclass and pass
|
|
40
|
+
it as its own argument to `LLMProvider.complete()`. That keeps the
|
|
41
|
+
interface aligned with what the SDK actually wants.
|
|
42
|
+
|
|
43
|
+
CONTRACT v0.7: a `role="tool"` message echoes a tool result back to
|
|
44
|
+
the model. `tool_use_id` ties it to the originating tool_use block
|
|
45
|
+
from the previous assistant turn.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
role: Role
|
|
49
|
+
content: str
|
|
50
|
+
tool_use_id: Optional[str] = None
|
|
51
|
+
tool_name: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
out: dict[str, Any] = {"role": self.role, "content": self.content}
|
|
55
|
+
if self.tool_use_id is not None:
|
|
56
|
+
out["tool_use_id"] = self.tool_use_id
|
|
57
|
+
if self.tool_name is not None:
|
|
58
|
+
out["tool_name"] = self.tool_name
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ToolCall:
|
|
64
|
+
"""A single tool-call request returned inside `LLMResponse.tool_calls`.
|
|
65
|
+
|
|
66
|
+
`id` is the provider-assigned identifier the assistant uses to
|
|
67
|
+
match up its tool_use block with the following tool result; the
|
|
68
|
+
runtime forwards it back as `LLMMessage.tool_use_id`. `name`
|
|
69
|
+
matches the tool's `@tool(name=...)`. `args` is the JSON-shaped
|
|
70
|
+
argument payload.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
name: str
|
|
75
|
+
args: dict[str, Any]
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
return {"id": self.id, "name": self.name, "args": dict(self.args)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class LLMResponse:
|
|
83
|
+
raw_text: str
|
|
84
|
+
parsed: Optional[Any]
|
|
85
|
+
input_tokens: int
|
|
86
|
+
output_tokens: int
|
|
87
|
+
cost_usd: Decimal
|
|
88
|
+
latency_seconds: float
|
|
89
|
+
model: str
|
|
90
|
+
finish_reason: str
|
|
91
|
+
seed: Optional[int] = None
|
|
92
|
+
cache_hit: bool = False
|
|
93
|
+
provider_meta: dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
# v0.7: when finish_reason indicates the model wants to call tools,
|
|
95
|
+
# `tool_calls` is non-empty and the runtime enters the turn loop
|
|
96
|
+
# instead of handing `parsed` to the handler.
|
|
97
|
+
tool_calls: Optional[list[ToolCall]] = None
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
return {
|
|
101
|
+
"raw_text": self.raw_text,
|
|
102
|
+
"parsed": _parsed_to_jsonable(self.parsed),
|
|
103
|
+
"input_tokens": int(self.input_tokens),
|
|
104
|
+
"output_tokens": int(self.output_tokens),
|
|
105
|
+
"cost_usd": str(self.cost_usd),
|
|
106
|
+
"latency_seconds": float(self.latency_seconds),
|
|
107
|
+
"model": self.model,
|
|
108
|
+
"finish_reason": self.finish_reason,
|
|
109
|
+
"seed": self.seed,
|
|
110
|
+
"cache_hit": bool(self.cache_hit),
|
|
111
|
+
"provider_meta": dict(self.provider_meta),
|
|
112
|
+
"tool_calls": (
|
|
113
|
+
[tc.to_dict() for tc in self.tool_calls]
|
|
114
|
+
if self.tool_calls
|
|
115
|
+
else None
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parsed_to_jsonable(parsed: Any) -> Any:
|
|
121
|
+
"""Make a Pydantic model JSON-safe for event payloads."""
|
|
122
|
+
if parsed is None:
|
|
123
|
+
return None
|
|
124
|
+
dump = getattr(parsed, "model_dump", None)
|
|
125
|
+
if callable(dump):
|
|
126
|
+
try:
|
|
127
|
+
return dump(mode="json")
|
|
128
|
+
except TypeError:
|
|
129
|
+
return dump()
|
|
130
|
+
return parsed
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Operator-facing observability surface. v0.8.
|
|
2
|
+
|
|
3
|
+
Three pillars, all opt-in:
|
|
4
|
+
|
|
5
|
+
- Structured logging (``configure_logging``) — JSON-line logger setup
|
|
6
|
+
matching the documented schema. Off by default; users with existing
|
|
7
|
+
``logging`` config keep theirs.
|
|
8
|
+
- Metrics (``Metrics`` protocol + ``NoOpMetrics`` + ``PrometheusMetrics``)
|
|
9
|
+
— three methods, fixed metric names. NoOp is the default.
|
|
10
|
+
- Runtime introspection (``RuntimeStatus``) — frozen snapshot returned
|
|
11
|
+
by ``runtime.status()``.
|
|
12
|
+
|
|
13
|
+
The framework never auto-configures any of these. A library that does
|
|
14
|
+
is hostile to operators who already have their own config.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from activegraph.observability.logging import (
|
|
18
|
+
LOG_FIELDS,
|
|
19
|
+
configure_logging,
|
|
20
|
+
get_logger,
|
|
21
|
+
runtime_log_extra,
|
|
22
|
+
)
|
|
23
|
+
from activegraph.observability.metrics import (
|
|
24
|
+
METRIC_NAMES,
|
|
25
|
+
Metrics,
|
|
26
|
+
NoOpMetrics,
|
|
27
|
+
)
|
|
28
|
+
from activegraph.observability.migration import (
|
|
29
|
+
MigrationReport,
|
|
30
|
+
MigrationRunReport,
|
|
31
|
+
migrate,
|
|
32
|
+
)
|
|
33
|
+
from activegraph.observability.prometheus import PrometheusMetrics
|
|
34
|
+
from activegraph.observability.status import (
|
|
35
|
+
BehaviorInfo,
|
|
36
|
+
BudgetSnapshot,
|
|
37
|
+
EventSummary,
|
|
38
|
+
FrameSnapshot,
|
|
39
|
+
RuntimeStatus,
|
|
40
|
+
status_to_dict,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"BehaviorInfo",
|
|
45
|
+
"BudgetSnapshot",
|
|
46
|
+
"EventSummary",
|
|
47
|
+
"FrameSnapshot",
|
|
48
|
+
"LOG_FIELDS",
|
|
49
|
+
"METRIC_NAMES",
|
|
50
|
+
"Metrics",
|
|
51
|
+
"MigrationReport",
|
|
52
|
+
"MigrationRunReport",
|
|
53
|
+
"NoOpMetrics",
|
|
54
|
+
"PrometheusMetrics",
|
|
55
|
+
"RuntimeStatus",
|
|
56
|
+
"configure_logging",
|
|
57
|
+
"get_logger",
|
|
58
|
+
"migrate",
|
|
59
|
+
"runtime_log_extra",
|
|
60
|
+
"status_to_dict",
|
|
61
|
+
]
|