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,230 @@
1
+ """Connection URL parsing for stores. CONTRACT v0.8 #2.
2
+
3
+ The framework addresses stores by URL everywhere (runtime, CLI, library).
4
+ URLs follow SQLAlchemy conventions:
5
+
6
+ - sqlite:///absolute/path/to/run.db (three slashes!)
7
+ - sqlite:///./relative/path.db
8
+ - postgres://user:pass@host:port/dbname
9
+ - postgresql://user:pass@host:port/dbname (same scheme)
10
+
11
+ A path with no scheme is rejected with a message pointing operators at
12
+ the right form. We do not silently guess. The operator who types
13
+ `activegraph inspect run.db` should see "use sqlite:///run.db", not a
14
+ confusing parse error from psycopg.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any, Optional
21
+ from urllib.parse import urlparse
22
+
23
+ from activegraph.errors import StorageError
24
+
25
+
26
+ SQLITE_SCHEMES = ("sqlite",)
27
+ POSTGRES_SCHEMES = ("postgres", "postgresql")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class StoreURL:
32
+ scheme: str
33
+ """Normalised: "sqlite" or "postgres"."""
34
+
35
+ raw: str
36
+ """The original URL as the user typed it."""
37
+
38
+ sqlite_path: Optional[str] = None
39
+ """Filesystem path; populated for SQLite URLs only."""
40
+
41
+
42
+ class InvalidStoreURL(StorageError, ValueError):
43
+ """Raised when a URL is missing a scheme, has an unsupported scheme,
44
+ or is otherwise malformed.
45
+
46
+ Multi-inherits :class:`ValueError` so user code that catches
47
+ ``ValueError`` around URL parsing keeps working. The message always
48
+ points the user at a concrete fix — bare paths get
49
+ ``sqlite:///<that path>``, unsupported schemes get the list of
50
+ supported ones.
51
+ """
52
+
53
+ _doc_slug = "invalid-store-url-error"
54
+
55
+
56
+ # Voice notes for the InvalidStoreURL leaves: every "Why:" frames the
57
+ # invariant as 'the framework refuses to guess what you meant.' The
58
+ # operator who types `activegraph inspect run.db` should not silently
59
+ # get treated as `sqlite:///run.db`; the same string could equally be
60
+ # a Postgres URL fragment or a typo, and guessing wrong corrupts the
61
+ # audit trail (or worse, opens the wrong store).
62
+ _WHY_NO_GUESS = (
63
+ "The framework addresses stores by URL everywhere — runtime, CLI, "
64
+ "library — so the same string can be passed around without ambiguity "
65
+ "about which driver opens it. A malformed URL is refused at parse "
66
+ "time rather than silently coerced to a default scheme; guessing "
67
+ "wrong would either corrupt the audit trail or open an unintended "
68
+ "store."
69
+ )
70
+
71
+
72
+ def _invalid_url(
73
+ summary: str, *, what_failed: str, how_to_fix: str, url: str | None = None
74
+ ) -> "InvalidStoreURL":
75
+ ctx: dict[str, Any] = {}
76
+ if url is not None:
77
+ ctx["url"] = url
78
+ return InvalidStoreURL(
79
+ summary,
80
+ what_failed=what_failed,
81
+ why=_WHY_NO_GUESS,
82
+ how_to_fix=how_to_fix,
83
+ context=ctx,
84
+ )
85
+
86
+
87
+ def parse_store_url(url: str) -> StoreURL:
88
+ """Parse a store URL, or raise InvalidStoreURL with a helpful message."""
89
+ if not url or not isinstance(url, str):
90
+ raise _invalid_url(
91
+ "store URL is empty",
92
+ what_failed="The store URL is empty or not a string.",
93
+ how_to_fix=(
94
+ "Provide a URL like:\n"
95
+ " sqlite:///path/to/run.db\n"
96
+ " postgres://host/dbname"
97
+ ),
98
+ url=url if isinstance(url, str) else None,
99
+ )
100
+ parsed = urlparse(url)
101
+ scheme = parsed.scheme.lower()
102
+ if not scheme:
103
+ # Bare path — the most common operator mistake.
104
+ raise _invalid_url(
105
+ f"store URL {url!r} has no scheme",
106
+ what_failed=(
107
+ f"The string {url!r} looks like a filesystem path with no "
108
+ f"URL scheme prefix."
109
+ ),
110
+ how_to_fix=(
111
+ f"If it's a SQLite file, use:\n"
112
+ f" sqlite:///{url}\n"
113
+ f"\n"
114
+ f"If it's a Postgres database, use:\n"
115
+ f" postgres://host/dbname"
116
+ ),
117
+ url=url,
118
+ )
119
+ if scheme in SQLITE_SCHEMES:
120
+ # SQLAlchemy convention (the de-facto standard the framework
121
+ # follows):
122
+ # sqlite:///path — relative path
123
+ # sqlite:////abs/path — absolute path (the leading / of
124
+ # the absolute path adds a 4th slash)
125
+ # urlparse splits these as path="/path" and path="//abs/path"
126
+ # respectively. Strip one leading slash to recover the actual
127
+ # filesystem path.
128
+ path = parsed.path
129
+ if parsed.netloc:
130
+ # "sqlite://host/path" — non-standard; treat host as part of
131
+ # the path component for forgiveness in tests.
132
+ path = f"//{parsed.netloc}{path}"
133
+ if not path:
134
+ raise _invalid_url(
135
+ f"sqlite URL {url!r} has no path",
136
+ what_failed=(
137
+ f"The URL {url!r} has the `sqlite` scheme but no filesystem "
138
+ f"path after it."
139
+ ),
140
+ how_to_fix=(
141
+ "Use one of:\n"
142
+ " sqlite:///relative/path/to/run.db\n"
143
+ " sqlite:////absolute/path/to/run.db\n"
144
+ "Note the slash counts — three for relative, four for absolute."
145
+ ),
146
+ url=url,
147
+ )
148
+ if path.startswith("/"):
149
+ path = path[1:]
150
+ if not path:
151
+ raise _invalid_url(
152
+ f"sqlite URL {url!r} has no path",
153
+ what_failed=(
154
+ f"The URL {url!r} has the `sqlite` scheme but the path "
155
+ f"resolved to empty."
156
+ ),
157
+ how_to_fix=(
158
+ "Use one of:\n"
159
+ " sqlite:///relative/path/to/run.db\n"
160
+ " sqlite:////absolute/path/to/run.db"
161
+ ),
162
+ url=url,
163
+ )
164
+ return StoreURL(scheme="sqlite", raw=url, sqlite_path=path)
165
+ if scheme in POSTGRES_SCHEMES:
166
+ if not (parsed.hostname or parsed.path.lstrip("/")):
167
+ raise _invalid_url(
168
+ f"postgres URL {url!r} has no host or database",
169
+ what_failed=(
170
+ f"The URL {url!r} has a `postgres` scheme but no host and "
171
+ f"no database name."
172
+ ),
173
+ how_to_fix=(
174
+ "Use:\n"
175
+ " postgres://host/dbname\n"
176
+ "or with credentials and port:\n"
177
+ " postgres://user:pass@host:port/dbname"
178
+ ),
179
+ url=url,
180
+ )
181
+ return StoreURL(scheme="postgres", raw=url)
182
+ raise _invalid_url(
183
+ f"unsupported store URL scheme {scheme!r} in {url!r}",
184
+ what_failed=(
185
+ f"The URL {url!r} has scheme {scheme!r}, which the framework "
186
+ f"does not recognize."
187
+ ),
188
+ how_to_fix=(
189
+ "Supported schemes are:\n"
190
+ " sqlite:///... local SQLite file\n"
191
+ " postgres://... PostgreSQL (also accepted: postgresql://)\n"
192
+ "\n"
193
+ "Other databases are not supported in v1.0; the EventStore protocol "
194
+ "in activegraph/store/base.py is the path for adding new backends."
195
+ ),
196
+ url=url,
197
+ )
198
+
199
+
200
+ def open_store(url: str, run_id: str) -> Any:
201
+ """Open a store for `run_id` at `url`. Returns an EventStore.
202
+
203
+ This is the single entry point the runtime and CLI use to open a
204
+ store from a URL. Drivers are imported lazily so the Postgres
205
+ dependency stays optional.
206
+ """
207
+ parsed = parse_store_url(url)
208
+ if parsed.scheme == "sqlite":
209
+ from activegraph.store.sqlite import SQLiteEventStore
210
+
211
+ assert parsed.sqlite_path is not None
212
+ return SQLiteEventStore(parsed.sqlite_path, run_id=run_id)
213
+ if parsed.scheme == "postgres":
214
+ from activegraph.store.postgres import PostgresEventStore
215
+
216
+ return PostgresEventStore(parsed.raw, run_id=run_id)
217
+ # Defensive: parse_store_url should have rejected this already.
218
+ raise _invalid_url(
219
+ f"unhandled scheme {parsed.scheme!r}",
220
+ what_failed=(
221
+ f"open_store reached its dispatcher with parsed scheme "
222
+ f"{parsed.scheme!r}, which parse_store_url should have refused."
223
+ ),
224
+ how_to_fix=(
225
+ "This is an internal inconsistency between parse_store_url "
226
+ "and open_store. File an issue with the URL that triggered "
227
+ "it at https://github.com/yoheinakajima/activegraph/issues."
228
+ ),
229
+ url=url,
230
+ )
@@ -0,0 +1,64 @@
1
+ """Tools subpackage. CONTRACT v0.7.
2
+
3
+ A tool is a primitive — not buried inside an LLM behavior. The
4
+ `@tool` decorator registers a callable with a name, schemas, cost,
5
+ and a determinism flag. The runtime invokes tools via the same
6
+ event-sourced pattern as LLM calls: every invocation is a
7
+ `tool.requested` / `tool.responded` event pair, and replay reads
8
+ those back instead of re-invoking (unless `replay_reinvoke_deterministic`
9
+ is set and the tool is marked deterministic).
10
+
11
+ Public surface:
12
+
13
+ Tool — registered tool metadata + body
14
+ ToolContext — passed to tool function bodies
15
+ ToolCache — content-keyed replay cache
16
+ RecordedToolProvider — fixture-backed tool invoker for tests
17
+ RecordingToolProvider — wraps another invoker, persists fixtures
18
+ ToolError — structured failure from a tool body
19
+ MissingToolError — raised at registration when an LLM
20
+ behavior references an unregistered tool
21
+ UnknownToolError — raised when the LLM requests a tool
22
+ the behavior didn't declare
23
+ tool — decorator
24
+ get_tool_registry — global tool registry inspector
25
+ clear_tool_registry — registry reset (test hygiene)
26
+ make_graph_query_tool — factory binding a graph_query tool to a Graph
27
+ web_fetch — reference tool: stdlib urllib-based URL fetcher
28
+ """
29
+
30
+ from activegraph.tools.base import Tool
31
+ from activegraph.tools.cache import ToolCache
32
+ from activegraph.tools.context import ToolContext
33
+ from activegraph.tools.decorators import (
34
+ clear_tool_registry,
35
+ get_tool_registry,
36
+ tool,
37
+ )
38
+ from activegraph.tools.errors import (
39
+ MissingToolError,
40
+ ToolError,
41
+ UnknownToolError,
42
+ )
43
+ from activegraph.tools.graph_query import make_graph_query_tool
44
+ from activegraph.tools.recorded import (
45
+ RecordedToolProvider,
46
+ RecordingToolProvider,
47
+ )
48
+ from activegraph.tools.web_fetch import web_fetch
49
+
50
+ __all__ = [
51
+ "MissingToolError",
52
+ "RecordedToolProvider",
53
+ "RecordingToolProvider",
54
+ "Tool",
55
+ "ToolCache",
56
+ "ToolContext",
57
+ "ToolError",
58
+ "UnknownToolError",
59
+ "clear_tool_registry",
60
+ "get_tool_registry",
61
+ "make_graph_query_tool",
62
+ "tool",
63
+ "web_fetch",
64
+ ]
@@ -0,0 +1,57 @@
1
+ """Tool dataclass. CONTRACT v0.7 #2 / #5.
2
+
3
+ A Tool is metadata + callable, mirror image of Behavior. The runtime
4
+ introspects metadata to dispatch invocations and budget enforcement;
5
+ the callable is the actual body. The tool function signature is
6
+ fixed:
7
+
8
+ def tool_fn(args: InputSchema, ctx: ToolContext) -> OutputSchema
9
+
10
+ `input_schema` and `output_schema` are Pydantic v2 BaseModel classes;
11
+ the runtime validates inputs before invocation and outputs after.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from decimal import Decimal
18
+ from typing import Any, Callable, Optional
19
+
20
+
21
+ @dataclass
22
+ class Tool:
23
+ name: str
24
+ fn: Callable[..., Any]
25
+ description: str = ""
26
+ input_schema: Optional[type] = None
27
+ output_schema: Optional[type] = None
28
+ cost_per_call: Decimal = field(default_factory=lambda: Decimal("0"))
29
+ timeout_seconds: float = 30.0
30
+ # CONTRACT v0.7 #7. False (the default) means "replay must serve
31
+ # this tool from the recorded fixture or fail loud"; True means
32
+ # "replay may re-invoke this tool". The runtime's replay-cache
33
+ # policy serves from cache by default for ALL tools, deterministic
34
+ # or not, because deterministic-tool correctness depends on the
35
+ # graph state at the moment of the call matching exactly — and the
36
+ # runtime cannot cheaply verify that. `replay_reinvoke_deterministic`
37
+ # on the Runtime is the opt-in that exercises re-invocation.
38
+ deterministic: bool = False
39
+
40
+ def to_definition(self) -> dict[str, Any]:
41
+ """Provider-facing tool definition.
42
+
43
+ Sent in the `tools=` parameter to `LLMProvider.complete()`.
44
+ Anthropic and OpenAI both accept a similar shape; the provider
45
+ translates if needed.
46
+ """
47
+ from activegraph.llm.prompt import schema_to_json
48
+
49
+ return {
50
+ "name": self.name,
51
+ "description": self.description,
52
+ "input_schema": (
53
+ schema_to_json(self.input_schema)
54
+ if self.input_schema is not None
55
+ else {"type": "object", "properties": {}}
56
+ ),
57
+ }
@@ -0,0 +1,157 @@
1
+ """Tool replay cache. CONTRACT v0.7 #3.
2
+
3
+ Mirror of `activegraph.llm.cache.LLMCache`. Keyed by
4
+ `sha256(canonical_json({tool_name, args_normalized}))`. Population
5
+ paths:
6
+
7
+ * `ToolCache.from_events(events)` — used at `Runtime.load(...,
8
+ replay_tool_cache=True)` and `runtime.fork(...,
9
+ replay_tool_cache=True)`. Walks the recorded log and harvests
10
+ every `tool.responded` whose preceding `tool.requested` carries
11
+ `args_hash`.
12
+
13
+ * `cache.record(args_hash, output, ...)` — same-run repeat-call
14
+ insurance, called inline from the runtime's tool invocation path.
15
+
16
+ CONTRACT v0.7 (tool-determinism decision): serve-from-cache is the
17
+ default on replay for ALL tools, deterministic or not. The
18
+ `replay_reinvoke_deterministic=True` Runtime flag is the opt-in that
19
+ lets deterministic tools actually re-invoke during replay. The
20
+ reasoning is documented in CONTRACT.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ from dataclasses import dataclass
28
+ from decimal import Decimal
29
+ from typing import Any, Iterable, Optional
30
+
31
+ from activegraph.core.event import Event
32
+
33
+
34
+ def canonicalize_args(args: Any) -> Any:
35
+ """Normalize tool args into a JSON-stable shape for hashing.
36
+
37
+ - Pydantic v2 BaseModel instance → `model.model_dump(mode="json")`
38
+ - dict / list / scalar → recursed, Decimals → canonical string
39
+ - sort_keys at JSON-dump time guarantees ordering stability.
40
+ """
41
+ dump = getattr(args, "model_dump", None)
42
+ if callable(dump):
43
+ try:
44
+ return dump(mode="json")
45
+ except TypeError:
46
+ return dump()
47
+ if isinstance(args, dict):
48
+ return {k: canonicalize_args(v) for k, v in args.items()}
49
+ if isinstance(args, list):
50
+ return [canonicalize_args(v) for v in args]
51
+ if isinstance(args, Decimal):
52
+ return str(args)
53
+ return args
54
+
55
+
56
+ def hash_tool_call(*, tool_name: str, args: Any) -> str:
57
+ payload = {"tool": tool_name, "args": canonicalize_args(args)}
58
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
59
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
60
+
61
+
62
+ @dataclass
63
+ class CachedToolResponse:
64
+ """A cached tool response. Mirrors `LLMResponse` in shape so the
65
+ replay path can hydrate without re-invoking the tool.
66
+ """
67
+
68
+ output: Any # JSON-serializable; runtime will re-validate via output_schema
69
+ error: Optional[dict[str, Any]] = None # None for success
70
+ latency_seconds: float = 0.0
71
+ cost_usd: Decimal = Decimal("0")
72
+ cache_hit: bool = False
73
+ requesting_event_id: Optional[str] = None
74
+
75
+
76
+ class ToolCache:
77
+ def __init__(self) -> None:
78
+ self._by_hash: dict[str, CachedToolResponse] = {}
79
+
80
+ # ---- read ----
81
+
82
+ def get(self, args_hash: str) -> Optional[CachedToolResponse]:
83
+ entry = self._by_hash.get(args_hash)
84
+ if entry is None:
85
+ return None
86
+ return CachedToolResponse(
87
+ output=entry.output,
88
+ error=dict(entry.error) if entry.error else None,
89
+ latency_seconds=entry.latency_seconds,
90
+ cost_usd=entry.cost_usd,
91
+ cache_hit=True,
92
+ requesting_event_id=entry.requesting_event_id,
93
+ )
94
+
95
+ def has(self, args_hash: str) -> bool:
96
+ return args_hash in self._by_hash
97
+
98
+ def __len__(self) -> int:
99
+ return len(self._by_hash)
100
+
101
+ # ---- write ----
102
+
103
+ def record(
104
+ self,
105
+ args_hash: str,
106
+ response: CachedToolResponse,
107
+ *,
108
+ requesting_event_id: Optional[str] = None,
109
+ ) -> None:
110
+ clean = CachedToolResponse(
111
+ output=response.output,
112
+ error=dict(response.error) if response.error else None,
113
+ latency_seconds=response.latency_seconds,
114
+ cost_usd=response.cost_usd,
115
+ cache_hit=False,
116
+ requesting_event_id=requesting_event_id or response.requesting_event_id,
117
+ )
118
+ self._by_hash[args_hash] = clean
119
+
120
+ # ---- bulk-load from a recorded event log ----
121
+
122
+ @classmethod
123
+ def from_events(cls, events: Iterable[Event]) -> "ToolCache":
124
+ cache = cls()
125
+ events_list = list(events)
126
+ by_id: dict[str, Event] = {e.id: e for e in events_list}
127
+ for e in events_list:
128
+ if e.type != "tool.responded":
129
+ continue
130
+ req_id = e.caused_by
131
+ if req_id is None:
132
+ continue
133
+ req = by_id.get(req_id)
134
+ if req is None or req.type != "tool.requested":
135
+ continue
136
+ args_hash = req.payload.get("args_hash")
137
+ if not args_hash:
138
+ continue
139
+ cache.record(
140
+ args_hash,
141
+ CachedToolResponse(
142
+ output=e.payload.get("output"),
143
+ error=e.payload.get("error"),
144
+ latency_seconds=float(
145
+ e.payload.get("latency_seconds", 0.0) or 0.0
146
+ ),
147
+ cost_usd=_decimal(e.payload.get("cost_usd", "0")),
148
+ ),
149
+ requesting_event_id=req_id,
150
+ )
151
+ return cache
152
+
153
+
154
+ def _decimal(v: Any) -> Decimal:
155
+ if isinstance(v, Decimal):
156
+ return v
157
+ return Decimal(str(v))
@@ -0,0 +1,48 @@
1
+ """ToolContext — what a tool function body sees. CONTRACT v0.7 #5.
2
+
3
+ A `ToolContext` is to a tool what `BehaviorGraph` is to a behavior:
4
+ the surface deliberately omits anything a tool shouldn't touch.
5
+
6
+ Provides:
7
+ - behavior_name: the @llm_behavior that triggered this tool call
8
+ - event_id: the triggering event id (NOT the tool.requested
9
+ event id — that one is on the wrapping turn loop)
10
+ - frame: the active Frame, if any
11
+ - idempotency_key: opaque pass-through for tools to forward to
12
+ external APIs that support idempotency tokens.
13
+ The runtime never uses this for dedupe — that's
14
+ the cache's job (CONTRACT v0.7 #5 / idempotency
15
+ decision).
16
+ - timeout_seconds: copied from the @tool decorator; tools may
17
+ enforce via signal/select; the runtime does NOT
18
+ forcibly preempt (no thread/signal magic).
19
+ - logger: a `logging.Logger` named "activegraph.tools.<tool>"
20
+
21
+ Does NOT provide a graph reference. Tools that need to read graph
22
+ state (e.g. `graph_query`) close over the Graph at registration time
23
+ via the `make_graph_query_tool(graph)` factory. The constraint is
24
+ intentional: tools that need graph state should be obvious (named,
25
+ constructed deliberately) rather than every tool having ambient
26
+ graph access.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from typing import TYPE_CHECKING, Optional
34
+
35
+ if TYPE_CHECKING:
36
+ from activegraph.frame import Frame
37
+
38
+
39
+ @dataclass
40
+ class ToolContext:
41
+ behavior_name: str
42
+ event_id: str
43
+ frame: Optional["Frame"]
44
+ idempotency_key: str
45
+ timeout_seconds: float
46
+ logger: logging.Logger = field(
47
+ default_factory=lambda: logging.getLogger("activegraph.tools")
48
+ )
@@ -0,0 +1,70 @@
1
+ """@tool decorator + global tool registry. CONTRACT v0.7 #2.
2
+
3
+ The registry mirrors the @behavior registry: decoration pushes into
4
+ a module-level list; tests call `clear_tool_registry()` for
5
+ isolation. Runtime construction snapshots the registry — late
6
+ registrations after `_ensure_registry()` are ignored.
7
+
8
+ `Runtime(graph, tools=[...])` is the explicit override that bypasses
9
+ the global registry, mirroring how `behaviors=[...]` works.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from decimal import Decimal
15
+ from typing import Any, Callable, Optional
16
+
17
+ from activegraph.tools.base import Tool
18
+
19
+
20
+ _TOOL_REGISTRY: list[Tool] = []
21
+
22
+
23
+ def clear_tool_registry() -> None:
24
+ _TOOL_REGISTRY.clear()
25
+
26
+
27
+ def get_tool_registry() -> list[Tool]:
28
+ return list(_TOOL_REGISTRY)
29
+
30
+
31
+ def tool(
32
+ *,
33
+ name: Optional[str] = None,
34
+ description: str = "",
35
+ input_schema: Optional[type] = None,
36
+ output_schema: Optional[type] = None,
37
+ cost_per_call: Any = Decimal("0"),
38
+ timeout_seconds: float = 30.0,
39
+ deterministic: bool = False,
40
+ ) -> Callable[[Callable], Tool]:
41
+ """Register a function as a Tool.
42
+
43
+ The decorated function's signature is
44
+ `(args: input_schema, ctx: ToolContext) -> output_schema`. The
45
+ runtime validates `args` against `input_schema` before invocation
46
+ and validates the return value against `output_schema` after.
47
+
48
+ Keyword-only on purpose — too many fields for safe positional
49
+ binding.
50
+ """
51
+
52
+ cost = cost_per_call if isinstance(cost_per_call, Decimal) else Decimal(
53
+ str(cost_per_call)
54
+ )
55
+
56
+ def wrap(fn: Callable) -> Tool:
57
+ t = Tool(
58
+ name=name or fn.__name__,
59
+ fn=fn,
60
+ description=description,
61
+ input_schema=input_schema,
62
+ output_schema=output_schema,
63
+ cost_per_call=cost,
64
+ timeout_seconds=float(timeout_seconds),
65
+ deterministic=bool(deterministic),
66
+ )
67
+ _TOOL_REGISTRY.append(t)
68
+ return t
69
+
70
+ return wrap