activegraph 1.0.0rc2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|