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,2441 @@
1
+ """The runtime loop. Single-threaded FIFO. CONTRACT #10.
2
+
3
+ Responsibilities (v0):
4
+ - Subscribe to graph events, enqueue them.
5
+ - Pop events, find matching behaviors, invoke each in registration order.
6
+ - Wrap behavior calls with behavior.started / behavior.completed
7
+ (or relation_behavior.started / behavior.completed for relation behaviors).
8
+ - Catch any behavior exception, emit behavior.failed (CONTRACT #13).
9
+ - Stop on idle or budget exhaustion.
10
+
11
+ Added in v0.5:
12
+ - `persist_to=PATH` (sugar) or `store=...` attaches a durable EventStore.
13
+ - `Runtime.load(path, run_id=None)` rebuilds from an event log (CONTRACT v0.5 #5).
14
+ - `runtime.save_state(path=None)` flushes / late-binds persistence.
15
+ - `runtime.fork(at_event, label=None)` branches a run (#9).
16
+ - `runtime.diff(other)` returns a structural `Diff` (#10).
17
+ - `replay_strict=True` re-runs behaviors and verifies the output matches
18
+ the recorded log; on the first divergence, raises ReplayDivergenceError
19
+ (#7).
20
+
21
+ Added in v0.6:
22
+ - `llm_provider=` plugs an LLMProvider (Anthropic, recorded, scripted).
23
+ - `replay_llm_cache=True` pre-populates a content-keyed cache from
24
+ recorded `llm.responded` events so re-runs (forks, strict-replay)
25
+ do not call the API.
26
+ - The `_invoke_llm` path is the runtime-owned LLM lifecycle:
27
+ assemble prompt → cache lookup → optional cost pre-check →
28
+ emit llm.requested → call provider (or use cached) → emit
29
+ llm.responded → parse output → invoke handler. Failures flow as
30
+ `behavior.failed` with a `reason` from CONTRACT v0.6 #11.
31
+ - `replay_strict=True` + recorded llm.responded whose prompt hash
32
+ does not match the live re-assembly → ReplayDivergenceError pinned
33
+ to the offending llm.requested event id (decision-2 adjustment).
34
+
35
+ Added in v0.7:
36
+ - `tools=[t1, t2, ...]` plugs a list of `Tool` objects; absent tools
37
+ fall back to the global `@tool` registry.
38
+ - The `_invoke_llm` path becomes a turn loop: provider's response can
39
+ include `tool_calls`, in which case the runtime invokes the tool,
40
+ echoes the result back into messages, and re-calls the provider.
41
+ `max_tool_turns` caps the loop. Per-turn LLM and tool events are
42
+ emitted; replay reads them back in order.
43
+ - `replay_tool_cache=True` (parallel to `replay_llm_cache=True`)
44
+ pre-populates a content-keyed cache from `tool.responded` events.
45
+ By default ALL tools (deterministic or not) serve from cache on
46
+ replay; `replay_reinvoke_deterministic=True` opts in to actually
47
+ re-invoking deterministic tools (CONTRACT v0.7 tool-determinism).
48
+ - `pattern=` on a behavior compiles a Cypher subset matcher; the
49
+ registry's `match()` includes the bindings. Behaviors fire once
50
+ per event when both `on=` and `pattern=` agree (CONTRACT v0.7 #11).
51
+ - `activate_after=N` schedules a behavior to fire N events later,
52
+ with the `where=` re-checked at fire time (CONTRACT v0.7 #13).
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import random as _random
59
+ import time as _time
60
+ import traceback
61
+
62
+
63
+ def _monotonic() -> float:
64
+ return _time.monotonic()
65
+ from dataclasses import dataclass, field
66
+ from datetime import datetime, timezone
67
+ from decimal import Decimal
68
+ from typing import Any, Callable, Iterable, Optional, Union
69
+
70
+ from activegraph.behaviors.base import Behavior, LLMBehavior, RelationBehavior
71
+ from activegraph.behaviors.decorators import get_registry
72
+ from activegraph.core.event import Event
73
+ from activegraph.core.graph import Graph, evaluate_where as _evaluate_where
74
+ from activegraph.core.ids import IDGen
75
+ from activegraph.core.view import View
76
+ from activegraph.frame import Frame
77
+ from activegraph.llm.cache import LLMCache
78
+ from activegraph.llm.errors import LLMBehaviorError, MissingProviderError
79
+ from activegraph.llm.provider import LLMProvider
80
+ from activegraph.llm.types import LLMMessage, ToolCall
81
+ from activegraph.policy import Policy
82
+ from activegraph.runtime.behavior_graph import BehaviorGraph
83
+ from activegraph.runtime.budget import Budget
84
+ from activegraph.runtime.diff import Diff, compute_diff
85
+ from activegraph.runtime.errors import ReplayDivergenceError
86
+ from activegraph.runtime.queue import EventQueue
87
+ from activegraph.runtime.registry import Registry
88
+ from activegraph.runtime.scheduler import DelayedQueue, ScheduledEntry
89
+ from activegraph.runtime.view_builder import build_view
90
+ from activegraph.tools.base import Tool
91
+ from activegraph.tools.cache import (
92
+ CachedToolResponse,
93
+ canonicalize_args,
94
+ hash_tool_call,
95
+ )
96
+ from activegraph.tools.context import ToolContext
97
+ from activegraph.tools.decorators import get_tool_registry
98
+ from activegraph.tools.errors import (
99
+ MissingToolError,
100
+ ToolError,
101
+ UnknownToolError,
102
+ )
103
+ from activegraph.tools.recorded import DirectToolInvoker
104
+
105
+ from activegraph.observability.logging import get_logger, runtime_log_extra
106
+ from activegraph.observability.metrics import Metrics, NoOpMetrics
107
+ from activegraph.observability.status import (
108
+ BehaviorInfo,
109
+ BudgetSnapshot,
110
+ EventSummary,
111
+ FrameSnapshot,
112
+ RuntimeStatus,
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class Context:
118
+ view: View
119
+ frame: Optional[Frame]
120
+ policy: Optional[Policy]
121
+ random: _random.Random
122
+ clock: Any # Clock-like
123
+ llm_provider: Optional[LLMProvider] = None
124
+ # v0.7: pattern bindings for the current invocation. Empty list for
125
+ # behaviors that don't declare a pattern. The handler is fired
126
+ # ONCE per event regardless of how many bindings the pattern
127
+ # produced — iterating `ctx.matches` is the developer's job
128
+ # (CONTRACT v0.7 #12).
129
+ matches: list = field(default_factory=list)
130
+ # v0.9: pack-aware context. Set by the runtime when invoking a
131
+ # pack-owned behavior.
132
+ # `settings` — the executing behavior's pack's settings instance,
133
+ # or None if the behavior is not pack-owned.
134
+ # `_runtime` — backref so ctx.pack_settings(...) and
135
+ # ctx.propose_object(...) can reach the runtime.
136
+ settings: Any = None
137
+ _runtime: Any = None
138
+
139
+ def pack_settings(self, pack_name: str) -> Any:
140
+ """Look up settings for any loaded pack by name. Returns the
141
+ Pydantic settings instance, or None if the pack isn't loaded.
142
+ CONTRACT v0.9 #7 (Form 3 / cross-pack lookup).
143
+ """
144
+ if self._runtime is None or self._runtime._pack_state is None:
145
+ return None
146
+ return self._runtime._pack_state.pack_settings.get(pack_name)
147
+
148
+ def propose_object(
149
+ self,
150
+ object_type: str,
151
+ data: dict,
152
+ *,
153
+ reason: str = "",
154
+ ) -> str:
155
+ """Defer creation of an object behind a policy approval.
156
+
157
+ Returns the proposal id. The object materializes when
158
+ `runtime.approve(id)` is called. Intended for use by behaviors
159
+ whose pack policy gates `object_type` writes.
160
+
161
+ Convenience: behaviors can just call `graph.add_object` if their
162
+ pack settings say auto-approval is on; this helper is the
163
+ explicit path when gating is enabled.
164
+ """
165
+ if self._runtime is None:
166
+ from activegraph.runtime.exec_errors import RuntimeContextRequiredError
167
+ raise RuntimeContextRequiredError(method="ctx.propose_object")
168
+ return self._runtime._add_pending_approval(
169
+ object_type=object_type, data=data, reason=reason
170
+ )
171
+
172
+
173
+ class Runtime:
174
+ def __init__(
175
+ self,
176
+ graph: Graph,
177
+ behaviors: Optional[Iterable[Union[Behavior, RelationBehavior]]] = None,
178
+ frame: Optional[Frame] = None,
179
+ policy: Optional[Policy] = None,
180
+ budget: Optional[dict[str, Any]] = None,
181
+ seed: int = 0,
182
+ *,
183
+ persist_to: Optional[str] = None,
184
+ store: Optional[Any] = None,
185
+ replay_strict: bool = False,
186
+ llm_provider: Optional[LLMProvider] = None,
187
+ replay_llm_cache: bool = False,
188
+ llm_cache: Optional[LLMCache] = None,
189
+ # v0.7 additions
190
+ tools: Optional[Iterable[Tool]] = None,
191
+ replay_tool_cache: bool = False,
192
+ tool_cache: Any = None, # ToolCache; Any to avoid import cycle
193
+ replay_reinvoke_deterministic: bool = False,
194
+ tool_invoker: Any = None, # defaults to DirectToolInvoker()
195
+ # v0.8: observability
196
+ metrics: Optional[Metrics] = None,
197
+ ) -> None:
198
+ self.graph = graph
199
+ self.frame = frame
200
+ self.policy = policy
201
+ self.budget = Budget(budget or {})
202
+ self._random = _random.Random(seed)
203
+ self.replay_strict = replay_strict
204
+ # CONTRACT v0.6 #3: provider is set once at construction.
205
+ self.llm_provider: Optional[LLMProvider] = llm_provider
206
+ self.replay_llm_cache: bool = replay_llm_cache
207
+ # Cache is content-keyed by prompt hash. May be pre-populated
208
+ # (load/fork with replay_llm_cache=True) or lazily filled.
209
+ self._llm_cache: Optional[LLMCache] = llm_cache
210
+ # During _verify_replay we install the sequence of expected
211
+ # prompt hashes (from recorded llm.requested events). A live
212
+ # re-assembled prompt whose hash doesn't match the next
213
+ # expected one is a divergence — pinned to the new
214
+ # llm.requested event id (decision-2 adjustment).
215
+ self._strict_expected_hashes: Optional[list[str]] = None
216
+
217
+ # v0.7: tool plumbing
218
+ self._explicit_tools = list(tools) if tools is not None else None
219
+ self.tool_registry: dict[str, Tool] = {}
220
+ self.replay_tool_cache: bool = replay_tool_cache
221
+ from activegraph.tools.cache import ToolCache as _ToolCache
222
+ self._tool_cache = tool_cache if tool_cache is not None else _ToolCache()
223
+ self.replay_reinvoke_deterministic: bool = replay_reinvoke_deterministic
224
+ self._tool_invoker = tool_invoker if tool_invoker is not None else DirectToolInvoker()
225
+
226
+ # If behaviors are passed explicitly, snapshot now. Otherwise, defer
227
+ # to the global registry — the user may decorate behaviors after
228
+ # constructing the Runtime (the README quickstart does exactly this).
229
+ self._explicit_behaviors = (
230
+ list(behaviors) if behaviors is not None else None
231
+ )
232
+ self.registry: Optional[Registry] = None
233
+
234
+ # frame id (cheap auto-allocate so provenance always has one)
235
+ if self.frame is not None and self.frame.id is None:
236
+ self.frame.id = graph.ids.frame()
237
+
238
+ # v0.8: observability — metrics defaults to NoOp so the runtime
239
+ # is fully functional without any metrics backend configured.
240
+ self.metrics: Metrics = metrics if metrics is not None else NoOpMetrics()
241
+ self._log = get_logger("activegraph.runtime")
242
+
243
+ self._queue = EventQueue()
244
+ # v0.7: delayed queue for activate_after scheduling.
245
+ self._delayed = DelayedQueue()
246
+ # Event tick counter: increments for every non-lifecycle event
247
+ # processed. This is the time axis for activate_after.
248
+ self._tick: int = 0
249
+ # Stash for tool result message between _invoke_tool and the
250
+ # turn-loop caller. Always cleared after consumption.
251
+ self._last_tool_result_message: Optional[LLMMessage] = None
252
+ self._inside_dispatch = False
253
+ graph.add_listener(self._on_event)
254
+ self._idle_emitted = False
255
+
256
+ # ---- v0.5: persistence wiring ----
257
+ if persist_to is not None and store is not None:
258
+ from activegraph.runtime.config_errors import InvalidRuntimeConfiguration
259
+ raise InvalidRuntimeConfiguration(
260
+ "Runtime(...) was passed both `persist_to=` and `store=`",
261
+ what_failed=(
262
+ "Runtime construction received both a `persist_to=` path "
263
+ "and an explicit `store=` instance. The two kwargs are "
264
+ "alternative ways to attach storage — only one can be "
265
+ "used per Runtime."
266
+ ),
267
+ why=(
268
+ "`persist_to=` is shorthand for 'open a SQLite store at "
269
+ "this path and attach it.' `store=` is the explicit form "
270
+ "for any EventStore implementation. If both were "
271
+ "accepted, the runtime would have to pick one or merge "
272
+ "them, and silent precedence rules would surface as bugs "
273
+ "the first time an operator switched stores."
274
+ ),
275
+ how_to_fix=(
276
+ "Pass exactly one:\n"
277
+ " Runtime(graph, persist_to='/path/to/run.db')\n"
278
+ "or:\n"
279
+ " Runtime(graph, store=SQLiteEventStore('/path/to/run.db'))\n"
280
+ "\n"
281
+ "The two forms produce equivalent runtimes for SQLite. "
282
+ "Use `store=` when you need a non-SQLite backend or want "
283
+ "to share an open store across runtimes."
284
+ ),
285
+ )
286
+ if persist_to is not None:
287
+ store = _open_sqlite_store(persist_to, graph.run_id)
288
+ store.upsert_run(
289
+ created_at=_now_iso(),
290
+ frame_id=self.frame.id if self.frame else None,
291
+ )
292
+ if store is not None:
293
+ graph.attach_store(store)
294
+
295
+ # ---- v0.9: pack state (lazy) ----
296
+ # Holds the per-runtime pack bookkeeping populated by
297
+ # `load_pack`. Initialized lazily on first access via the
298
+ # loader's `_ensure_pack_state`. The `_pack_behaviors` and
299
+ # `_pack_tools` lists are merged into the registry inside
300
+ # `_ensure_registry` (which rebuilds `tool_registry` from
301
+ # scratch each call).
302
+ self._pack_state = None # type: ignore[assignment]
303
+ self._pack_behaviors: list = []
304
+ self._pack_tools: list = []
305
+
306
+ # ---------- public surface ----------
307
+
308
+ @property
309
+ def run_id(self) -> str:
310
+ return self.graph.run_id
311
+
312
+ # ---------- listener ----------
313
+
314
+ def _on_event(self, event: Event) -> None:
315
+ # v0.8: count every emitted event, lifecycle or not. The metric
316
+ # tag is the event type. The graph's listener fires for every
317
+ # event passing through emit(), including lifecycle events that
318
+ # we suppress from re-matching below.
319
+ self.metrics.counter(
320
+ "activegraph_events_emitted_total",
321
+ {"event_type": event.type},
322
+ )
323
+ # Don't enqueue our own lifecycle events for re-matching.
324
+ # v0.7 adds `llm.*`, `tool.*`, `pattern.*`, `behavior.scheduled`
325
+ # to the suppression list — they're internal to the runtime's
326
+ # bookkeeping. (User behaviors that want to audit LLM/tool
327
+ # activity can still subscribe via the registry's lookup.)
328
+ if (
329
+ event.type.startswith("behavior.")
330
+ or event.type.startswith("relation_behavior.")
331
+ or event.type.startswith("runtime.")
332
+ or event.type.startswith("llm.")
333
+ or event.type.startswith("tool.")
334
+ or event.type.startswith("pattern.")
335
+ # v0.9: approval bookkeeping is internal; CONTRACT v0.9 #13
336
+ # deliberately keeps `pack.loaded` queue-visible so pack-aware
337
+ # behaviors can subscribe, but `approval.*` is suppressed.
338
+ or event.type.startswith("approval.")
339
+ ):
340
+ return
341
+ self._queue.push(event)
342
+ self.metrics.gauge(
343
+ "activegraph_queue_depth", {}, float(len(self._queue))
344
+ )
345
+ # New activity → we're not idle anymore.
346
+ self._idle_emitted = False
347
+ # INFO: one log line per enqueued event. High-volume; operators
348
+ # filter at WARNING in production dashboards.
349
+ self._log.info(
350
+ "event emitted",
351
+ extra=runtime_log_extra(
352
+ run_id=self.graph.run_id,
353
+ event_id=event.id,
354
+ ),
355
+ )
356
+
357
+ # ---------- public entry points ----------
358
+
359
+ def _ensure_registry(self) -> None:
360
+ source = (
361
+ self._explicit_behaviors
362
+ if self._explicit_behaviors is not None
363
+ else get_registry()
364
+ )
365
+ # v0.9: pack-owned behaviors live in `_pack_behaviors` (filled
366
+ # by `load_pack`) and are merged on top of the global / explicit
367
+ # source. Pack behaviors carry canonical (namespace-prefixed)
368
+ # names; they never collide with non-pack behaviors because the
369
+ # `pack.` prefix is reserved for packs.
370
+ if self._pack_behaviors:
371
+ source = list(source) + list(self._pack_behaviors)
372
+ # CONTRACT v0.6 #21: LLM behaviors fail loud at registration if
373
+ # there is no provider. We do not silently fall back to a mock —
374
+ # a missing provider is almost always a real misconfiguration.
375
+ if self.llm_provider is None:
376
+ for b in source:
377
+ if isinstance(b, LLMBehavior):
378
+ raise MissingProviderError(behavior_name=b.name)
379
+ self.registry = Registry(source)
380
+
381
+ # v0.7: assemble the tool registry. Explicit tools= override the
382
+ # global @tool registry, mirroring how behaviors= works.
383
+ if self._explicit_tools is not None:
384
+ tools_source = list(self._explicit_tools)
385
+ else:
386
+ tools_source = list(get_tool_registry())
387
+ # v0.9: pack-owned tools merge in here (filled by `load_pack`).
388
+ # These carry canonical (namespace-prefixed) names and may also
389
+ # be registered under their short name if `export_globally=True`.
390
+ if self._pack_tools:
391
+ tools_source = tools_source + list(self._pack_tools)
392
+ # LLM behaviors may also bring their own tools via tools=[...] on
393
+ # the decorator; pull those in too so the name lookup is unified.
394
+ for b in source:
395
+ if isinstance(b, LLMBehavior):
396
+ for t in b.tools:
397
+ if isinstance(t, Tool) and t not in tools_source:
398
+ tools_source = list(tools_source) + [t]
399
+ # CONTRACT v0.7 #2: each LLM behavior with tools= must reference
400
+ # registered tools by Tool object or by name string. Names are
401
+ # resolved against the merged registry. Missing → MissingToolError.
402
+ self.tool_registry = {}
403
+ for t in tools_source:
404
+ if not isinstance(t, Tool):
405
+ from activegraph.runtime.registration_errors import (
406
+ InvalidToolRegistration,
407
+ )
408
+ raise InvalidToolRegistration(t)
409
+ self.tool_registry[t.name] = t
410
+ # v0.9: globally-exported pack tools are also registered
411
+ # under their short name.
412
+ if getattr(t, "_export_globally", False):
413
+ short = getattr(t, "_short_name", None) or t.name.split(".", 1)[-1]
414
+ if short != t.name:
415
+ self.tool_registry[short] = t
416
+ for b in source:
417
+ if not isinstance(b, LLMBehavior):
418
+ continue
419
+ for t in b.tools:
420
+ name = t.name if isinstance(t, Tool) else str(t)
421
+ if name not in self.tool_registry:
422
+ raise MissingToolError(
423
+ name,
424
+ behavior_name=b.name,
425
+ registered=tuple(self.tool_registry.keys()),
426
+ )
427
+
428
+ def run_goal(self, goal: str, *, actor: str = "user") -> None:
429
+ self._ensure_registry()
430
+ # Stamp the run row's goal (best-effort; only meaningful with a store).
431
+ if self.graph.store is not None and hasattr(self.graph.store, "upsert_run"):
432
+ self.graph.store.upsert_run(
433
+ created_at=_now_iso(),
434
+ goal=goal,
435
+ frame_id=self.frame.id if self.frame else None,
436
+ )
437
+ ev = Event(
438
+ id=self.graph.ids.event(),
439
+ type="goal.created",
440
+ payload={"goal": goal},
441
+ actor=actor,
442
+ frame_id=self.frame.id if self.frame else None,
443
+ caused_by=None,
444
+ timestamp=self.graph.clock.now(),
445
+ )
446
+ self.budget.start()
447
+ self.graph.emit(ev)
448
+ self.run_until_idle()
449
+
450
+ def run_until_idle(self) -> None:
451
+ self._ensure_registry()
452
+ if self.budget._start is None:
453
+ self.budget.start()
454
+ self._loop(stop=lambda: False)
455
+ self._emit_idle_or_exhausted()
456
+
457
+ def run_until(self, predicate: Callable[[Graph], bool]) -> None:
458
+ self._ensure_registry()
459
+ if self.budget._start is None:
460
+ self.budget.start()
461
+ self._loop(stop=lambda: predicate(self.graph))
462
+ self._emit_idle_or_exhausted()
463
+
464
+ # ---------- loop ----------
465
+
466
+ def _loop(self, *, stop: Callable[[], bool]) -> None:
467
+ while (self._queue or self._delayed) and self.budget.remaining():
468
+ if stop():
469
+ return
470
+ # Always drain main queue first. Delayed entries are checked
471
+ # after each event tick so they fire at the right moment.
472
+ if self._queue:
473
+ event = self._queue.pop()
474
+ assert event is not None
475
+ self.budget.consume("max_events")
476
+ self._tick += 1
477
+ matches = self.registry.match(event, self.graph)
478
+ for b, rels, p_matches in matches:
479
+ if not self.budget.remaining():
480
+ break
481
+ # v0.7: activate_after defers invocation. Schedule and
482
+ # emit `behavior.scheduled` instead of invoking now.
483
+ if b.activate_after is not None:
484
+ self._schedule(b, event, p_matches)
485
+ continue
486
+ if p_matches and (b.on and b.pattern):
487
+ self._emit_pattern_matched(b, event, p_matches)
488
+ elif p_matches and not b.on:
489
+ # Pattern-only behavior: still emit a marker so
490
+ # the trace shows what fired.
491
+ self._emit_pattern_matched(b, event, p_matches)
492
+ if isinstance(b, RelationBehavior):
493
+ for r in rels:
494
+ if not self.budget.remaining():
495
+ break
496
+ self._invoke_relation(b, r, event, p_matches)
497
+ elif isinstance(b, LLMBehavior):
498
+ self._invoke_llm(b, event, p_matches)
499
+ else:
500
+ self._invoke(b, event, p_matches)
501
+ # Drain due delayed entries (after every tick).
502
+ self._fire_due_delayed()
503
+
504
+ # ---- v0.7: delayed-queue scheduling (activate_after) -----------------
505
+
506
+ def _schedule(
507
+ self,
508
+ behavior,
509
+ event: Event,
510
+ pattern_matches,
511
+ ) -> None:
512
+ """Emit behavior.scheduled and push onto the delayed queue."""
513
+ sched_evt = self._emit_lifecycle(
514
+ "behavior.scheduled",
515
+ {
516
+ "behavior": behavior.name,
517
+ "event_id": event.id,
518
+ "activate_after": behavior.activate_after,
519
+ "fire_at_tick": self._tick + behavior.activate_after,
520
+ "current_tick": self._tick,
521
+ },
522
+ )
523
+ self._delayed.push(
524
+ ScheduledEntry(
525
+ behavior_name=behavior.name,
526
+ behavior_index=self.registry.index_of(behavior),
527
+ triggering_event_id=event.id,
528
+ fire_at_event_count=self._tick + behavior.activate_after,
529
+ where_recheck_path=None,
530
+ scheduled_event_id=sched_evt.id,
531
+ )
532
+ )
533
+
534
+ def _fire_due_delayed(self) -> None:
535
+ due = self._delayed.pop_due(self._tick)
536
+ for entry in due:
537
+ if not self.budget.remaining():
538
+ # Re-push and exit — budget exhausted before all due
539
+ # entries fired; preserved for next run_until_idle.
540
+ self._delayed.push(entry)
541
+ break
542
+ behavior = self.registry.all()[entry.behavior_index]
543
+ # Re-fetch the triggering event so the handler still sees it.
544
+ ev = self._find_event(entry.triggering_event_id)
545
+ if ev is None:
546
+ continue
547
+ # Re-check where= against the LATEST graph state.
548
+ if behavior.where and not _evaluate_where(behavior.where, ev.payload):
549
+ # Silently skip per CONTRACT v0.7 #13. The trace already
550
+ # has the behavior.scheduled event; absence of a
551
+ # behavior.started is sufficient evidence the where
552
+ # didn't hold.
553
+ continue
554
+ # Re-check pattern as well so a stale pattern hit doesn't
555
+ # fire after the graph has moved on. We pass empty matches
556
+ # if there's no pattern.
557
+ p_matches: list = []
558
+ if behavior.pattern_matcher is not None:
559
+ p_matches = behavior.pattern_matcher.matches(ev, self.graph)
560
+ if not p_matches:
561
+ continue
562
+ # Dispatch as normal (without re-scheduling — we are AT the
563
+ # fire moment).
564
+ if isinstance(behavior, RelationBehavior):
565
+ # For relation behaviors we'd need to refetch relations.
566
+ # Defer this rare combination to a future enhancement.
567
+ continue
568
+ if isinstance(behavior, LLMBehavior):
569
+ self._invoke_llm(behavior, ev, p_matches)
570
+ else:
571
+ self._invoke(behavior, ev, p_matches)
572
+
573
+ def _find_event(self, event_id: str) -> Optional[Event]:
574
+ for e in self.graph.events:
575
+ if e.id == event_id:
576
+ return e
577
+ return None
578
+
579
+ def _emit_pattern_matched(self, behavior, event: Event, p_matches) -> None:
580
+ """Emit a pattern.matched marker so the trace shows the bindings."""
581
+ self._emit_lifecycle(
582
+ "pattern.matched",
583
+ {
584
+ "behavior": behavior.name,
585
+ "event_id": event.id,
586
+ "matches_count": len(p_matches),
587
+ "pattern": behavior.pattern,
588
+ },
589
+ )
590
+
591
+ # ---------- invocation ----------
592
+
593
+ def _invoke(self, b: Behavior, event: Event, matches: Optional[list] = None) -> None:
594
+ self.budget.consume("max_behavior_calls")
595
+ bgraph = BehaviorGraph(
596
+ self.graph,
597
+ actor=b.name,
598
+ caused_by=event.id,
599
+ frame_id=self.frame.id if self.frame else None,
600
+ )
601
+ view = build_view(b, event, self.graph)
602
+ ctx = Context(
603
+ view=view,
604
+ frame=self.frame,
605
+ policy=self.policy,
606
+ random=self._random,
607
+ clock=self.graph.clock,
608
+ matches=list(matches or []),
609
+ settings=self._pack_settings_for_behavior(b),
610
+ _runtime=self,
611
+ )
612
+
613
+ # v0.8: count and time the handler call. Only function behaviors
614
+ # are instrumented here; LLM and relation behaviors have their
615
+ # own invocation paths and their own metrics hooks.
616
+ self.metrics.counter(
617
+ "activegraph_behaviors_invoked_total", {"behavior": b.name}
618
+ )
619
+
620
+ self._emit_lifecycle(
621
+ "behavior.started",
622
+ {
623
+ "behavior": b.name,
624
+ "event_id": event.id,
625
+ "triggering_event_type": event.type,
626
+ "triggering_object_id": _maybe_object_id(event),
627
+ },
628
+ )
629
+ _t0 = _monotonic()
630
+ try:
631
+ b.run(event, bgraph, ctx)
632
+ except Exception as e:
633
+ self.metrics.histogram(
634
+ "activegraph_behaviors_duration_seconds",
635
+ {"behavior": b.name},
636
+ _monotonic() - _t0,
637
+ )
638
+ self.metrics.counter(
639
+ "activegraph_behaviors_failed_total",
640
+ {"behavior": b.name, "reason": f"exception.{type(e).__name__}"},
641
+ )
642
+ self._log.error(
643
+ "behavior failed",
644
+ extra=runtime_log_extra(
645
+ run_id=self.graph.run_id,
646
+ event_id=event.id,
647
+ behavior=b.name,
648
+ reason=f"exception.{type(e).__name__}",
649
+ error_type=type(e).__name__,
650
+ error_message=str(e),
651
+ ),
652
+ )
653
+ self._emit_lifecycle(
654
+ "behavior.failed",
655
+ {
656
+ "behavior": b.name,
657
+ "event_id": event.id,
658
+ "exception_type": type(e).__name__,
659
+ "message": str(e),
660
+ "traceback": traceback.format_exc(),
661
+ },
662
+ )
663
+ return
664
+
665
+ self.metrics.histogram(
666
+ "activegraph_behaviors_duration_seconds",
667
+ {"behavior": b.name},
668
+ _monotonic() - _t0,
669
+ )
670
+ self._emit_lifecycle(
671
+ "behavior.completed",
672
+ {
673
+ "behavior": b.name,
674
+ "event_id": event.id,
675
+ "objects_created": bgraph.counters.objects_created,
676
+ "relations_created": bgraph.counters.relations_created,
677
+ "patches_applied": bgraph.counters.patches_applied,
678
+ "patches_proposed": bgraph.counters.patches_proposed,
679
+ "events_emitted": bgraph.counters.events_emitted,
680
+ },
681
+ )
682
+
683
+ def _invoke_llm(
684
+ self,
685
+ b: LLMBehavior,
686
+ event: Event,
687
+ matches: Optional[list] = None,
688
+ ) -> None:
689
+ """LLM behavior lifecycle, v0.7 — turn loop over tool calls.
690
+
691
+ Order:
692
+ behavior.started
693
+ (loop, up to max_tool_turns):
694
+ assemble prompt (with tools= if b.tools)
695
+ cache lookup (LLM cache)
696
+ optional cost gate (if max_cost_usd)
697
+ emit llm.requested
698
+ provider.complete() | cached
699
+ emit llm.responded
700
+ if response.tool_calls: dispatch each tool, append result
701
+ to messages, loop again
702
+ else: break with response.parsed
703
+ handler(event, bgraph, ctx, parsed)
704
+ behavior.completed
705
+ """
706
+
707
+ self.budget.consume("max_behavior_calls")
708
+ self.budget.consume("max_llm_calls")
709
+
710
+ view = build_view(b, event, self.graph)
711
+ bgraph = BehaviorGraph(
712
+ self.graph,
713
+ actor=b.name,
714
+ caused_by=event.id,
715
+ frame_id=self.frame.id if self.frame else None,
716
+ )
717
+ ctx = Context(
718
+ view=view,
719
+ frame=self.frame,
720
+ policy=self.policy,
721
+ random=self._random,
722
+ clock=self.graph.clock,
723
+ llm_provider=self.llm_provider,
724
+ matches=list(matches or []),
725
+ settings=self._pack_settings_for_behavior(b),
726
+ _runtime=self,
727
+ )
728
+
729
+ self._emit_lifecycle(
730
+ "behavior.started",
731
+ {
732
+ "behavior": b.name,
733
+ "event_id": event.id,
734
+ "triggering_event_type": event.type,
735
+ "triggering_object_id": _maybe_object_id(event),
736
+ },
737
+ )
738
+
739
+ # v0.7: resolve tool objects (decorator may have stored names or
740
+ # objects). Build the provider-facing tool definitions list.
741
+ tools_for_call: list[Tool] = []
742
+ for t in b.tools:
743
+ name = t.name if isinstance(t, Tool) else str(t)
744
+ tool_obj = self.tool_registry.get(name)
745
+ if tool_obj is None:
746
+ self._emit_behavior_failed(
747
+ b.name,
748
+ event.id,
749
+ MissingToolError(name, registered=tuple(self.tool_registry.keys())),
750
+ reason="tool.unknown_tool",
751
+ extras={"tool": name},
752
+ )
753
+ return
754
+ tools_for_call.append(tool_obj)
755
+ tool_defs = [t.to_definition() for t in tools_for_call] if tools_for_call else None
756
+ tool_request_event_ids: list[str] = []
757
+
758
+ # ---- 1. Assemble base prompt -------------------------------------
759
+ try:
760
+ prompt = b.build_prompt(event, self.graph, frame=self.frame)
761
+ except Exception as e: # pragma: no cover — defensive
762
+ self._emit_behavior_failed(
763
+ b.name,
764
+ event.id,
765
+ e,
766
+ reason="llm.prompt_assembly_error",
767
+ )
768
+ return
769
+
770
+ # The base prompt has the system text + a single user message
771
+ # assembled from view+event+instruction. The turn loop will
772
+ # append assistant/tool messages as it goes.
773
+ running_messages: list[LLMMessage] = list(prompt.messages)
774
+
775
+ # The "last LLM request event id" gets stamped onto every
776
+ # object/relation/patch the handler creates. We track the
777
+ # most recent one — across the turn loop it's always the
778
+ # FINAL llm.requested whose response actually fed the handler.
779
+ last_llm_request_id: Optional[str] = None
780
+ first_llm_request_id: Optional[str] = None
781
+ response = None # final non-tool response
782
+
783
+ for turn_idx in range(max(1, b.max_tool_turns)):
784
+ if not self.budget.remaining():
785
+ self._emit_behavior_failed(
786
+ b.name,
787
+ event.id,
788
+ RuntimeError("budget exhausted mid-turn-loop"),
789
+ reason=_budget_reason(self.budget.exhausted_by()),
790
+ )
791
+ return
792
+ turn_hash = _hash_turn_prompt(
793
+ prompt=prompt,
794
+ messages=running_messages,
795
+ tool_defs=tool_defs,
796
+ )
797
+
798
+ # ---- Cache lookup ----------------------------------------------
799
+ cached: Optional[Any] = None
800
+ if self.replay_llm_cache and self._llm_cache is not None:
801
+ cached = self._llm_cache.get(turn_hash)
802
+
803
+ # ---- Pre-call cost gate ----------------------------------------
804
+ pre_estimate_cost: Optional[Decimal] = None
805
+ estimated_input_tokens: Optional[int] = None
806
+ if cached is None and self.budget.has_cost_limit():
807
+ try:
808
+ estimated_input_tokens = self.llm_provider.count_tokens(
809
+ system=prompt.system,
810
+ messages=running_messages,
811
+ model=prompt.model,
812
+ )
813
+ except Exception as e:
814
+ self._emit_behavior_failed(
815
+ b.name,
816
+ event.id,
817
+ e,
818
+ reason="llm.network_error",
819
+ extras={"model": prompt.model, "phase": "count_tokens"},
820
+ )
821
+ return
822
+ pre_estimate_cost = self.llm_provider.estimate_cost(
823
+ input_tokens=estimated_input_tokens,
824
+ output_tokens=prompt.max_tokens,
825
+ model=prompt.model,
826
+ )
827
+ if not self.budget.cost_remaining(pre_estimate_cost):
828
+ self._emit_behavior_failed(
829
+ b.name,
830
+ event.id,
831
+ RuntimeError("max_cost_usd would be exceeded"),
832
+ reason="budget.cost_exhausted",
833
+ extras={
834
+ "estimated_cost_usd": str(pre_estimate_cost),
835
+ "budget_remaining_usd": str(
836
+ self.budget.cost_remaining_amount()
837
+ ),
838
+ "model": prompt.model,
839
+ },
840
+ )
841
+ return
842
+
843
+ # ---- Emit llm.requested ----------------------------------------
844
+ requested_payload: dict[str, Any] = {
845
+ "behavior": b.name,
846
+ "model": prompt.model,
847
+ "prompt_hash": turn_hash,
848
+ "deterministic": prompt.deterministic,
849
+ "cache_hit": cached is not None,
850
+ "turn_index": turn_idx,
851
+ # CONTRACT v0.6 follow-up: surface the prompt-normalized
852
+ # flag explicitly so trace consumers can see that
853
+ # volatile-field stripping ran (always true in v0.7).
854
+ "prompt_normalized": True,
855
+ }
856
+ if turn_idx == 0:
857
+ # Only include full prompt body on turn 0; subsequent
858
+ # turns can be reconstructed from messages.
859
+ requested_payload["prompt"] = prompt.to_hashable()
860
+ if estimated_input_tokens is not None:
861
+ requested_payload["estimated_input_tokens"] = estimated_input_tokens
862
+ if pre_estimate_cost is not None:
863
+ requested_payload["estimated_cost_usd"] = str(pre_estimate_cost)
864
+ if self.budget.has_cost_limit():
865
+ remaining = self.budget.cost_remaining_amount()
866
+ requested_payload["budget_remaining_usd"] = (
867
+ str(remaining) if remaining is not None else None
868
+ )
869
+ requested_evt = self._emit_llm_event(
870
+ "llm.requested",
871
+ requested_payload,
872
+ actor=b.name,
873
+ caused_by=event.id if turn_idx == 0 else last_llm_request_id,
874
+ )
875
+ if first_llm_request_id is None:
876
+ first_llm_request_id = requested_evt.id
877
+ last_llm_request_id = requested_evt.id
878
+
879
+ # ---- Strict-replay hash check ---------------------------------
880
+ if self.replay_strict and self._strict_expected_hashes is not None:
881
+ expected = (
882
+ self._strict_expected_hashes.pop(0)
883
+ if self._strict_expected_hashes
884
+ else None
885
+ )
886
+ if expected is not None and expected != turn_hash:
887
+ raise ReplayDivergenceError(
888
+ event_id=requested_evt.id,
889
+ expected=f"prompt_hash={expected}",
890
+ actual=f"prompt_hash={turn_hash}",
891
+ )
892
+
893
+ # ---- Get the response (cache or provider) ---------------------
894
+ if cached is not None:
895
+ turn_response = cached
896
+ else:
897
+ try:
898
+ turn_response = self.llm_provider.complete(
899
+ system=prompt.system,
900
+ messages=running_messages,
901
+ model=prompt.model,
902
+ max_tokens=prompt.max_tokens,
903
+ temperature=prompt.temperature,
904
+ top_p=prompt.top_p,
905
+ output_schema=b.output_schema,
906
+ timeout_seconds=b.timeout_seconds,
907
+ tools=tool_defs,
908
+ )
909
+ except LLMBehaviorError as e:
910
+ self._emit_behavior_failed(
911
+ b.name, event.id, e, reason=e.reason,
912
+ extras=e.payload_extras,
913
+ )
914
+ return
915
+ except Exception as e:
916
+ self._emit_behavior_failed(
917
+ b.name, event.id, e,
918
+ reason="llm.network_error",
919
+ extras={"model": prompt.model},
920
+ )
921
+ return
922
+ self._llm_cache.record(
923
+ turn_hash, turn_response, requesting_event_id=requested_evt.id
924
+ ) if self._llm_cache is not None else None
925
+ if self._llm_cache is None:
926
+ self._llm_cache = LLMCache()
927
+ self._llm_cache.record(
928
+ turn_hash, turn_response, requesting_event_id=requested_evt.id
929
+ )
930
+ self.budget.add_cost(turn_response.cost_usd)
931
+
932
+ # ---- Emit llm.responded ---------------------------------------
933
+ responded_payload = turn_response.to_dict() | {
934
+ "behavior": b.name,
935
+ "prompt_hash": turn_hash,
936
+ "turn_index": turn_idx,
937
+ }
938
+ self._emit_llm_event(
939
+ "llm.responded",
940
+ responded_payload,
941
+ actor=b.name,
942
+ caused_by=requested_evt.id,
943
+ )
944
+
945
+ # ---- Branch: tool calls vs final response ---------------------
946
+ # `tool_calls` is optional on LLMResponse; some test providers
947
+ # return ad-hoc objects without it. Treat missing/None/empty
948
+ # as "no tool calls" so backward compatibility holds.
949
+ response_tool_calls = getattr(turn_response, "tool_calls", None) or []
950
+ if not response_tool_calls:
951
+ response = turn_response
952
+ break
953
+
954
+ # Tool calls. Append the assistant turn to messages, then
955
+ # dispatch each tool. Anthropic's assistant turn that
956
+ # triggers tool_use carries the model's reasoning text plus
957
+ # the tool_use blocks; we approximate by including raw_text
958
+ # (may be empty) — the providers' adapters handle the rest.
959
+ if turn_response.raw_text:
960
+ running_messages.append(
961
+ LLMMessage(role="assistant", content=turn_response.raw_text)
962
+ )
963
+ for call in response_tool_calls:
964
+ # CONTRACT v0.7 #6: budget enforcement BEFORE invocation
965
+ # so an exhausted budget fails the behavior, doesn't
966
+ # silently no-op. Check the tool-call counter FIRST so
967
+ # we surface the specific v0.7 reason code rather than
968
+ # the generic budget name.
969
+ limit = self.budget.limits.get("max_tool_calls", float("inf"))
970
+ if self.budget.used.get("max_tool_calls", 0.0) >= limit:
971
+ self._emit_behavior_failed(
972
+ b.name, event.id,
973
+ RuntimeError("max_tool_calls exhausted"),
974
+ reason="budget.tool_calls_exhausted",
975
+ extras={"tool": call.name},
976
+ )
977
+ return
978
+ if not self.budget.remaining():
979
+ self._emit_behavior_failed(
980
+ b.name, event.id,
981
+ RuntimeError("budget exhausted before tool call"),
982
+ reason=_budget_reason(self.budget.exhausted_by()),
983
+ extras={"tool": call.name},
984
+ )
985
+ return
986
+ # Tool must have been declared by the behavior.
987
+ if not any(
988
+ (isinstance(t, Tool) and t.name == call.name)
989
+ or (isinstance(t, str) and t == call.name)
990
+ for t in b.tools
991
+ ):
992
+ declared = tuple(
993
+ t if isinstance(t, str) else getattr(t, "name", repr(t))
994
+ for t in (b.tools or [])
995
+ )
996
+ self._emit_behavior_failed(
997
+ b.name, event.id,
998
+ UnknownToolError(
999
+ f"LLM called tool {call.name!r} which is not "
1000
+ f"declared on @llm_behavior(tools=[...])",
1001
+ tool_name=call.name,
1002
+ behavior_name=b.name,
1003
+ declared_tools=declared,
1004
+ ),
1005
+ reason="tool.unknown_tool",
1006
+ extras={"tool": call.name},
1007
+ )
1008
+ return
1009
+ tool_obj = self.tool_registry[call.name]
1010
+ tr_id = self._invoke_tool(
1011
+ behavior=b,
1012
+ event=event,
1013
+ tool=tool_obj,
1014
+ call=call,
1015
+ last_llm_request_id=requested_evt.id,
1016
+ ctx_frame=self.frame,
1017
+ )
1018
+ if tr_id is None:
1019
+ # tool failed — wrapper already emitted behavior.failed.
1020
+ return
1021
+ tool_request_event_ids.append(tr_id)
1022
+ # Append tool result back into the conversation so the
1023
+ # next turn sees it. The result message was stashed by
1024
+ # _invoke_tool on self._last_tool_result_message.
1025
+ if self._last_tool_result_message is not None:
1026
+ running_messages.append(self._last_tool_result_message)
1027
+ self._last_tool_result_message = None
1028
+
1029
+ # All tool calls succeeded; loop again with new messages.
1030
+ continue
1031
+ else:
1032
+ # for/else: we ran out of turns without a final response.
1033
+ self._emit_behavior_failed(
1034
+ b.name,
1035
+ event.id,
1036
+ RuntimeError(
1037
+ f"exceeded max_tool_turns={b.max_tool_turns} without "
1038
+ f"a non-tool response"
1039
+ ),
1040
+ reason="tool.max_turns_exhausted",
1041
+ extras={"max_tool_turns": b.max_tool_turns},
1042
+ )
1043
+ return
1044
+
1045
+ # ---- Hydrate parsed output (cache may have dicts) -----------------
1046
+ if (
1047
+ b.output_schema is not None
1048
+ and response.parsed is not None
1049
+ and not isinstance(response.parsed, b.output_schema)
1050
+ ):
1051
+ try:
1052
+ response.parsed = b.output_schema.model_validate(response.parsed)
1053
+ except Exception as e:
1054
+ self._emit_behavior_failed(
1055
+ b.name,
1056
+ event.id,
1057
+ e,
1058
+ reason="llm.schema_violation",
1059
+ extras={
1060
+ "raw_text": response.raw_text,
1061
+ "schema": b.output_schema.__name__,
1062
+ "validation_errors": str(e),
1063
+ "from_cache": True,
1064
+ },
1065
+ )
1066
+ return
1067
+
1068
+ # ---- Validate output ---------------------------------------------
1069
+ if b.output_schema is not None and response.parsed is None:
1070
+ self._emit_behavior_failed(
1071
+ b.name,
1072
+ event.id,
1073
+ LLMBehaviorError(
1074
+ "llm.parse_error",
1075
+ "provider returned no parsed output despite output_schema",
1076
+ ),
1077
+ reason="llm.parse_error",
1078
+ extras={
1079
+ "raw_text": response.raw_text,
1080
+ "schema": b.output_schema.__name__,
1081
+ },
1082
+ )
1083
+ return
1084
+
1085
+ # ---- Invoke developer handler with provenance stamping -----------
1086
+ bgraph._llm_request_event_id = first_llm_request_id # noqa: SLF001
1087
+ bgraph._tool_request_event_ids = list(tool_request_event_ids) # noqa: SLF001
1088
+ try:
1089
+ b.handler(event, bgraph, ctx, response.parsed)
1090
+ except LLMBehaviorError as e:
1091
+ self._emit_behavior_failed(
1092
+ b.name, event.id, e, reason=e.reason,
1093
+ extras=e.payload_extras,
1094
+ )
1095
+ return
1096
+ except Exception as e:
1097
+ self._emit_behavior_failed(b.name, event.id, e)
1098
+ return
1099
+
1100
+ self._emit_lifecycle(
1101
+ "behavior.completed",
1102
+ {
1103
+ "behavior": b.name,
1104
+ "event_id": event.id,
1105
+ "objects_created": bgraph.counters.objects_created,
1106
+ "relations_created": bgraph.counters.relations_created,
1107
+ "patches_applied": bgraph.counters.patches_applied,
1108
+ "patches_proposed": bgraph.counters.patches_proposed,
1109
+ "events_emitted": bgraph.counters.events_emitted,
1110
+ "tool_calls": len(tool_request_event_ids),
1111
+ },
1112
+ )
1113
+
1114
+ # ---- v0.7: single tool invocation ------------------------------------
1115
+
1116
+ def _invoke_tool(
1117
+ self,
1118
+ *,
1119
+ behavior: LLMBehavior,
1120
+ event: Event,
1121
+ tool: Tool,
1122
+ call: ToolCall,
1123
+ last_llm_request_id: str,
1124
+ ctx_frame: Optional[Frame],
1125
+ ) -> Optional[str]:
1126
+ """Dispatch a single tool call. Emits tool.requested + tool.responded.
1127
+
1128
+ Returns the `tool.requested` event id on success; None on
1129
+ failure (caller has already emitted behavior.failed).
1130
+ """
1131
+ import logging
1132
+ import uuid
1133
+
1134
+ self.budget.consume("max_tool_calls")
1135
+ args_hash = hash_tool_call(tool_name=tool.name, args=call.args)
1136
+
1137
+ # Validate input
1138
+ try:
1139
+ if tool.input_schema is not None:
1140
+ input_obj = tool.input_schema.model_validate(call.args)
1141
+ else:
1142
+ input_obj = call.args
1143
+ except Exception as e:
1144
+ # Emit a tool.requested + tool.responded(error=...) pair
1145
+ # before bubbling the failure, so the trace is complete.
1146
+ req_evt = self._emit_tool_event(
1147
+ "tool.requested",
1148
+ {
1149
+ "behavior": behavior.name,
1150
+ "tool": tool.name,
1151
+ "args_hash": args_hash,
1152
+ "args": canonicalize_args(call.args),
1153
+ "call_id": call.id,
1154
+ "cache_hit": False,
1155
+ },
1156
+ actor=behavior.name,
1157
+ caused_by=last_llm_request_id,
1158
+ )
1159
+ err = {"reason": "tool.invalid_input", "message": str(e)}
1160
+ self._emit_tool_event(
1161
+ "tool.responded",
1162
+ {
1163
+ "behavior": behavior.name,
1164
+ "tool": tool.name,
1165
+ "args_hash": args_hash,
1166
+ "error": err,
1167
+ "cache_hit": False,
1168
+ "latency_seconds": 0.0,
1169
+ "cost_usd": "0",
1170
+ },
1171
+ actor=behavior.name,
1172
+ caused_by=req_evt.id,
1173
+ )
1174
+ self._emit_behavior_failed(
1175
+ behavior.name, event.id, e,
1176
+ reason="tool.invalid_input",
1177
+ extras={"tool": tool.name, "validation_errors": str(e)},
1178
+ )
1179
+ return None
1180
+
1181
+ # Cache lookup
1182
+ cached_tool: Optional[CachedToolResponse] = None
1183
+ if self.replay_tool_cache:
1184
+ cached_tool = self._tool_cache.get(args_hash)
1185
+ # Determinism opt-in: re-invoke deterministic tools instead
1186
+ # of serving from cache.
1187
+ if cached_tool is not None and tool.deterministic and self.replay_reinvoke_deterministic:
1188
+ cached_tool = None
1189
+
1190
+ # Cost gate (tools and LLM share max_cost_usd budget)
1191
+ if cached_tool is None and self.budget.has_cost_limit():
1192
+ if not self.budget.cost_remaining(tool.cost_per_call):
1193
+ self._emit_behavior_failed(
1194
+ behavior.name, event.id,
1195
+ RuntimeError("max_cost_usd would be exceeded by tool"),
1196
+ reason="budget.cost_exhausted",
1197
+ extras={
1198
+ "tool": tool.name,
1199
+ "estimated_cost_usd": str(tool.cost_per_call),
1200
+ },
1201
+ )
1202
+ return None
1203
+
1204
+ # Emit tool.requested
1205
+ req_evt = self._emit_tool_event(
1206
+ "tool.requested",
1207
+ {
1208
+ "behavior": behavior.name,
1209
+ "tool": tool.name,
1210
+ "args_hash": args_hash,
1211
+ "args": canonicalize_args(call.args),
1212
+ "call_id": call.id,
1213
+ "cache_hit": cached_tool is not None,
1214
+ "deterministic": tool.deterministic,
1215
+ },
1216
+ actor=behavior.name,
1217
+ caused_by=last_llm_request_id,
1218
+ )
1219
+
1220
+ if cached_tool is not None:
1221
+ tool_response = cached_tool
1222
+ else:
1223
+ tool_ctx = ToolContext(
1224
+ behavior_name=behavior.name,
1225
+ event_id=event.id,
1226
+ frame=ctx_frame,
1227
+ idempotency_key=str(uuid.uuid4()),
1228
+ timeout_seconds=tool.timeout_seconds,
1229
+ logger=logging.getLogger(f"activegraph.tools.{tool.name}"),
1230
+ )
1231
+ try:
1232
+ tool_response = self._tool_invoker.invoke(tool, input_obj, tool_ctx)
1233
+ except ToolError as e:
1234
+ self._emit_tool_event(
1235
+ "tool.responded",
1236
+ {
1237
+ "behavior": behavior.name,
1238
+ "tool": tool.name,
1239
+ "args_hash": args_hash,
1240
+ "error": {
1241
+ "reason": e.reason,
1242
+ "message": str(e),
1243
+ **e.payload_extras,
1244
+ },
1245
+ "cache_hit": False,
1246
+ "latency_seconds": 0.0,
1247
+ "cost_usd": "0",
1248
+ },
1249
+ actor=behavior.name,
1250
+ caused_by=req_evt.id,
1251
+ )
1252
+ self._emit_behavior_failed(
1253
+ behavior.name, event.id, e,
1254
+ reason=e.reason,
1255
+ extras={"tool": tool.name, **e.payload_extras},
1256
+ )
1257
+ return None
1258
+ self._tool_cache.record(
1259
+ args_hash, tool_response, requesting_event_id=req_evt.id
1260
+ )
1261
+ self.budget.add_cost(tool_response.cost_usd)
1262
+
1263
+ # Validate output (if schema)
1264
+ validated_output = tool_response.output
1265
+ if tool.output_schema is not None and tool_response.output is not None:
1266
+ try:
1267
+ model_inst = tool.output_schema.model_validate(tool_response.output)
1268
+ dump = getattr(model_inst, "model_dump", None)
1269
+ if callable(dump):
1270
+ try:
1271
+ validated_output = dump(mode="json")
1272
+ except TypeError:
1273
+ validated_output = dump()
1274
+ except Exception as e:
1275
+ self._emit_tool_event(
1276
+ "tool.responded",
1277
+ {
1278
+ "behavior": behavior.name,
1279
+ "tool": tool.name,
1280
+ "args_hash": args_hash,
1281
+ "error": {
1282
+ "reason": "tool.invalid_output",
1283
+ "message": str(e),
1284
+ },
1285
+ "cache_hit": tool_response.cache_hit,
1286
+ "latency_seconds": tool_response.latency_seconds,
1287
+ "cost_usd": str(tool_response.cost_usd),
1288
+ },
1289
+ actor=behavior.name,
1290
+ caused_by=req_evt.id,
1291
+ )
1292
+ self._emit_behavior_failed(
1293
+ behavior.name, event.id, e,
1294
+ reason="tool.invalid_output",
1295
+ extras={
1296
+ "tool": tool.name,
1297
+ "validation_errors": str(e),
1298
+ },
1299
+ )
1300
+ return None
1301
+
1302
+ # Emit tool.responded
1303
+ self._emit_tool_event(
1304
+ "tool.responded",
1305
+ {
1306
+ "behavior": behavior.name,
1307
+ "tool": tool.name,
1308
+ "args_hash": args_hash,
1309
+ "output": validated_output,
1310
+ "error": None,
1311
+ "cache_hit": tool_response.cache_hit,
1312
+ "latency_seconds": tool_response.latency_seconds,
1313
+ "cost_usd": str(tool_response.cost_usd),
1314
+ "deterministic": tool.deterministic,
1315
+ },
1316
+ actor=behavior.name,
1317
+ caused_by=req_evt.id,
1318
+ )
1319
+
1320
+ # Echo the tool result back into the message stream so the
1321
+ # next LLM turn can see it.
1322
+ import json as _json
1323
+ running_messages_append = getattr(self, "_current_running_messages", None)
1324
+ # NOTE: we use the caller's local running_messages via closure;
1325
+ # to avoid threading it through, the caller appends after this.
1326
+ # Stash the tool-result content on the request event payload
1327
+ # for now so the caller can fetch it. Simpler: return a payload.
1328
+
1329
+ # We append in the caller — but we need to return the content
1330
+ # too. Refactor: pass running_messages by reference via mutating
1331
+ # method. Cleanest: do it here using a passed list.
1332
+ # (Inlined below by storing in self._last_tool_result_message.)
1333
+ self._last_tool_result_message = LLMMessage(
1334
+ role="tool",
1335
+ content=_json.dumps(validated_output, sort_keys=True, default=str),
1336
+ tool_use_id=call.id,
1337
+ tool_name=tool.name,
1338
+ )
1339
+ return req_evt.id
1340
+
1341
+ def _emit_tool_event(
1342
+ self,
1343
+ type_: str,
1344
+ payload: dict[str, Any],
1345
+ *,
1346
+ actor: str,
1347
+ caused_by: Optional[str],
1348
+ ) -> Event:
1349
+ ev = Event(
1350
+ id=self.graph.ids.event(),
1351
+ type=type_,
1352
+ payload=payload,
1353
+ actor=actor,
1354
+ frame_id=self.frame.id if self.frame else None,
1355
+ caused_by=caused_by,
1356
+ timestamp=self.graph.clock.now(),
1357
+ )
1358
+ self.graph.emit(ev)
1359
+ return ev
1360
+
1361
+ def _emit_llm_event(
1362
+ self,
1363
+ type_: str,
1364
+ payload: dict[str, Any],
1365
+ *,
1366
+ actor: str,
1367
+ caused_by: Optional[str],
1368
+ ) -> Event:
1369
+ ev = Event(
1370
+ id=self.graph.ids.event(),
1371
+ type=type_,
1372
+ payload=payload,
1373
+ actor=actor,
1374
+ frame_id=self.frame.id if self.frame else None,
1375
+ caused_by=caused_by,
1376
+ timestamp=self.graph.clock.now(),
1377
+ )
1378
+ self.graph.emit(ev)
1379
+ return ev
1380
+
1381
+ def _emit_behavior_failed(
1382
+ self,
1383
+ behavior_name: str,
1384
+ event_id: str,
1385
+ exc: BaseException,
1386
+ *,
1387
+ reason: Optional[str] = None,
1388
+ extras: Optional[dict[str, Any]] = None,
1389
+ ) -> None:
1390
+ """Centralized behavior.failed emission. CONTRACT #13 plus
1391
+ v0.6 #11 (optional `reason` + LLM-specific extras).
1392
+ """
1393
+
1394
+ tb_str = "".join(
1395
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
1396
+ ) if exc.__traceback__ is not None else traceback.format_exc()
1397
+ payload: dict[str, Any] = {
1398
+ "behavior": behavior_name,
1399
+ "event_id": event_id,
1400
+ "exception_type": type(exc).__name__,
1401
+ "message": str(exc),
1402
+ "traceback": tb_str,
1403
+ }
1404
+ if reason is not None:
1405
+ payload["reason"] = reason
1406
+ if extras:
1407
+ for k, v in extras.items():
1408
+ if k not in payload:
1409
+ payload[k] = v
1410
+ self._emit_lifecycle("behavior.failed", payload)
1411
+
1412
+ def _invoke_relation(
1413
+ self,
1414
+ b: RelationBehavior,
1415
+ relation,
1416
+ event: Event,
1417
+ matches: Optional[list] = None,
1418
+ ) -> None:
1419
+ self.budget.consume("max_behavior_calls")
1420
+ bgraph = BehaviorGraph(
1421
+ self.graph,
1422
+ actor=b.name,
1423
+ caused_by=event.id,
1424
+ frame_id=self.frame.id if self.frame else None,
1425
+ )
1426
+ view = build_view(b, event, self.graph)
1427
+ ctx = Context(
1428
+ view=view,
1429
+ frame=self.frame,
1430
+ policy=self.policy,
1431
+ random=self._random,
1432
+ clock=self.graph.clock,
1433
+ matches=list(matches or []),
1434
+ settings=self._pack_settings_for_behavior(b),
1435
+ _runtime=self,
1436
+ )
1437
+
1438
+ self._emit_lifecycle(
1439
+ "relation_behavior.started",
1440
+ {
1441
+ "behavior": b.name,
1442
+ "event_id": event.id,
1443
+ "triggering_event_type": event.type,
1444
+ "relation_type": b.relation_type,
1445
+ "relation_id": relation.id,
1446
+ },
1447
+ )
1448
+ try:
1449
+ b.run(relation, event, bgraph, ctx)
1450
+ except Exception as e:
1451
+ self._emit_lifecycle(
1452
+ "behavior.failed",
1453
+ {
1454
+ "behavior": b.name,
1455
+ "event_id": event.id,
1456
+ "exception_type": type(e).__name__,
1457
+ "message": str(e),
1458
+ "traceback": traceback.format_exc(),
1459
+ },
1460
+ )
1461
+ return
1462
+
1463
+ self._emit_lifecycle(
1464
+ "behavior.completed",
1465
+ {
1466
+ "behavior": b.name,
1467
+ "event_id": event.id,
1468
+ "objects_created": bgraph.counters.objects_created,
1469
+ "relations_created": bgraph.counters.relations_created,
1470
+ "patches_applied": bgraph.counters.patches_applied,
1471
+ "patches_proposed": bgraph.counters.patches_proposed,
1472
+ "events_emitted": bgraph.counters.events_emitted,
1473
+ },
1474
+ )
1475
+
1476
+ # ---------- v0.8: runtime.status ----------
1477
+
1478
+ def status(self, recent: int = 20) -> RuntimeStatus:
1479
+ """Frozen snapshot of the runtime. CONTRACT v0.8 #11.
1480
+
1481
+ Cheap to call. No graph traversal beyond a tail-slice of the
1482
+ event log. Returns immutable data; mutating any field raises.
1483
+
1484
+ ``recent`` controls the length of the ``recent_events`` tail.
1485
+ The CLI's ``inspect --tail N`` passes through.
1486
+ """
1487
+ if recent < 0:
1488
+ from activegraph.runtime.config_errors import InvalidRuntimeConfiguration
1489
+ raise InvalidRuntimeConfiguration(
1490
+ f"runtime.status(recent={recent}) — recent must be >= 0",
1491
+ what_failed=(
1492
+ f"runtime.status(recent={recent}) was called with a "
1493
+ f"negative count. The `recent` argument controls the "
1494
+ f"length of the `recent_events` tail in the status "
1495
+ f"snapshot."
1496
+ ),
1497
+ why=(
1498
+ "A negative recent count has no defined semantics — "
1499
+ "the tail length is a non-negative integer by "
1500
+ "construction. The framework refuses the call rather "
1501
+ "than silently coerce to zero, because the caller's "
1502
+ "intent is ambiguous (did they mean zero? did they "
1503
+ "compute the value and end up with a negative? did "
1504
+ "they want 'all events' and pass -1 from another "
1505
+ "API's convention?)."
1506
+ ),
1507
+ how_to_fix=(
1508
+ "Pass a non-negative integer:\n"
1509
+ " rt.status(recent=20) # last 20 events\n"
1510
+ " rt.status(recent=0) # no recent events in the\n"
1511
+ " # snapshot (just totals)\n"
1512
+ "\n"
1513
+ "To get every event, read `rt.graph.events` directly "
1514
+ "rather than passing a large `recent`."
1515
+ ),
1516
+ context={"recent": recent},
1517
+ )
1518
+ snap = self.budget.snapshot()
1519
+ budget_snap = BudgetSnapshot(
1520
+ used=dict(snap.get("used") or {}),
1521
+ limits=dict(snap.get("limits") or {}),
1522
+ cost_used_usd=str(snap.get("cost_used_usd", "0")),
1523
+ cost_limit_usd=snap.get("cost_limit_usd"),
1524
+ exhausted_by=self.budget.exhausted_by(),
1525
+ )
1526
+
1527
+ # State derivation: log-based, so a freshly loaded runtime and
1528
+ # the runtime that saved the log agree. Walk back through the
1529
+ # event log for the most recent terminal lifecycle event.
1530
+ # CONTRACT v0.8 #11.
1531
+ state: str = "stopped"
1532
+ for ev in reversed(self.graph.events):
1533
+ t = ev.type
1534
+ if t == "runtime.budget_exhausted":
1535
+ state = "exhausted"
1536
+ break
1537
+ if t == "runtime.idle":
1538
+ state = "idle"
1539
+ break
1540
+
1541
+ frame_snap: Optional[FrameSnapshot] = None
1542
+ if self.frame is not None:
1543
+ frame_snap = FrameSnapshot(
1544
+ id=self.frame.id,
1545
+ name=getattr(self.frame, "name", None),
1546
+ )
1547
+
1548
+ # Behaviors: only enumerable if we've built the registry. Pre-run
1549
+ # status() calls work fine but show an empty list (which is what
1550
+ # the operator should see — registry isn't materialized yet).
1551
+ b_infos: list[BehaviorInfo] = []
1552
+ if self.registry is not None:
1553
+ for b in self.registry.all():
1554
+ kind = "function"
1555
+ if isinstance(b, RelationBehavior):
1556
+ kind = "relation"
1557
+ elif isinstance(b, LLMBehavior):
1558
+ kind = "llm"
1559
+ b_infos.append(
1560
+ BehaviorInfo(
1561
+ name=b.name,
1562
+ kind=kind,
1563
+ subscribed_to=tuple(b.on or ()),
1564
+ pattern=getattr(b, "pattern", None),
1565
+ activate_after=getattr(b, "activate_after", None),
1566
+ )
1567
+ )
1568
+
1569
+ events = self.graph.events
1570
+ tail = events[-recent:] if recent > 0 else []
1571
+ e_summaries = tuple(
1572
+ EventSummary(
1573
+ id=e.id, type=e.type, actor=e.actor, timestamp=e.timestamp
1574
+ )
1575
+ for e in tail
1576
+ )
1577
+
1578
+ return RuntimeStatus(
1579
+ run_id=self.graph.run_id,
1580
+ state=state, # type: ignore[arg-type]
1581
+ queue_depth=len(self._queue),
1582
+ events_processed=len(events),
1583
+ budget=budget_snap,
1584
+ frame=frame_snap,
1585
+ registered_behaviors=tuple(b_infos),
1586
+ recent_events=e_summaries,
1587
+ )
1588
+
1589
+ # ---------- lifecycle / idle emit (bypass queue) ----------
1590
+
1591
+ def _emit_lifecycle(self, type_: str, payload: dict[str, Any]) -> Event:
1592
+ ev = Event(
1593
+ id=self.graph.ids.event(),
1594
+ type=type_,
1595
+ payload=payload,
1596
+ actor="runtime",
1597
+ frame_id=self.frame.id if self.frame else None,
1598
+ caused_by=payload.get("event_id"),
1599
+ timestamp=self.graph.clock.now(),
1600
+ )
1601
+ self.graph.emit(ev)
1602
+ return ev
1603
+
1604
+ def _emit_idle_or_exhausted(self) -> None:
1605
+ if self._idle_emitted:
1606
+ return
1607
+ if not self.budget.remaining():
1608
+ self._emit_lifecycle(
1609
+ "runtime.budget_exhausted",
1610
+ {
1611
+ "exhausted_by": self.budget.exhausted_by(),
1612
+ "snapshot": self.budget.snapshot(),
1613
+ },
1614
+ )
1615
+ else:
1616
+ self._emit_lifecycle(
1617
+ "runtime.idle",
1618
+ {"snapshot": self.budget.snapshot()},
1619
+ )
1620
+ self._idle_emitted = True
1621
+
1622
+ # ---------- trace + graph dump (delegate to trace module) ----------
1623
+
1624
+ # ---------- v0.9: pack public API ----------
1625
+
1626
+ def load_pack(self, pack, settings=None) -> bool:
1627
+ """Load a pack into the runtime.
1628
+
1629
+ Returns True on first load, False if the same `(name, version)`
1630
+ was already loaded (CONTRACT v0.9 #6 idempotency). Raises
1631
+ `PackVersionConflictError` for name-match-version-mismatch and
1632
+ `PackConflictError` for any contributor name collision.
1633
+ Pre-mutation: a failed load leaves the runtime exactly as it was.
1634
+ """
1635
+ from activegraph.packs.loader import load_pack_into_runtime
1636
+ return load_pack_into_runtime(self, pack, settings=settings)
1637
+
1638
+ def loaded_packs(self) -> list:
1639
+ """List of currently-loaded packs."""
1640
+ if self._pack_state is None:
1641
+ return []
1642
+ return list(self._pack_state.loaded_packs.values())
1643
+
1644
+ def get_behavior(self, name: str):
1645
+ """Look up a registered behavior by canonical or short name.
1646
+
1647
+ Short names resolve when unambiguous (load-time conflict check
1648
+ guarantees this invariant). Raises `LookupError` if not found
1649
+ or `ValueError` if ambiguous. CONTRACT v0.9 #8.
1650
+ """
1651
+ from activegraph.runtime.registration_errors import (
1652
+ AmbiguousBehaviorError,
1653
+ BehaviorNotFoundError,
1654
+ )
1655
+ # Canonical lookup first
1656
+ if "." in name:
1657
+ for b in self._pack_behaviors:
1658
+ if b.name == name:
1659
+ return b
1660
+ raise BehaviorNotFoundError(
1661
+ name, registered=tuple(b.name for b in self._pack_behaviors),
1662
+ )
1663
+ # Short name
1664
+ if self._pack_state is None:
1665
+ raise BehaviorNotFoundError(
1666
+ name, registered=tuple(b.name for b in self._pack_behaviors),
1667
+ pack_state=False,
1668
+ )
1669
+ from activegraph.packs.loader import AMBIGUOUS
1670
+ canonical = self._pack_state.behavior_short_to_canonical.get(name)
1671
+ if canonical is None:
1672
+ raise BehaviorNotFoundError(
1673
+ name,
1674
+ registered=tuple(b.name for b in self._pack_behaviors),
1675
+ pack_state=True,
1676
+ )
1677
+ if canonical == AMBIGUOUS:
1678
+ # Find which packs collide on this short name.
1679
+ owners = tuple(
1680
+ p for c, p in self._pack_state.behavior_owners.items()
1681
+ if c.endswith(f".{name}")
1682
+ )
1683
+ raise AmbiguousBehaviorError(name, packs=owners)
1684
+ return self.get_behavior(canonical)
1685
+
1686
+ def get_tool(self, name: str):
1687
+ """Look up a registered tool by canonical or short name.
1688
+
1689
+ Same resolution rule as `get_behavior`. CONTRACT v0.9 #8 / #9.
1690
+ """
1691
+ from activegraph.runtime.registration_errors import (
1692
+ AmbiguousToolError,
1693
+ ToolNotFoundError,
1694
+ )
1695
+ if "." in name:
1696
+ t = self.tool_registry.get(name)
1697
+ if t is None:
1698
+ raise ToolNotFoundError(
1699
+ name, registered=tuple(self.tool_registry.keys()),
1700
+ )
1701
+ return t
1702
+ # Short name
1703
+ if self._pack_state is None:
1704
+ t = self.tool_registry.get(name)
1705
+ if t is None:
1706
+ raise ToolNotFoundError(
1707
+ name, registered=tuple(self.tool_registry.keys()),
1708
+ )
1709
+ return t
1710
+ from activegraph.packs.loader import AMBIGUOUS
1711
+ canonical = self._pack_state.tool_short_to_canonical.get(name)
1712
+ if canonical is None:
1713
+ # Maybe a globally-exported tool registered under its short name
1714
+ t = self.tool_registry.get(name)
1715
+ if t is None:
1716
+ raise ToolNotFoundError(
1717
+ name, registered=tuple(self.tool_registry.keys()),
1718
+ )
1719
+ return t
1720
+ if canonical == AMBIGUOUS:
1721
+ owners = tuple(
1722
+ p for c, p in self._pack_state.tool_owners.items()
1723
+ if c.endswith(f".{name}")
1724
+ )
1725
+ raise AmbiguousToolError(name, packs=owners)
1726
+ return self.tool_registry[canonical]
1727
+
1728
+ def _pack_settings_for_behavior(self, b) -> Any:
1729
+ """Return the settings instance for the pack that owns `b`, or
1730
+ None if `b` is not pack-owned.
1731
+ """
1732
+ if self._pack_state is None:
1733
+ return None
1734
+ owner = getattr(b, "_pack_owner", None)
1735
+ if owner is None:
1736
+ return None
1737
+ return self._pack_state.pack_settings.get(owner)
1738
+
1739
+ # ---------- v0.9: approval flow ----------
1740
+
1741
+ def pending_approvals(self) -> list:
1742
+ """List of currently-pending approvals (in creation order)."""
1743
+ if self._pack_state is None:
1744
+ return []
1745
+ return list(self._pack_state.pending_approvals)
1746
+
1747
+ def _add_pending_approval(
1748
+ self, *, object_type: str, data: dict, reason: str = ""
1749
+ ) -> str:
1750
+ """Internal: create a PendingApproval and return its id.
1751
+
1752
+ Called from `ctx.propose_object(...)`. The id is later used by
1753
+ `runtime.approve(id)` to materialize the deferred object.
1754
+ """
1755
+ from activegraph.packs import PendingApproval
1756
+ from activegraph.packs.loader import _ensure_pack_state
1757
+
1758
+ state = _ensure_pack_state(self)
1759
+ # Find the pack that gates this object type, if any.
1760
+ gating = state.gated_object_types.get(object_type, [])
1761
+ owner_pack = gating[0].split(".", 1)[0] if gating else ""
1762
+ n = state._next_approval_n
1763
+ state._next_approval_n += 1
1764
+ approval_id = f"approval_{n:03d}"
1765
+ pa = PendingApproval(
1766
+ id=approval_id,
1767
+ kind="object",
1768
+ object_type=object_type,
1769
+ data=dict(data),
1770
+ reason=reason,
1771
+ pack=owner_pack,
1772
+ )
1773
+ state.pending_approvals.append(pa)
1774
+ # Emit an event so the trace shows the proposal.
1775
+ self.graph.emit(
1776
+ Event(
1777
+ id=self.graph.ids.event(),
1778
+ type="approval.proposed",
1779
+ payload={
1780
+ "approval_id": approval_id,
1781
+ "object_type": object_type,
1782
+ "reason": reason,
1783
+ "pack": owner_pack,
1784
+ },
1785
+ actor="runtime",
1786
+ frame_id=self.frame.id if self.frame else None,
1787
+ caused_by=None,
1788
+ timestamp=self.graph.clock.now(),
1789
+ )
1790
+ )
1791
+ return approval_id
1792
+
1793
+ def approve(self, approval_id: str, approved_by: Optional[str] = None) -> str:
1794
+ """Materialize a pending approval. Returns the new object id.
1795
+
1796
+ Raises `LookupError` if `approval_id` is not pending. Emits an
1797
+ `approval.granted` event followed by the deferred `object.created`.
1798
+ """
1799
+ from activegraph.runtime.exec_errors import ApprovalNotFoundError
1800
+ if self._pack_state is None:
1801
+ raise ApprovalNotFoundError(approval_id, pending_count=0)
1802
+ for i, pa in enumerate(self._pack_state.pending_approvals):
1803
+ if pa.id != approval_id:
1804
+ continue
1805
+ self._pack_state.pending_approvals.pop(i)
1806
+ self.graph.emit(
1807
+ Event(
1808
+ id=self.graph.ids.event(),
1809
+ type="approval.granted",
1810
+ payload={
1811
+ "approval_id": approval_id,
1812
+ "object_type": pa.object_type,
1813
+ "approved_by": approved_by or "user",
1814
+ },
1815
+ actor="runtime",
1816
+ frame_id=self.frame.id if self.frame else None,
1817
+ caused_by=None,
1818
+ timestamp=self.graph.clock.now(),
1819
+ )
1820
+ )
1821
+ obj = self.graph.add_object(pa.object_type, pa.data, actor=approved_by or "user")
1822
+ return obj.id
1823
+ raise ApprovalNotFoundError(
1824
+ approval_id, pending_count=len(self._pack_state.pending_approvals)
1825
+ )
1826
+
1827
+ @property
1828
+ def trace(self):
1829
+ from activegraph.trace.printer import Trace
1830
+
1831
+ return Trace(self.graph)
1832
+
1833
+ def print_trace(self) -> None:
1834
+ self.trace.print()
1835
+
1836
+ def export_trace(self, path: str) -> None:
1837
+ with open(path, "w") as f:
1838
+ for ev in self.graph.events:
1839
+ f.write(json.dumps(ev.to_dict()) + "\n")
1840
+
1841
+ def print_graph(self) -> None:
1842
+ print("graph:")
1843
+ print(f" objects ({len(self.graph.all_objects())}):")
1844
+ for o in self.graph.all_objects():
1845
+ label = o.data.get("title") or o.data.get("text") or ""
1846
+ extra = f' "{label}"' if label else ""
1847
+ status = o.data.get("status")
1848
+ status_s = f" ({status})" if status else ""
1849
+ print(f" {o.id}{extra}{status_s}")
1850
+ print(f" relations ({len(self.graph.all_relations())}):")
1851
+ for r in self.graph.all_relations():
1852
+ print(f" {r.source} --{r.type}--> {r.target}")
1853
+
1854
+ # ---------- v0.5: save / load / fork / diff ----------
1855
+
1856
+ def save_state(self, path: Optional[str] = None) -> str:
1857
+ """Persist the event log.
1858
+
1859
+ - With a store already attached: flush (no path needed). If `path` is
1860
+ given it must match the attached store's path.
1861
+ - Without a store: late-bind a SQLite store at `path` and append all
1862
+ in-memory events to it (CONTRACT v0.5 #5).
1863
+ Returns the path the events were written to.
1864
+ """
1865
+ attached = self.graph.store
1866
+ if attached is not None:
1867
+ attached_path = getattr(attached, "path", None)
1868
+ if path is not None and attached_path is not None and path != attached_path:
1869
+ from activegraph.runtime.config_errors import InvalidRuntimeConfiguration
1870
+ raise InvalidRuntimeConfiguration(
1871
+ f"save_state(path={path!r}) — runtime already persisting to {attached_path!r}",
1872
+ what_failed=(
1873
+ f"runtime.save_state(path={path!r}) was called, but "
1874
+ f"this runtime is already persisting to "
1875
+ f"{attached_path!r}. Save targets are pinned at "
1876
+ f"runtime construction; save_state cannot redirect."
1877
+ ),
1878
+ why=(
1879
+ "A runtime's store is its source of truth for the "
1880
+ "event log. Redirecting save mid-run would split the "
1881
+ "log across two stores — replay would only see one "
1882
+ "half. The framework refuses the redirect to keep "
1883
+ "the audit trail consistent."
1884
+ ),
1885
+ how_to_fix=(
1886
+ "To save to the originally-attached store, omit the "
1887
+ "`path=` argument — `save_state()` flushes whatever "
1888
+ "store is attached:\n"
1889
+ " rt.save_state()\n"
1890
+ "\n"
1891
+ "To move a run to a different store, use "
1892
+ "`activegraph migrate` after the run completes:\n"
1893
+ f" activegraph migrate --from sqlite:///{attached_path} "
1894
+ f"--to sqlite:///{path}"
1895
+ ),
1896
+ context={
1897
+ "requested_path": path,
1898
+ "attached_path": attached_path,
1899
+ },
1900
+ )
1901
+ # SQLite autocommit means already durable, but call commit() defensively.
1902
+ conn = getattr(attached, "_conn", None)
1903
+ if conn is not None:
1904
+ try:
1905
+ conn.commit()
1906
+ except Exception:
1907
+ pass
1908
+ return attached_path or "<in-memory>"
1909
+
1910
+ if path is None:
1911
+ from activegraph.runtime.config_errors import InvalidRuntimeConfiguration
1912
+ raise InvalidRuntimeConfiguration(
1913
+ "save_state() requires path= when no store is attached",
1914
+ what_failed=(
1915
+ "runtime.save_state() was called without a `path=` "
1916
+ "argument, but this runtime has no store attached. "
1917
+ "Without either, save_state has nowhere to write."
1918
+ ),
1919
+ why=(
1920
+ "save_state() is the bridge between an in-memory "
1921
+ "runtime and a durable store. It needs either a "
1922
+ "pre-attached store (from Runtime construction) or an "
1923
+ "explicit `path=` argument naming a SQLite file. "
1924
+ "Defaulting to a temp file would silently lose runs "
1925
+ "the next time the process exited."
1926
+ ),
1927
+ how_to_fix=(
1928
+ "Either attach a store at construction time:\n"
1929
+ " rt = Runtime(graph, persist_to='/path/to/run.db')\n"
1930
+ " rt.run_goal('...')\n"
1931
+ " rt.save_state()\n"
1932
+ "or pass a path explicitly:\n"
1933
+ " rt.save_state(path='/path/to/run.db')\n"
1934
+ "\n"
1935
+ "For ephemeral runs that should not persist, omit "
1936
+ "save_state() — the in-memory graph is the run's "
1937
+ "lifetime."
1938
+ ),
1939
+ )
1940
+
1941
+ store = _open_sqlite_store(path, self.graph.run_id)
1942
+ store.upsert_run(
1943
+ created_at=_now_iso(),
1944
+ goal=_first_goal(self.graph),
1945
+ frame_id=self.frame.id if self.frame else None,
1946
+ )
1947
+ # Append everything we've accumulated in memory.
1948
+ for ev in self.graph.events:
1949
+ store.append(ev)
1950
+ self.graph.attach_store(store)
1951
+ return path
1952
+
1953
+ @classmethod
1954
+ def load(
1955
+ cls,
1956
+ path: str,
1957
+ run_id: Optional[str] = None,
1958
+ *,
1959
+ behaviors: Optional[Iterable[Union[Behavior, RelationBehavior]]] = None,
1960
+ frame: Optional[Frame] = None,
1961
+ policy: Optional[Policy] = None,
1962
+ budget: Optional[dict[str, Any]] = None,
1963
+ seed: int = 0,
1964
+ replay_strict: bool = False,
1965
+ llm_provider: Optional[LLMProvider] = None,
1966
+ replay_llm_cache: bool = False,
1967
+ tools: Optional[Iterable[Tool]] = None,
1968
+ replay_tool_cache: bool = False,
1969
+ replay_reinvoke_deterministic: bool = False,
1970
+ metrics: Optional[Metrics] = None,
1971
+ ) -> "Runtime":
1972
+ """Open `path`, choose a run, replay its events, return a Runtime
1973
+ wired to continue from where the log left off.
1974
+
1975
+ If `run_id` is None, loads the most recently appended-to run
1976
+ (CONTRACT v0.5 #6).
1977
+
1978
+ `replay_strict=True` re-fires behaviors from the recorded seed
1979
+ events and compares the resulting event-type stream (id, type) to
1980
+ the log. KNOWN LIMITATION (v0.5): payload-only drift is not
1981
+ detected; see CONTRACT v0.5 #7. Tightens in v0.6 with LLMs.
1982
+
1983
+ v0.8: ``path`` accepts a URL (sqlite:///... or postgres://...)
1984
+ in addition to a bare SQLite path. Backward-compatible.
1985
+ """
1986
+ chosen = run_id or _most_recent_run_id(path)
1987
+ if chosen is None:
1988
+ raise FileNotFoundError(f"no runs found in {path}")
1989
+
1990
+ store = _open_sqlite_store(path, run_id=chosen)
1991
+ graph = Graph(ids=IDGen(), run_id=chosen)
1992
+ events = list(store.iter_events())
1993
+ for ev in events:
1994
+ graph._replay_event(ev) # noqa: SLF001 — internal seam
1995
+ graph.ids.reseed_from_events(events)
1996
+ graph.attach_store(store)
1997
+
1998
+ # If we're caching, harvest llm.responded events from the log so
1999
+ # forks and strict-replay re-runs serve from cache instead of
2000
+ # the API. Safe to construct even when there are no LLM events
2001
+ # (just an empty cache).
2002
+ cache = LLMCache.from_events(events) if replay_llm_cache else None
2003
+ # v0.7: same pattern for the tool cache.
2004
+ from activegraph.tools.cache import ToolCache as _ToolCache
2005
+ tcache = _ToolCache.from_events(events) if replay_tool_cache else None
2006
+
2007
+ rt = cls(
2008
+ graph,
2009
+ behaviors=behaviors,
2010
+ frame=frame,
2011
+ policy=policy,
2012
+ budget=budget,
2013
+ seed=seed,
2014
+ replay_strict=replay_strict,
2015
+ llm_provider=llm_provider,
2016
+ replay_llm_cache=replay_llm_cache,
2017
+ llm_cache=cache,
2018
+ tools=tools,
2019
+ replay_tool_cache=replay_tool_cache,
2020
+ tool_cache=tcache,
2021
+ replay_reinvoke_deterministic=replay_reinvoke_deterministic,
2022
+ metrics=metrics,
2023
+ )
2024
+ # Make sure the run row exists (older files might predate it; in v0.5
2025
+ # they shouldn't, but be defensive).
2026
+ store.upsert_run(created_at=_now_iso())
2027
+
2028
+ # Re-queue events whose behaviors never fired (CONTRACT v0.5 diff #8).
2029
+ # Events that already have a behavior.started referencing them are
2030
+ # left alone — re-firing them would duplicate work. Events that were
2031
+ # in the queue when the runtime stopped (e.g. budget exhausted) get
2032
+ # processed on the next run_until_idle / run_goal call. Behaviors
2033
+ # that started but never completed still lose their in-progress
2034
+ # work — that's the original tradeoff, unchanged.
2035
+ _requeue_unfired(rt, events)
2036
+
2037
+ if replay_strict:
2038
+ _verify_replay(
2039
+ graph,
2040
+ events,
2041
+ behaviors,
2042
+ frame,
2043
+ policy,
2044
+ budget,
2045
+ seed,
2046
+ llm_provider=llm_provider,
2047
+ )
2048
+
2049
+ return rt
2050
+
2051
+ def fork(
2052
+ self,
2053
+ at_event: str,
2054
+ label: Optional[str] = None,
2055
+ *,
2056
+ behaviors: Optional[Iterable[Union[Behavior, RelationBehavior]]] = None,
2057
+ llm_provider: Optional[LLMProvider] = None,
2058
+ replay_llm_cache: bool = False,
2059
+ tools: Optional[Iterable[Tool]] = None,
2060
+ replay_tool_cache: bool = False,
2061
+ replay_reinvoke_deterministic: bool = False,
2062
+ ) -> "Runtime":
2063
+ """Branch this run at `at_event` into an independent new run.
2064
+
2065
+ Requires a SQLite store. Copies events from the parent's log up to
2066
+ and including `at_event` into a fresh `run_id`, replays them into a
2067
+ new Graph, then returns a Runtime that operates on that Graph.
2068
+ Forks-of-forks work the same way (CONTRACT v0.5 #9).
2069
+ """
2070
+ from activegraph.store.sqlite import SQLiteEventStore
2071
+
2072
+ store = self.graph.store
2073
+ if store is None or not isinstance(store, SQLiteEventStore):
2074
+ from activegraph.runtime.config_errors import IncompatibleRuntimeState
2075
+ current_kind = (
2076
+ "no store attached" if store is None else type(store).__name__
2077
+ )
2078
+ raise IncompatibleRuntimeState(
2079
+ f"runtime.fork() requires a SQLite-backed runtime (current: {current_kind})",
2080
+ what_failed=(
2081
+ f"runtime.fork() was called on a runtime with "
2082
+ f"{current_kind}. The fork primitive currently only "
2083
+ f"supports SQLite-backed runtimes."
2084
+ ),
2085
+ why=(
2086
+ "Fork copies events up to the fork point using the "
2087
+ "store's native primitives (SQLite uses a direct SQL "
2088
+ "copy under a single transaction). Postgres has a "
2089
+ "different transactional shape and an in-memory store "
2090
+ "has no copy primitive at all. v0.8 deliberately "
2091
+ "scoped the fork command to SQLite first — the "
2092
+ "limitation is documented in CONTRACT v0.8 #5."
2093
+ ),
2094
+ how_to_fix=(
2095
+ "Migrate the run to a SQLite store first, then fork:\n"
2096
+ " activegraph migrate --from <current-url> --to sqlite:///fork-source.db\n"
2097
+ " activegraph fork sqlite:///fork-source.db --run-id <run> --at-event <evt>\n"
2098
+ "\n"
2099
+ "For Postgres-native forking, file an issue — the "
2100
+ "primitive shape (transactional copy of events up to a "
2101
+ "seq cutoff) is known, and a contributor with Postgres "
2102
+ "operational experience could land it as a v1.1 follow-on."
2103
+ ),
2104
+ context={"current_store_kind": current_kind},
2105
+ )
2106
+
2107
+ new_run_id = self.graph.ids.run()
2108
+ SQLiteEventStore.fork_run(
2109
+ store.path,
2110
+ parent_run_id=self.graph.run_id,
2111
+ new_run_id=new_run_id,
2112
+ at_event_id=at_event,
2113
+ label=label,
2114
+ created_at=_now_iso(),
2115
+ )
2116
+ fork_store = SQLiteEventStore(store.path, run_id=new_run_id)
2117
+ fork_graph = Graph(ids=IDGen(), run_id=new_run_id)
2118
+ events = list(fork_store.iter_events())
2119
+ for ev in events:
2120
+ fork_graph._replay_event(ev) # noqa: SLF001
2121
+ fork_graph.ids.reseed_from_events(events)
2122
+ fork_graph.attach_store(fork_store)
2123
+
2124
+ # Reuse behaviors from the explicit list if the parent had one; else
2125
+ # let the new Runtime fall back to the global registry like v0 does.
2126
+ fork_behaviors = behaviors
2127
+ if fork_behaviors is None and self._explicit_behaviors is not None:
2128
+ fork_behaviors = list(self._explicit_behaviors)
2129
+
2130
+ # Cache is populated from the PARENT's recorded llm.responded
2131
+ # events (not the fork's, which only contains events up to and
2132
+ # including at_event). A diverging fork that regenerates an
2133
+ # identical prompt will hit the cache; a divergent prompt will
2134
+ # fall through to the provider (CONTRACT v0.6 #8).
2135
+ cache = (
2136
+ LLMCache.from_events(self.graph.events) if replay_llm_cache else None
2137
+ )
2138
+ # v0.7: tool cache pre-populated from the parent's events.
2139
+ from activegraph.tools.cache import ToolCache as _ToolCache
2140
+ tcache = (
2141
+ _ToolCache.from_events(self.graph.events)
2142
+ if replay_tool_cache
2143
+ else None
2144
+ )
2145
+
2146
+ rt = Runtime(
2147
+ fork_graph,
2148
+ behaviors=fork_behaviors,
2149
+ frame=self.frame,
2150
+ policy=self.policy,
2151
+ budget=None, # fresh budget for the fork
2152
+ seed=0,
2153
+ llm_provider=llm_provider if llm_provider is not None else self.llm_provider,
2154
+ replay_llm_cache=replay_llm_cache,
2155
+ llm_cache=cache,
2156
+ tools=tools,
2157
+ replay_tool_cache=replay_tool_cache,
2158
+ tool_cache=tcache,
2159
+ replay_reinvoke_deterministic=replay_reinvoke_deterministic,
2160
+ )
2161
+ # CONTRACT v0.5 diff #8 (extended to fork in v0.6): events whose
2162
+ # behaviors never started get re-queued. For a fork at an early
2163
+ # event (e.g. goal.created) this is how downstream behaviors fire
2164
+ # on the fork — without it, fork-then-run-until-idle would be a
2165
+ # no-op when there's no new seed event.
2166
+ _requeue_unfired(rt, events)
2167
+ return rt
2168
+
2169
+ def diff(self, other: "Runtime") -> Diff:
2170
+ return compute_diff(self.graph, other.graph, self.run_id, other.run_id)
2171
+
2172
+
2173
+ # ---------- helpers ----------
2174
+
2175
+
2176
+ _BUDGET_REASON_MAP = {
2177
+ "max_tool_calls": "budget.tool_calls_exhausted",
2178
+ "max_cost_usd": "budget.cost_exhausted",
2179
+ "max_llm_calls": "budget.llm_calls_exhausted",
2180
+ }
2181
+
2182
+
2183
+ def _budget_reason(name: Optional[str]) -> str:
2184
+ if name is None:
2185
+ return "budget.exhausted"
2186
+ return _BUDGET_REASON_MAP.get(name, f"budget.{name.removeprefix('max_')}_exhausted")
2187
+
2188
+
2189
+ def _hash_turn_prompt(
2190
+ *,
2191
+ prompt,
2192
+ messages: list,
2193
+ tool_defs: Optional[list],
2194
+ ) -> str:
2195
+ """Hash the prompt+messages+tools for a single turn.
2196
+
2197
+ Used as the LLM cache key per-turn (CONTRACT v0.7 per-turn cache
2198
+ decision). Includes the running messages list so each turn in a
2199
+ tool loop produces a distinct hash; same shape as v0.6's
2200
+ prompt.hash() otherwise.
2201
+ """
2202
+ import hashlib
2203
+ import json as _json
2204
+
2205
+ payload = {
2206
+ "model": prompt.model,
2207
+ "system": prompt.system,
2208
+ "messages": [m.to_dict() for m in messages],
2209
+ "output_schema_name": prompt.output_schema_name,
2210
+ "output_schema_json": prompt.output_schema_json,
2211
+ "max_tokens": int(prompt.max_tokens),
2212
+ "temperature": float(prompt.temperature),
2213
+ "top_p": float(prompt.top_p),
2214
+ "deterministic": bool(prompt.deterministic),
2215
+ "tools": list(tool_defs) if tool_defs else None,
2216
+ }
2217
+ canonical = _json.dumps(payload, sort_keys=True, separators=(",", ":"))
2218
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
2219
+
2220
+
2221
+ def _maybe_object_id(event: Event) -> Optional[str]:
2222
+ obj = event.payload.get("object") if isinstance(event.payload, dict) else None
2223
+ if isinstance(obj, dict):
2224
+ return obj.get("id")
2225
+ return None
2226
+
2227
+
2228
+ def _now_iso() -> str:
2229
+ return (
2230
+ datetime.now(tz=timezone.utc)
2231
+ .isoformat(timespec="seconds")
2232
+ .replace("+00:00", "Z")
2233
+ )
2234
+
2235
+
2236
+ def _first_goal(graph: Graph) -> Optional[str]:
2237
+ for e in graph.events:
2238
+ if e.type == "goal.created":
2239
+ return e.payload.get("goal")
2240
+ return None
2241
+
2242
+
2243
+ def _most_recent_run_id(path_or_url: str) -> Optional[str]:
2244
+ """Backend-aware most_recent_run_id used by Runtime.load."""
2245
+ if "://" in path_or_url:
2246
+ from activegraph.store.url import parse_store_url
2247
+
2248
+ parsed = parse_store_url(path_or_url)
2249
+ if parsed.scheme == "postgres":
2250
+ from activegraph.store.postgres import PostgresEventStore
2251
+
2252
+ return PostgresEventStore.most_recent_run_id(parsed.raw)
2253
+ # sqlite via URL — fall through to the SQLite helper on the path
2254
+ path_or_url = parsed.sqlite_path or ""
2255
+ from activegraph.store.sqlite import SQLiteEventStore
2256
+
2257
+ return SQLiteEventStore.most_recent_run_id(path_or_url)
2258
+
2259
+
2260
+ def _open_sqlite_store(path_or_url: str, run_id: str):
2261
+ """Open a store by path (v0.5-v0.7 sugar) or URL (v0.8).
2262
+
2263
+ A bare path like ``/tmp/run.db`` is treated as a SQLite path —
2264
+ preserves backward compatibility with all existing call sites.
2265
+ Anything containing ``://`` is parsed as a connection URL.
2266
+ """
2267
+ if "://" in path_or_url:
2268
+ from activegraph.store import open_store
2269
+
2270
+ return open_store(path_or_url, run_id=run_id)
2271
+ from activegraph.store.sqlite import SQLiteEventStore
2272
+
2273
+ return SQLiteEventStore(path_or_url, run_id=run_id)
2274
+
2275
+
2276
+ def _requeue_unfired(rt: "Runtime", events: list[Event]) -> None:
2277
+ """Push events that haven't yet triggered any behavior back into the queue.
2278
+
2279
+ See CONTRACT v0.5 diff #8 in CONTRACT.md for the rationale.
2280
+
2281
+ INVARIANT: under the single-threaded, run-to-completion loop
2282
+ (CONTRACT #10), an event has either been popped — in which case ALL
2283
+ matching behaviors have already had behavior.started emitted on it, or
2284
+ the runtime crashed before the loop could pop the next event. There is
2285
+ no partial-fanout state.
2286
+
2287
+ The naive inference "no behavior.started ever referenced this event id"
2288
+ ⟹ "this event was still in the queue when the runtime stopped" is false:
2289
+ an event with zero matching subscribers is popped-and-discarded with no
2290
+ behavior.started emitted. In a real run, the majority of events
2291
+ (llm.requested, llm.responded, tool.requested, tool.responded,
2292
+ relation.created, patch.applied, downstream object.created) have no
2293
+ subscribers and would be falsely requeued. The bug surface is
2294
+ `runtime.status().queue_depth` reading e.g. 416 on a freshly loaded
2295
+ cleanly-drained run (CONTRACT v1.0 user-test finding C3).
2296
+
2297
+ The fix uses `runtime.idle` as the high-water mark. The runtime emits
2298
+ `runtime.idle` only after the queue empties (see `_emit_idle_or_exhausted`);
2299
+ every event at or before the last `runtime.idle` has by definition been
2300
+ popped. Only events emitted after the last `runtime.idle` are candidates
2301
+ for "still in queue when the runtime stopped."
2302
+
2303
+ `runtime.budget_exhausted` is NOT a drain marker. It fires when the
2304
+ budget hits while the queue may still have events; those un-popped
2305
+ events are exactly the resume-recovery cases v0.5 #8 was designed for.
2306
+ Using `runtime.budget_exhausted` as a high-water mark would break
2307
+ budget-bounded pause-and-resume (a documented v0.5 contract surface).
2308
+
2309
+ When v1 introduces parallelism (decision #16: out of scope for v0.5),
2310
+ this still holds — `runtime.idle` fires only when the queue is empty
2311
+ regardless of loop concurrency. The double-fire-from-partial-fanout
2312
+ concern documented in the original v0.5 invariant is still real for
2313
+ a crash mid-fanout, but that's bounded to events after the last idle,
2314
+ not the whole log.
2315
+ """
2316
+ # Find the highest index of a runtime.idle event. Events at or before
2317
+ # that index were necessarily processed-and-drained; only the suffix
2318
+ # can hold unprocessed events.
2319
+ drain_idx: int = -1
2320
+ for i, e in enumerate(events):
2321
+ if e.type == "runtime.idle":
2322
+ drain_idx = i
2323
+ suffix = events[drain_idx + 1:]
2324
+ if not suffix:
2325
+ return
2326
+ fired_on: set[str] = set()
2327
+ for e in suffix:
2328
+ if (
2329
+ e.type.startswith("behavior.")
2330
+ or e.type.startswith("relation_behavior.")
2331
+ ):
2332
+ eid = e.payload.get("event_id") if isinstance(e.payload, dict) else None
2333
+ if eid:
2334
+ fired_on.add(eid)
2335
+ for e in suffix:
2336
+ if _is_lifecycle(e):
2337
+ continue
2338
+ if e.id in fired_on:
2339
+ continue
2340
+ rt._queue.push(e) # noqa: SLF001 — internal seam by design
2341
+ if rt._queue:
2342
+ rt._idle_emitted = False
2343
+
2344
+
2345
+ # ---------- replay strictness (CONTRACT v0.5 #7) ----------
2346
+
2347
+
2348
+ def _verify_replay(
2349
+ loaded_graph: Graph,
2350
+ recorded_events: list[Event],
2351
+ behaviors: Optional[Iterable[Union[Behavior, RelationBehavior]]],
2352
+ frame: Optional[Frame],
2353
+ policy: Optional[Policy],
2354
+ budget: Optional[dict[str, Any]],
2355
+ seed: int,
2356
+ *,
2357
+ llm_provider: Optional[LLMProvider] = None,
2358
+ ) -> None:
2359
+ """Replay the run from scratch — fresh Graph, behaviors fire — and
2360
+ compare the resulting event stream to the recorded log.
2361
+
2362
+ On the first mismatch (id or type), raise ReplayDivergenceError.
2363
+
2364
+ CONTRACT v0.6: replay_strict implies cache-on for verification
2365
+ regardless of replay_llm_cache — otherwise we'd hit the live API.
2366
+ A live-rebuilt prompt whose hash doesn't match the recorded one is
2367
+ itself a divergence (decision-2 adjustment).
2368
+ """
2369
+
2370
+ # Seed events: everything with no caused_by (goal.created, user-emitted
2371
+ # bootstrap events). Behaviors re-derive everything else.
2372
+ seed_events = [e for e in recorded_events if e.caused_by is None and not _is_lifecycle(e)]
2373
+ if not seed_events:
2374
+ return # nothing to verify
2375
+
2376
+ # Pre-populate the cache from recorded llm.responded events and
2377
+ # extract the sequence of expected prompt hashes (in the order the
2378
+ # behaviors fired them) so we can pin divergence at the right
2379
+ # event id.
2380
+ expected_hashes = [
2381
+ e.payload.get("prompt_hash")
2382
+ for e in recorded_events
2383
+ if e.type == "llm.requested" and e.payload.get("prompt_hash")
2384
+ ]
2385
+ cache = LLMCache.from_events(recorded_events)
2386
+
2387
+ fresh = Graph(ids=IDGen(), run_id="verify_" + loaded_graph.run_id)
2388
+ fresh_rt = Runtime(
2389
+ fresh,
2390
+ behaviors=behaviors,
2391
+ frame=frame,
2392
+ policy=policy,
2393
+ budget=budget,
2394
+ seed=seed,
2395
+ llm_provider=llm_provider,
2396
+ replay_llm_cache=True,
2397
+ llm_cache=cache,
2398
+ replay_strict=True,
2399
+ )
2400
+ # Install the expected-hash queue. _invoke_llm pops one per LLM call
2401
+ # and raises ReplayDivergenceError if the live hash differs.
2402
+ fresh_rt._strict_expected_hashes = list(expected_hashes)
2403
+ fresh_rt._ensure_registry()
2404
+
2405
+ # Replay seed events through emit so behaviors fire.
2406
+ for e in seed_events:
2407
+ new_id = fresh.ids.event()
2408
+ replay_ev = Event(
2409
+ id=new_id,
2410
+ type=e.type,
2411
+ payload=dict(e.payload),
2412
+ actor=e.actor,
2413
+ frame_id=e.frame_id,
2414
+ caused_by=None,
2415
+ timestamp=e.timestamp,
2416
+ )
2417
+ fresh.emit(replay_ev)
2418
+ fresh_rt.run_until_idle()
2419
+
2420
+ # Compare type-stream of non-lifecycle events.
2421
+ rec_stream = [(e.id, e.type) for e in recorded_events if not _is_lifecycle(e)]
2422
+ new_stream = [(e.id, e.type) for e in fresh.events if not _is_lifecycle(e)]
2423
+ for i, (rec, new) in enumerate(zip(rec_stream, new_stream)):
2424
+ if rec[1] != new[1]:
2425
+ raise ReplayDivergenceError(
2426
+ event_id=rec[0], expected=rec[1], actual=new[1]
2427
+ )
2428
+ if len(rec_stream) != len(new_stream):
2429
+ # Length mismatch — pin the divergence point.
2430
+ offending = rec_stream[len(new_stream)][0] if len(new_stream) < len(rec_stream) else new_stream[len(rec_stream)][0]
2431
+ expected = rec_stream[len(new_stream)][1] if len(new_stream) < len(rec_stream) else "<no recorded event>"
2432
+ actual = new_stream[len(rec_stream)][1] if len(rec_stream) < len(new_stream) else None
2433
+ raise ReplayDivergenceError(event_id=offending, expected=expected, actual=actual)
2434
+
2435
+
2436
+ def _is_lifecycle(e: Event) -> bool:
2437
+ return (
2438
+ e.type.startswith("behavior.")
2439
+ or e.type.startswith("relation_behavior.")
2440
+ or e.type.startswith("runtime.")
2441
+ )