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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. 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))