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,320 @@
|
|
|
1
|
+
"""Tool-side errors. CONTRACT v0.7 #6, + v1.0 PR-D format migration.
|
|
2
|
+
|
|
3
|
+
Three surface types:
|
|
4
|
+
|
|
5
|
+
- :class:`ToolError` — structured failure from a tool body. Carries a
|
|
6
|
+
``reason`` code that the runtime merges into
|
|
7
|
+
``tool.responded.payload.error`` and into the wrapping behavior's
|
|
8
|
+
``behavior.failed`` event.
|
|
9
|
+
|
|
10
|
+
- :class:`MissingToolError` — raised at runtime startup when an
|
|
11
|
+
``@llm_behavior`` declares a tool name the runtime cannot find.
|
|
12
|
+
Stays a plain RuntimeError subclass through PR-D; PR-E re-parents.
|
|
13
|
+
|
|
14
|
+
- :class:`UnknownToolError` — raised when an LLM response asks for a
|
|
15
|
+
tool the behavior did not declare. Caught by the runtime and
|
|
16
|
+
surfaced as ``behavior.failed reason="tool.unknown_tool"``.
|
|
17
|
+
|
|
18
|
+
PR-D migrates ``ToolError`` and ``UnknownToolError`` to
|
|
19
|
+
:class:`activegraph.errors.ExecutionError`. The
|
|
20
|
+
``(reason, message, payload_extras)`` constructor on ``ToolError`` is
|
|
21
|
+
preserved; per-reason prose lives in ``_TOOL_REASON_PROSE``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from activegraph.errors import ExecutionError, RegistrationError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tool_prose_timeout(message: str) -> tuple[str, str, str]:
|
|
32
|
+
return (
|
|
33
|
+
f"A tool invocation exceeded its declared `timeout_seconds`:\n {message}",
|
|
34
|
+
"Tools declare a per-call timeout at the decorator. The runtime "
|
|
35
|
+
"enforces it so a slow or hung tool can't stall the whole behavior "
|
|
36
|
+
"loop. A timed-out call returns a structured failure to the calling "
|
|
37
|
+
"behavior; the behavior decides whether to retry, fall back, or "
|
|
38
|
+
"fail.",
|
|
39
|
+
"If the timeout is too aggressive for the expected work, raise the "
|
|
40
|
+
"tool's `timeout_seconds`. If the timeout is hitting because the "
|
|
41
|
+
"endpoint is slow under contention, the right answer is usually a "
|
|
42
|
+
"narrower retry policy in the calling behavior rather than a higher "
|
|
43
|
+
"ceiling.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tool_prose_network_error(message: str) -> tuple[str, str, str]:
|
|
48
|
+
return (
|
|
49
|
+
f"A tool call failed with a network error:\n {message}",
|
|
50
|
+
"Tools that reach the network can fail for many reasons (DNS, TLS, "
|
|
51
|
+
"connection drop, mid-transfer error). The framework treats these as "
|
|
52
|
+
"structured tool failures rather than untyped exceptions so the "
|
|
53
|
+
"calling behavior can read `reason='tool.network_error'` from the "
|
|
54
|
+
"tool.responded event payload and decide how to proceed.",
|
|
55
|
+
"Inspect the tool.responded event for the full underlying error. "
|
|
56
|
+
"Common recoveries: re-run after the network stabilizes, switch to "
|
|
57
|
+
"RecordedTool for offline replay, or add explicit retry-on-network "
|
|
58
|
+
"logic in the calling behavior.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _tool_prose_invalid_input(message: str) -> tuple[str, str, str]:
|
|
63
|
+
return (
|
|
64
|
+
f"A tool was invoked with arguments that didn't match its input schema:\n {message}",
|
|
65
|
+
"Tools declare typed input via Pydantic models. The framework "
|
|
66
|
+
"validates arguments before invoking the body so a malformed call "
|
|
67
|
+
"fails at the boundary with a clear error instead of producing a "
|
|
68
|
+
"stack trace inside the tool. This is the same Pydantic invariant "
|
|
69
|
+
"the LLM output_schema enforces — typed input is the contract.",
|
|
70
|
+
"Check the tool's declared input schema (in the @tool decorator) "
|
|
71
|
+
"against the arguments the LLM produced. If the LLM is producing "
|
|
72
|
+
"consistently malformed args, the prompt may need an explicit "
|
|
73
|
+
"example of correct invocation; if the tool's schema is too "
|
|
74
|
+
"strict, relax the relevant field.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tool_prose_invalid_output(message: str) -> tuple[str, str, str]:
|
|
79
|
+
return (
|
|
80
|
+
f"A tool returned a value that didn't match its output schema:\n {message}",
|
|
81
|
+
"Tools declare typed output via Pydantic models. The framework "
|
|
82
|
+
"validates the return value before merging it into the "
|
|
83
|
+
"tool.responded event so downstream behaviors can rely on the "
|
|
84
|
+
"shape. A schema-violating return is a bug in the tool body — "
|
|
85
|
+
"the audit trail would lie if the framework silently coerced it.",
|
|
86
|
+
"Fix the tool body to return data matching the declared schema, "
|
|
87
|
+
"or relax the schema if the actual return shape is correct. The "
|
|
88
|
+
"underlying value is in the tool.responded payload for inspection."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _tool_prose_execution_error(message: str) -> tuple[str, str, str]:
|
|
93
|
+
return (
|
|
94
|
+
f"A tool body raised an exception:\n {message}",
|
|
95
|
+
"When a tool body raises, the framework catches it and surfaces a "
|
|
96
|
+
"structured failure so the calling behavior can read "
|
|
97
|
+
"`reason='tool.execution_error'` from tool.responded and decide "
|
|
98
|
+
"whether to retry or fail. The raw exception is preserved in "
|
|
99
|
+
"payload_extras for diagnosis without leaking it past the tool "
|
|
100
|
+
"boundary.",
|
|
101
|
+
"Inspect tool.responded.payload_extras for the original exception "
|
|
102
|
+
"type and traceback. If the failure is intrinsic to the tool's "
|
|
103
|
+
"inputs (bad data), tighten the input validation. If it's "
|
|
104
|
+
"intermittent, add retry-on-execution-error logic to the calling "
|
|
105
|
+
"behavior.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _tool_prose_fixture_missing(message: str) -> tuple[str, str, str]:
|
|
110
|
+
return (
|
|
111
|
+
f"A RecordedTool has no fixture for this argument combination:\n {message}",
|
|
112
|
+
"RecordedTool replays a directory of recorded tool responses keyed "
|
|
113
|
+
"by tool name + argument hash. A missing fixture means the live "
|
|
114
|
+
"arguments don't match any recorded invocation — either the tool's "
|
|
115
|
+
"arguments changed since recording (a behavior edit, an upstream "
|
|
116
|
+
"data shift), or this is a new invocation that was never recorded.",
|
|
117
|
+
"Re-record the fixture from a live run with the current arguments:\n"
|
|
118
|
+
" 1. Switch the tool to its live implementation\n"
|
|
119
|
+
" 2. Run the goal once to produce live responses\n"
|
|
120
|
+
" 3. The recorder writes new fixtures alongside the existing ones\n"
|
|
121
|
+
" 4. Subsequent runs against RecordedTool replay them\n"
|
|
122
|
+
"\n"
|
|
123
|
+
"Or diff the args against the recorded hash to find the drift.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_TOOL_REASON_PROSE: dict[str, Any] = {
|
|
128
|
+
"tool.timeout": _tool_prose_timeout,
|
|
129
|
+
"tool.network_error": _tool_prose_network_error,
|
|
130
|
+
"tool.invalid_input": _tool_prose_invalid_input,
|
|
131
|
+
"tool.invalid_output": _tool_prose_invalid_output,
|
|
132
|
+
"tool.execution_error": _tool_prose_execution_error,
|
|
133
|
+
"tool.fixture_missing": _tool_prose_fixture_missing,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _tool_fallback_prose(reason: str, message: str) -> tuple[str, str, str]:
|
|
138
|
+
return (
|
|
139
|
+
f"A tool invocation failed with reason {reason!r}:\n {message}",
|
|
140
|
+
f"The runtime catches structured failures from tool bodies and merges "
|
|
141
|
+
f"them into the emitted tool.responded event, where the calling "
|
|
142
|
+
f"behavior can read `reason={reason!r}` and decide how to proceed. "
|
|
143
|
+
f"The exception you're seeing is the underlying carrier.",
|
|
144
|
+
f"Inspect the tool.responded event in the trace:\n"
|
|
145
|
+
f" activegraph inspect <store> --tail 50\n"
|
|
146
|
+
f"\n"
|
|
147
|
+
f"The full message is preserved verbatim above; check the tool's "
|
|
148
|
+
f"documentation for reason {reason!r}.",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MissingToolError(RegistrationError, RuntimeError):
|
|
153
|
+
"""An ``@llm_behavior`` declares a tool name the runtime cannot find
|
|
154
|
+
in its tool registry at startup.
|
|
155
|
+
|
|
156
|
+
Fires at construction time, not at LLM-call time — the runtime
|
|
157
|
+
validates the declared tools once when the behavior registers.
|
|
158
|
+
Multi-inherits :class:`RuntimeError` for back-compat.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
_doc_slug = "missing-tool-error"
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
tool_name: str,
|
|
166
|
+
*,
|
|
167
|
+
behavior_name: Optional[str] = None,
|
|
168
|
+
registered: Optional[tuple[str, ...]] = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
self.tool_name = tool_name
|
|
171
|
+
self.behavior_name = behavior_name
|
|
172
|
+
self.registered = registered or ()
|
|
173
|
+
ctx: dict[str, Any] = {"tool_name": tool_name}
|
|
174
|
+
if behavior_name:
|
|
175
|
+
ctx["behavior_name"] = behavior_name
|
|
176
|
+
if self.registered:
|
|
177
|
+
ctx["registered"] = list(self.registered)
|
|
178
|
+
sample = ""
|
|
179
|
+
if self.registered:
|
|
180
|
+
preview = ", ".join(repr(n) for n in list(self.registered)[:6])
|
|
181
|
+
extra = f" (+{len(self.registered) - 6} more)" if len(self.registered) > 6 else ""
|
|
182
|
+
sample = f"\n registered tools: {preview}{extra}"
|
|
183
|
+
on_behavior = (
|
|
184
|
+
f" on @llm_behavior {behavior_name!r}" if behavior_name else ""
|
|
185
|
+
)
|
|
186
|
+
RegistrationError.__init__(
|
|
187
|
+
self,
|
|
188
|
+
f"no tool named {tool_name!r} is registered",
|
|
189
|
+
what_failed=(
|
|
190
|
+
f"@llm_behavior declares the tool {tool_name!r}{on_behavior}, "
|
|
191
|
+
f"but the Runtime's tool registry has no tool by that name.{sample}"
|
|
192
|
+
),
|
|
193
|
+
why=(
|
|
194
|
+
"@llm_behavior validates its declared tools at startup so a "
|
|
195
|
+
"misconfiguration fails before any LLM call burns budget. "
|
|
196
|
+
"A missing tool at LLM-call time would either produce "
|
|
197
|
+
"UnknownToolError on every invocation (cost without "
|
|
198
|
+
"progress) or silently drop the call (which would corrupt "
|
|
199
|
+
"the audit trail). Validation at registration prevents both."
|
|
200
|
+
),
|
|
201
|
+
how_to_fix=(
|
|
202
|
+
"Either register the tool with the runtime:\n"
|
|
203
|
+
" rt = Runtime(graph, tools=[my_tool, ...])\n"
|
|
204
|
+
"or, if the tool comes from a pack, load the pack:\n"
|
|
205
|
+
" rt.load_pack(my_pack)\n"
|
|
206
|
+
"\n"
|
|
207
|
+
"For pack-scoped tools, use the canonical name "
|
|
208
|
+
"`'pack_name.tool_name'` in the @llm_behavior's "
|
|
209
|
+
"`tools=[...]` argument."
|
|
210
|
+
),
|
|
211
|
+
context=ctx,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class UnknownToolError(ExecutionError, RuntimeError):
|
|
216
|
+
"""Raised when an LLM response calls a tool the behavior didn't declare.
|
|
217
|
+
|
|
218
|
+
The runtime catches it during the LLM tool-loop and surfaces it as
|
|
219
|
+
``behavior.failed reason="tool.unknown_tool"``. Multi-inherits
|
|
220
|
+
RuntimeError so user code that catches RuntimeError around runtime
|
|
221
|
+
operations continues to work.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
_doc_slug = "unknown-tool-error"
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
message: str,
|
|
229
|
+
*,
|
|
230
|
+
tool_name: Optional[str] = None,
|
|
231
|
+
behavior_name: Optional[str] = None,
|
|
232
|
+
declared_tools: Optional[tuple[str, ...]] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
self.tool_name = tool_name
|
|
235
|
+
self.behavior_name = behavior_name
|
|
236
|
+
self.declared_tools = declared_tools or ()
|
|
237
|
+
ctx: dict[str, Any] = {"message": message}
|
|
238
|
+
if tool_name is not None:
|
|
239
|
+
ctx["tool_name"] = tool_name
|
|
240
|
+
if behavior_name is not None:
|
|
241
|
+
ctx["behavior_name"] = behavior_name
|
|
242
|
+
if self.declared_tools:
|
|
243
|
+
ctx["declared_tools"] = list(self.declared_tools)
|
|
244
|
+
declared_list = (
|
|
245
|
+
", ".join(repr(t) for t in self.declared_tools)
|
|
246
|
+
if self.declared_tools
|
|
247
|
+
else "(none declared)"
|
|
248
|
+
)
|
|
249
|
+
ExecutionError.__init__(
|
|
250
|
+
self,
|
|
251
|
+
message,
|
|
252
|
+
what_failed=(
|
|
253
|
+
f"An LLM response asked to invoke a tool that the calling "
|
|
254
|
+
f"behavior did not declare.\n"
|
|
255
|
+
f" tool requested: {tool_name!r}\n"
|
|
256
|
+
f" declared on behavior {behavior_name!r}: {declared_list}"
|
|
257
|
+
),
|
|
258
|
+
why=(
|
|
259
|
+
"@llm_behavior declares the exact set of tools the wrapped "
|
|
260
|
+
"behavior is allowed to invoke. The runtime refuses any other "
|
|
261
|
+
"tool call rather than silently execute it — an undeclared "
|
|
262
|
+
"tool could perform side effects the behavior's audit trail "
|
|
263
|
+
"doesn't account for, which would break replay determinism."
|
|
264
|
+
),
|
|
265
|
+
how_to_fix=(
|
|
266
|
+
f"Either add {tool_name!r} to the @llm_behavior's `tools=[...]` "
|
|
267
|
+
f"list (and confirm the tool is registered with @tool), or "
|
|
268
|
+
f"adjust the prompt so the model stops asking for it. If the "
|
|
269
|
+
f"model is consistently asking for an undeclared tool, the "
|
|
270
|
+
f"prompt may be implying capabilities the behavior doesn't "
|
|
271
|
+
f"have — be explicit about which tools are available."
|
|
272
|
+
),
|
|
273
|
+
context=ctx,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class ToolError(ExecutionError, Exception):
|
|
278
|
+
"""Structured failure from inside a tool invocation.
|
|
279
|
+
|
|
280
|
+
``reason`` must be one of the v0.7 codes:
|
|
281
|
+
|
|
282
|
+
tool.timeout, tool.network_error, tool.invalid_input,
|
|
283
|
+
tool.invalid_output, tool.execution_error,
|
|
284
|
+
tool.unknown_tool, tool.fixture_missing,
|
|
285
|
+
budget.tool_calls_exhausted, budget.cost_exhausted.
|
|
286
|
+
|
|
287
|
+
Constructor signature ``(reason, message, *, payload_extras=)`` is
|
|
288
|
+
preserved from v0.7 so the internal raise sites in tool bodies do
|
|
289
|
+
not change. The structured-format fields are auto-derived from
|
|
290
|
+
``reason`` via ``_TOOL_REASON_PROSE``.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
_doc_slug = "tool-error"
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
reason: str,
|
|
298
|
+
message: str,
|
|
299
|
+
*,
|
|
300
|
+
payload_extras: Optional[dict[str, Any]] = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
self.reason = reason
|
|
303
|
+
self.payload_extras = dict(payload_extras or {})
|
|
304
|
+
prose_fn = _TOOL_REASON_PROSE.get(reason)
|
|
305
|
+
if prose_fn is None:
|
|
306
|
+
what_failed, why, how_to_fix = _tool_fallback_prose(reason, message)
|
|
307
|
+
else:
|
|
308
|
+
what_failed, why, how_to_fix = prose_fn(message)
|
|
309
|
+
ExecutionError.__init__(
|
|
310
|
+
self,
|
|
311
|
+
f"{reason}: {message}",
|
|
312
|
+
what_failed=what_failed,
|
|
313
|
+
why=why,
|
|
314
|
+
how_to_fix=how_to_fix,
|
|
315
|
+
context={
|
|
316
|
+
"reason": reason,
|
|
317
|
+
"message": message,
|
|
318
|
+
"payload_extras": self.payload_extras,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Reference tool: graph_query. CONTRACT v0.7 #16.
|
|
2
|
+
|
|
3
|
+
`graph_query` is a tool whose body operates on the graph itself —
|
|
4
|
+
the demonstration that the tool primitive is general, not just an
|
|
5
|
+
"external API" escape hatch. It goes through the same event-sourced
|
|
6
|
+
invocation path as any other tool: `tool.requested` / `tool.responded`
|
|
7
|
+
events, the same cache, the same budget.
|
|
8
|
+
|
|
9
|
+
Per CONTRACT v0.7 #5, the `ToolContext` deliberately does NOT carry
|
|
10
|
+
a graph reference — tools that need the graph close over it via a
|
|
11
|
+
factory at registration time. `make_graph_query_tool(graph)` returns
|
|
12
|
+
a Tool bound to the supplied graph.
|
|
13
|
+
|
|
14
|
+
Per CONTRACT v0.7 #7 (tool-determinism decision), `graph_query` is
|
|
15
|
+
marked `deterministic=True`: given the same graph state, it returns
|
|
16
|
+
the same answer. But replay still serves from cache by default; the
|
|
17
|
+
`Runtime(replay_reinvoke_deterministic=True)` opt-in is what lets
|
|
18
|
+
deterministic tools actually re-invoke during replay. Reasoning:
|
|
19
|
+
even a deterministic tool's correctness depends on the reconstructed
|
|
20
|
+
graph state matching the recorded state at the moment of the call,
|
|
21
|
+
and that's a strong invariant — cheaper and more honest to serve
|
|
22
|
+
from cache.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, Field
|
|
31
|
+
|
|
32
|
+
from activegraph.tools.base import Tool
|
|
33
|
+
from activegraph.tools.context import ToolContext
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ObjectRef(BaseModel):
|
|
37
|
+
id: str
|
|
38
|
+
type: str
|
|
39
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GraphQueryInput(BaseModel):
|
|
43
|
+
object_type: str = Field(description="The object type to query.")
|
|
44
|
+
where: Optional[dict[str, Any]] = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description=(
|
|
47
|
+
"Optional filter: dotted-path keys, literal or {op: value} values. "
|
|
48
|
+
"Same semantics as Graph.query(where=...)."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
limit: int = Field(default=50, ge=1, le=500)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GraphQueryOutput(BaseModel):
|
|
55
|
+
refs: list[ObjectRef]
|
|
56
|
+
truncated: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_graph_query_tool(graph) -> Tool:
|
|
60
|
+
"""Factory: produces a `graph_query` tool bound to the given Graph.
|
|
61
|
+
|
|
62
|
+
Returns a Tool, NOT a registered callable — the caller is expected
|
|
63
|
+
to either pass it directly into `@llm_behavior(tools=[...])` or
|
|
64
|
+
into `Runtime(tools=[...])`. We do NOT push it into the global
|
|
65
|
+
`@tool` registry: graph-bound tools are runtime-specific, and a
|
|
66
|
+
global registry would silently couple tools to the first graph
|
|
67
|
+
that constructed one.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def fn(args: GraphQueryInput, ctx: ToolContext) -> GraphQueryOutput:
|
|
71
|
+
results = graph.query(object_type=args.object_type, where=args.where)
|
|
72
|
+
truncated = len(results) > args.limit
|
|
73
|
+
results = results[: args.limit]
|
|
74
|
+
return GraphQueryOutput(
|
|
75
|
+
refs=[
|
|
76
|
+
ObjectRef(id=o.id, type=o.type, data=dict(o.data))
|
|
77
|
+
for o in results
|
|
78
|
+
],
|
|
79
|
+
truncated=truncated,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return Tool(
|
|
83
|
+
name="graph_query",
|
|
84
|
+
fn=fn,
|
|
85
|
+
description=(
|
|
86
|
+
"Query objects in the active graph by type and optional WHERE "
|
|
87
|
+
"filter. Returns object id, type, and data for matching objects."
|
|
88
|
+
),
|
|
89
|
+
input_schema=GraphQueryInput,
|
|
90
|
+
output_schema=GraphQueryOutput,
|
|
91
|
+
cost_per_call=Decimal("0"),
|
|
92
|
+
timeout_seconds=1.0,
|
|
93
|
+
deterministic=True,
|
|
94
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Fixture-based tool invokers for tests. CONTRACT v0.7 #15.
|
|
2
|
+
|
|
3
|
+
Mirrors `activegraph.llm.recorded`. Fixtures live at
|
|
4
|
+
`tests/fixtures/tools/<tool_name>/<args_hash>.json`. Same
|
|
5
|
+
`recorded_at`-outside-the-hash pattern as v0.6's LLM fixtures.
|
|
6
|
+
|
|
7
|
+
RecordedToolProvider — wraps an inner invocation pipeline. On
|
|
8
|
+
`invoke(tool, args, ctx)`, computes the
|
|
9
|
+
args hash, reads the fixture, returns the
|
|
10
|
+
cached response. Missing fixtures raise
|
|
11
|
+
ToolError(reason="tool.fixture_missing").
|
|
12
|
+
|
|
13
|
+
RecordingToolProvider — wraps a real invoker. Calls the real
|
|
14
|
+
thing, persists the response as a
|
|
15
|
+
fixture, returns it. Use once under a
|
|
16
|
+
`@pytest.mark.records_tools` opt-in to
|
|
17
|
+
seed fixtures; commit; run thereafter
|
|
18
|
+
against `RecordedToolProvider`.
|
|
19
|
+
|
|
20
|
+
Fixture file shape:
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"tool": "web_fetch",
|
|
24
|
+
"args_hash": "<sha256_hex>",
|
|
25
|
+
"recorded_at": "2026-05-15T10:32:01Z",
|
|
26
|
+
"args": { ... only this contributes to the hash ... },
|
|
27
|
+
"output": { ... },
|
|
28
|
+
"error": null | { "reason": "tool.network_error", "message": "..." },
|
|
29
|
+
"latency_seconds": 0.8,
|
|
30
|
+
"cost_usd": "0.001"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
The "invoker" abstraction matters: tools register as Python
|
|
34
|
+
callables, but the runtime's tool-dispatch path can be wrapped by
|
|
35
|
+
either Recorded or Recording — the registered tool function is the
|
|
36
|
+
inner-most callable, and the Recording wrapper intercepts and
|
|
37
|
+
fingerprints. This is exactly the same pattern as
|
|
38
|
+
RecordingLLMProvider wrapping AnthropicProvider.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
from datetime import datetime, timezone
|
|
46
|
+
from decimal import Decimal
|
|
47
|
+
from typing import Any, Optional
|
|
48
|
+
|
|
49
|
+
from activegraph.tools.base import Tool
|
|
50
|
+
from activegraph.tools.cache import CachedToolResponse, hash_tool_call
|
|
51
|
+
from activegraph.tools.context import ToolContext
|
|
52
|
+
from activegraph.tools.errors import ToolError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now_iso() -> str:
|
|
56
|
+
return (
|
|
57
|
+
datetime.now(tz=timezone.utc)
|
|
58
|
+
.isoformat(timespec="seconds")
|
|
59
|
+
.replace("+00:00", "Z")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalize_args(tool: Tool, args: Any) -> Any:
|
|
64
|
+
"""If args is a dict and the tool has an input_schema, return the dict.
|
|
65
|
+
If args is a BaseModel instance, dump to dict via canonicalize_args.
|
|
66
|
+
"""
|
|
67
|
+
from activegraph.tools.cache import canonicalize_args
|
|
68
|
+
|
|
69
|
+
return canonicalize_args(args)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RecordedToolProvider:
|
|
73
|
+
"""Read-only invoker. Tests use this so they never call out."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, fixtures_dir: str) -> None:
|
|
76
|
+
self._dir = fixtures_dir
|
|
77
|
+
|
|
78
|
+
def invoke(
|
|
79
|
+
self,
|
|
80
|
+
tool: Tool,
|
|
81
|
+
args: Any,
|
|
82
|
+
ctx: ToolContext,
|
|
83
|
+
) -> CachedToolResponse:
|
|
84
|
+
args_hash = hash_tool_call(tool_name=tool.name, args=args)
|
|
85
|
+
path = os.path.join(self._dir, tool.name, f"{args_hash}.json")
|
|
86
|
+
if not os.path.exists(path):
|
|
87
|
+
raise ToolError(
|
|
88
|
+
"tool.fixture_missing",
|
|
89
|
+
f"no recorded fixture for tool={tool.name!r} "
|
|
90
|
+
f"args_hash={args_hash} in {self._dir}",
|
|
91
|
+
payload_extras={
|
|
92
|
+
"tool": tool.name,
|
|
93
|
+
"args_hash": args_hash,
|
|
94
|
+
"fixtures_dir": self._dir,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
with open(path, "r") as f:
|
|
98
|
+
data = json.load(f)
|
|
99
|
+
return CachedToolResponse(
|
|
100
|
+
output=data.get("output"),
|
|
101
|
+
error=data.get("error"),
|
|
102
|
+
latency_seconds=float(data.get("latency_seconds", 0.0) or 0.0),
|
|
103
|
+
cost_usd=_decimal(data.get("cost_usd", "0")),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RecordingToolProvider:
|
|
108
|
+
"""Wraps an inner invoker and persists each response as a fixture.
|
|
109
|
+
|
|
110
|
+
The inner invoker is normally the runtime's direct-call dispatcher
|
|
111
|
+
(i.e. it just runs `tool.fn(args, ctx)` with the right validation).
|
|
112
|
+
For seeding fixtures from a live tool body, use this:
|
|
113
|
+
|
|
114
|
+
invoker = RecordingToolProvider(
|
|
115
|
+
inner=DirectToolInvoker(), # runs tool.fn(args, ctx)
|
|
116
|
+
fixtures_dir="tests/fixtures/tools",
|
|
117
|
+
)
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, inner, fixtures_dir: str) -> None:
|
|
121
|
+
self._inner = inner
|
|
122
|
+
self._dir = fixtures_dir
|
|
123
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
124
|
+
|
|
125
|
+
def invoke(
|
|
126
|
+
self,
|
|
127
|
+
tool: Tool,
|
|
128
|
+
args: Any,
|
|
129
|
+
ctx: ToolContext,
|
|
130
|
+
) -> CachedToolResponse:
|
|
131
|
+
response = self._inner.invoke(tool, args, ctx)
|
|
132
|
+
args_hash = hash_tool_call(tool_name=tool.name, args=args)
|
|
133
|
+
fixture_dir = os.path.join(self._dir, tool.name)
|
|
134
|
+
os.makedirs(fixture_dir, exist_ok=True)
|
|
135
|
+
path = os.path.join(fixture_dir, f"{args_hash}.json")
|
|
136
|
+
fixture = {
|
|
137
|
+
"tool": tool.name,
|
|
138
|
+
"args_hash": args_hash,
|
|
139
|
+
"recorded_at": _now_iso(),
|
|
140
|
+
"args": _normalize_args(tool, args),
|
|
141
|
+
"output": response.output,
|
|
142
|
+
"error": response.error,
|
|
143
|
+
"latency_seconds": response.latency_seconds,
|
|
144
|
+
"cost_usd": str(response.cost_usd),
|
|
145
|
+
}
|
|
146
|
+
with open(path, "w") as f:
|
|
147
|
+
json.dump(fixture, f, indent=2, sort_keys=True)
|
|
148
|
+
return response
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class DirectToolInvoker:
|
|
152
|
+
"""The default invoker: just calls `tool.fn(args, ctx)` with timing
|
|
153
|
+
and exception trapping. The runtime uses this when no provider
|
|
154
|
+
wrapper is in play (i.e. production).
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def invoke(
|
|
158
|
+
self,
|
|
159
|
+
tool: Tool,
|
|
160
|
+
args: Any,
|
|
161
|
+
ctx: ToolContext,
|
|
162
|
+
) -> CachedToolResponse:
|
|
163
|
+
import time
|
|
164
|
+
|
|
165
|
+
# CONTRACT v0.7 #6: timeout and execution_error are mapped.
|
|
166
|
+
# Other failure modes (invalid_input/output) are checked by the
|
|
167
|
+
# runtime, NOT here — schema validation happens before/after.
|
|
168
|
+
t0 = time.monotonic()
|
|
169
|
+
try:
|
|
170
|
+
result = tool.fn(args, ctx)
|
|
171
|
+
except ToolError:
|
|
172
|
+
raise
|
|
173
|
+
except Exception as e:
|
|
174
|
+
raise ToolError(
|
|
175
|
+
"tool.execution_error",
|
|
176
|
+
f"{type(e).__name__}: {e}",
|
|
177
|
+
payload_extras={
|
|
178
|
+
"tool": tool.name,
|
|
179
|
+
"exception_type": type(e).__name__,
|
|
180
|
+
},
|
|
181
|
+
) from e
|
|
182
|
+
latency = time.monotonic() - t0
|
|
183
|
+
# Output can be a Pydantic instance or a dict. The runtime
|
|
184
|
+
# validates after; here we just store what the tool returned.
|
|
185
|
+
dump = getattr(result, "model_dump", None)
|
|
186
|
+
output: Any
|
|
187
|
+
if callable(dump):
|
|
188
|
+
try:
|
|
189
|
+
output = dump(mode="json")
|
|
190
|
+
except TypeError:
|
|
191
|
+
output = dump()
|
|
192
|
+
else:
|
|
193
|
+
output = result
|
|
194
|
+
return CachedToolResponse(
|
|
195
|
+
output=output,
|
|
196
|
+
error=None,
|
|
197
|
+
latency_seconds=latency,
|
|
198
|
+
cost_usd=tool.cost_per_call,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _decimal(v: Any) -> Decimal:
|
|
203
|
+
if isinstance(v, Decimal):
|
|
204
|
+
return v
|
|
205
|
+
return Decimal(str(v))
|