activegraph 1.0.0rc2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,709 @@
1
+ """Pack loading: conflict detection, namespace prefixing, settings
2
+ injection, prompt manifest emission.
3
+
4
+ This module is the implementation of `Runtime.load_pack(...)`. The
5
+ public surface lives there; this file holds the helpers.
6
+
7
+ Loading order:
8
+ 1. Idempotency check: same (name, version) already loaded -> no-op.
9
+ 2. Version conflict check: same name, different version -> raise.
10
+ 3. Build settings instance.
11
+ 4. Pre-emptive conflict detection: scan THIS pack's contributions
12
+ against ALREADY-loaded packs. Raise BEFORE mutating.
13
+ 5. Compute prefixed names.
14
+ 6. Wrap behavior functions with typed-settings injection.
15
+ 7. Attach schemas to the graph (object types + relation types).
16
+ 8. Record policies, tools, behaviors.
17
+ 9. Emit `pack.loaded` event.
18
+
19
+ All of step (4) must happen before any of steps (5)-(8). If a
20
+ conflict is raised mid-mutation, the runtime would be in a partial
21
+ state. The contract guarantees a failed `load_pack` leaves the
22
+ runtime unchanged (CONTRACT v0.9 #6).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import copy
28
+ import inspect
29
+ import json
30
+ from typing import TYPE_CHECKING, Any, Callable, Optional
31
+
32
+ from pydantic import BaseModel, ValidationError
33
+
34
+ from activegraph.behaviors.base import Behavior, LLMBehavior, RelationBehavior
35
+ from activegraph.core.event import Event
36
+ from activegraph.packs import (
37
+ EmptySettings,
38
+ Pack,
39
+ PackConflictError,
40
+ PackError,
41
+ PackSchemaViolation,
42
+ PackSettingsMissingError,
43
+ PackVersionConflictError,
44
+ PendingApproval,
45
+ )
46
+ from activegraph.tools.base import Tool
47
+
48
+ if TYPE_CHECKING:
49
+ from activegraph.runtime.runtime import Runtime
50
+
51
+
52
+ def load_pack_into_runtime(
53
+ rt: "Runtime",
54
+ pack: Pack,
55
+ settings: Optional[BaseModel] = None,
56
+ ) -> bool:
57
+ """Implementation of `Runtime.load_pack`. Returns True if the pack
58
+ was newly loaded, False if it was already loaded (idempotency).
59
+ """
60
+ # ---- 1. idempotency ---------------------------------------------------
61
+ state = _ensure_pack_state(rt)
62
+ existing = state.loaded_packs.get(pack.name)
63
+ if existing is not None:
64
+ if existing.version == pack.version:
65
+ return False # idempotent no-op
66
+ raise PackVersionConflictError(
67
+ f"pack {pack.name!r}: already loaded version {existing.version!r}, attempted to load version {pack.version!r}",
68
+ what_failed=(
69
+ f"runtime.load_pack({pack.name!r}, version={pack.version!r}) "
70
+ f"was rejected because the runtime already holds "
71
+ f"{pack.name!r} version {existing.version!r}."
72
+ ),
73
+ why=(
74
+ "A runtime can hold at most one version of any pack. "
75
+ "Two versions would compete for the same canonical names "
76
+ "in the registry — `pack.behavior_name` would resolve "
77
+ "differently depending on dispatch order, which would "
78
+ "silently corrupt the audit trail."
79
+ ),
80
+ how_to_fix=(
81
+ f"Pick one version. If you need both behaviors, the older "
82
+ f"version's namespace can be retained under a renamed pack: "
83
+ f"copy the pack, change its `name=` declaration, and load "
84
+ f"both. The two versions then have distinct canonical "
85
+ f"namespaces.\n"
86
+ f"\n"
87
+ f"To unload the current version and load the new one, "
88
+ f"construct a fresh Runtime — load_pack does not support "
89
+ f"version swapping in place."
90
+ ),
91
+ context={
92
+ "pack": pack.name,
93
+ "loaded_version": existing.version,
94
+ "attempted_version": pack.version,
95
+ },
96
+ )
97
+
98
+ # ---- 2. settings ------------------------------------------------------
99
+ settings_obj = _build_settings(pack, settings)
100
+
101
+ # ---- 3. pre-emptive conflict detection (no mutation yet) -------------
102
+ pack_behavior_names = {b.name for b in pack.behaviors}
103
+ pack_tool_names = {t.name for t in pack.tools}
104
+ pack_object_type_names = {ot.name for ot in pack.object_types}
105
+ pack_relation_type_names = {rt_.name for rt_ in pack.relation_types}
106
+ pack_policy_names = {p.name for p in pack.policies}
107
+
108
+ # Canonical (prefixed) names this pack will register
109
+ new_canonical_behaviors = {f"{pack.name}.{n}": n for n in pack_behavior_names}
110
+ new_canonical_tools = {f"{pack.name}.{n}": n for n in pack_tool_names}
111
+ new_canonical_policies = {f"{pack.name}.{n}": n for n in pack_policy_names}
112
+
113
+ for canonical in new_canonical_behaviors:
114
+ if canonical in state.behavior_owners:
115
+ owner = state.behavior_owners[canonical]
116
+ raise PackConflictError(
117
+ f"behavior name conflict: {canonical!r} declared by both pack {owner!r} and pack {pack.name!r}",
118
+ what_failed=(
119
+ f"runtime.load_pack({pack.name!r}) was rejected: the "
120
+ f"behavior name {canonical!r} is already registered by "
121
+ f"pack {owner!r}."
122
+ ),
123
+ why=(
124
+ "Canonical names in the runtime registry are unique "
125
+ "across loaded packs. Two packs claiming the same "
126
+ "canonical name would silently route dispatch one way "
127
+ "or the other depending on pack-load order; the runtime "
128
+ "refuses the load instead so the conflict is visible "
129
+ "and the operator decides which pack to keep."
130
+ ),
131
+ how_to_fix=(
132
+ f"One of three actions:\n"
133
+ f" 1. Don't load both packs in the same runtime — pick one.\n"
134
+ f" 2. Rename one pack: copy its source, change the\n"
135
+ f" `Pack(name=...)` declaration, re-install, and load\n"
136
+ f" under the new name. The behaviors are then under\n"
137
+ f" a different canonical prefix.\n"
138
+ f" 3. If both behaviors should run, the second pack's\n"
139
+ f" pyproject can re-export the behavior under a\n"
140
+ f" different name within its declaration."
141
+ ),
142
+ context={
143
+ "kind": "behavior",
144
+ "canonical": canonical,
145
+ "owner_pack": owner,
146
+ "conflicting_pack": pack.name,
147
+ },
148
+ )
149
+ for canonical in new_canonical_tools:
150
+ if canonical in state.tool_owners:
151
+ owner = state.tool_owners[canonical]
152
+ raise PackConflictError(
153
+ f"tool name conflict: {canonical!r} declared by both pack {owner!r} and pack {pack.name!r}",
154
+ what_failed=(
155
+ f"runtime.load_pack({pack.name!r}) was rejected: the "
156
+ f"tool name {canonical!r} is already registered by pack "
157
+ f"{owner!r}."
158
+ ),
159
+ why=(
160
+ "Tool canonical names are unique across loaded packs "
161
+ "for the same reason behavior names are: silent "
162
+ "dispatch routing would let an @llm_behavior call the "
163
+ "wrong pack's tool, with a potentially-different "
164
+ "input/output schema. Refusing the load surfaces the "
165
+ "conflict at registration time."
166
+ ),
167
+ how_to_fix=(
168
+ f"Same as behavior conflicts (above):\n"
169
+ f" 1. Pick one pack.\n"
170
+ f" 2. Rename one pack to namespace its tools.\n"
171
+ f" 3. Use a separate runtime per pack if both must run."
172
+ ),
173
+ context={
174
+ "kind": "tool",
175
+ "canonical": canonical,
176
+ "owner_pack": owner,
177
+ "conflicting_pack": pack.name,
178
+ },
179
+ )
180
+ for type_name in pack_object_type_names:
181
+ if type_name in state.object_type_owners:
182
+ owner = state.object_type_owners[type_name]
183
+ raise PackConflictError(
184
+ f"object type conflict: {type_name!r} is already provided by "
185
+ f"pack {owner!r}; pack {pack.name!r} also declares it"
186
+ )
187
+ for type_name in pack_relation_type_names:
188
+ if type_name in state.relation_type_owners:
189
+ owner = state.relation_type_owners[type_name]
190
+ raise PackConflictError(
191
+ f"relation type conflict: {type_name!r} is already provided by "
192
+ f"pack {owner!r}; pack {pack.name!r} also declares it"
193
+ )
194
+ for canonical in new_canonical_policies:
195
+ if canonical in state.policy_owners:
196
+ owner = state.policy_owners[canonical]
197
+ raise PackConflictError(
198
+ f"policy name conflict: {canonical!r} is already provided by "
199
+ f"pack {owner!r}; pack {pack.name!r} also declares it"
200
+ )
201
+
202
+ # Detect short-name ambiguity AGAINST other packs' short names.
203
+ # Same short name across two packs is allowed structurally — the
204
+ # canonical names differ. But the short-name lookup table needs to
205
+ # mark this short name as ambiguous so unqualified lookups raise.
206
+ pre_ambiguous_behaviors = _compute_new_ambiguous_shorts(
207
+ state.behavior_short_to_canonical, new_canonical_behaviors
208
+ )
209
+ pre_ambiguous_tools = _compute_new_ambiguous_shorts(
210
+ state.tool_short_to_canonical, new_canonical_tools
211
+ )
212
+
213
+ # Verify globally-exported tool short names don't collide with
214
+ # existing global tools or other packs' globally-exported tools.
215
+ # The runtime's `tool_registry` is rebuilt on every `_ensure_registry`
216
+ # so we check the actual sources (`_pack_tools` + global @tool
217
+ # registry) here.
218
+ from activegraph.tools.decorators import get_tool_registry as _global_tool_registry
219
+ if any(getattr(t, "_export_globally", False) for t in pack.tools):
220
+ existing_globals = {t.name for t in _global_tool_registry()}
221
+ existing_pack_globals = {
222
+ (getattr(t, "_short_name", None) or t.name)
223
+ for t in rt._pack_tools
224
+ if getattr(t, "_export_globally", False)
225
+ }
226
+ for t in pack.tools:
227
+ if not getattr(t, "_export_globally", False):
228
+ continue
229
+ if t.name in existing_globals or t.name in existing_pack_globals:
230
+ raise PackConflictError(
231
+ f"pack {pack.name!r} declares tool {t.name!r} with "
232
+ f"export_globally=True, but a tool by that name is "
233
+ f"already registered globally"
234
+ )
235
+
236
+ # ---- 4. mutate. From here on we MUST succeed or the runtime is
237
+ # in a partial state. The remaining operations are all in-memory
238
+ # dict insertions plus one event emission.
239
+
240
+ state.loaded_packs[pack.name] = pack
241
+ state.pack_settings[pack.name] = settings_obj
242
+
243
+ # behaviors: wrap with typed-settings injection, rename to canonical
244
+ canonical_to_pack_behavior: dict[str, Any] = {}
245
+ for b in pack.behaviors:
246
+ canonical = f"{pack.name}.{b.name}"
247
+ wrapped = _wrap_behavior_for_pack(b, pack, settings_obj)
248
+ # The wrapped object is a fresh Behavior/LLMBehavior/RelationBehavior
249
+ # with `name = canonical`.
250
+ canonical_to_pack_behavior[canonical] = wrapped
251
+ state.behavior_owners[canonical] = pack.name
252
+ _add_short_name(state.behavior_short_to_canonical, b.name, canonical)
253
+
254
+ # tools: rename to canonical, hold for merge into tool_registry
255
+ # by `_ensure_registry` (which rebuilds the registry from scratch
256
+ # on each call).
257
+ for t in pack.tools:
258
+ canonical = f"{pack.name}.{t.name}"
259
+ renamed = _rename_tool(t, canonical)
260
+ rt._pack_tools.append(renamed)
261
+ state.tool_owners[canonical] = pack.name
262
+ _add_short_name(state.tool_short_to_canonical, t.name, canonical)
263
+
264
+ # object types: attach schemas to graph for validation
265
+ for ot in pack.object_types:
266
+ state.object_type_owners[ot.name] = pack.name
267
+ state.object_type_schemas[ot.name] = ot.schema
268
+ # relation types
269
+ for rt_ in pack.relation_types:
270
+ state.relation_type_owners[rt_.name] = pack.name
271
+ state.relation_type_specs[rt_.name] = rt_
272
+ # policies
273
+ for p in pack.policies:
274
+ canonical = f"{pack.name}.{p.name}"
275
+ state.policy_owners[canonical] = pack.name
276
+ for type_name in p.requires_approval:
277
+ state.gated_object_types.setdefault(type_name, []).append(canonical)
278
+
279
+ # Insert behaviors into the runtime's effective registry. The
280
+ # Runtime treats `_pack_behaviors` as an additional source merged
281
+ # at registry-build time (see runtime._ensure_registry).
282
+ rt._pack_behaviors.extend(canonical_to_pack_behavior.values()) # type: ignore[attr-defined]
283
+
284
+ # Force a registry rebuild on the next event so the new behaviors
285
+ # are picked up. Runtime's `_ensure_registry` is the single seam.
286
+ rt.registry = None
287
+
288
+ # If a graph is already attached, install the schema validators
289
+ # NOW so subsequent live add_object calls are gated.
290
+ if rt.graph is not None:
291
+ _install_graph_validators(rt.graph, state)
292
+
293
+ # ---- 5. emit pack.loaded event ---------------------------------------
294
+ payload = _build_pack_loaded_payload(pack, settings_obj)
295
+ rt.graph.emit(
296
+ Event(
297
+ id=rt.graph.ids.event(),
298
+ type="pack.loaded",
299
+ payload=payload,
300
+ actor="runtime",
301
+ frame_id=rt.frame.id if rt.frame else None,
302
+ caused_by=None,
303
+ timestamp=rt.graph.clock.now(),
304
+ )
305
+ )
306
+ return True
307
+
308
+
309
+ # ---------------------------------------------------------------- state
310
+
311
+
312
+ class PackRuntimeState:
313
+ """Per-runtime pack bookkeeping. Lives on Runtime under
314
+ `_pack_state` (initialized lazily by `_ensure_pack_state`).
315
+ """
316
+
317
+ def __init__(self) -> None:
318
+ self.loaded_packs: dict[str, Pack] = {}
319
+ self.pack_settings: dict[str, BaseModel] = {}
320
+ # canonical name -> pack name
321
+ self.behavior_owners: dict[str, str] = {}
322
+ self.tool_owners: dict[str, str] = {}
323
+ self.policy_owners: dict[str, str] = {}
324
+ self.object_type_owners: dict[str, str] = {}
325
+ self.relation_type_owners: dict[str, str] = {}
326
+ # short name -> canonical name OR sentinel "<<AMBIGUOUS>>"
327
+ self.behavior_short_to_canonical: dict[str, str] = {}
328
+ self.tool_short_to_canonical: dict[str, str] = {}
329
+ # schema registry: object type name -> Pydantic class
330
+ self.object_type_schemas: dict[str, type] = {}
331
+ # relation type name -> RelationType (for source/target validation)
332
+ self.relation_type_specs: dict[str, Any] = {}
333
+ # object type -> [canonical policy names that gate it]
334
+ self.gated_object_types: dict[str, list[str]] = {}
335
+ # pending approvals
336
+ self.pending_approvals: list[PendingApproval] = []
337
+ self._next_approval_n: int = 1
338
+
339
+
340
+ def _ensure_pack_state(rt: "Runtime") -> PackRuntimeState:
341
+ """Initialize `rt._pack_state` if not present and return it."""
342
+ if not hasattr(rt, "_pack_state") or rt._pack_state is None: # type: ignore[attr-defined]
343
+ rt._pack_state = PackRuntimeState() # type: ignore[attr-defined]
344
+ # Also ensure `_pack_behaviors` exists for registry merging.
345
+ if not hasattr(rt, "_pack_behaviors"):
346
+ rt._pack_behaviors = [] # type: ignore[attr-defined]
347
+ return rt._pack_state # type: ignore[attr-defined]
348
+
349
+
350
+ AMBIGUOUS = "<<AMBIGUOUS>>"
351
+
352
+
353
+ def _add_short_name(table: dict[str, str], short: str, canonical: str) -> None:
354
+ """Insert `short -> canonical` unless `short` is already mapped to
355
+ a DIFFERENT canonical, in which case mark as ambiguous.
356
+ """
357
+ existing = table.get(short)
358
+ if existing is None:
359
+ table[short] = canonical
360
+ elif existing != canonical and existing != AMBIGUOUS:
361
+ table[short] = AMBIGUOUS
362
+
363
+
364
+ def _compute_new_ambiguous_shorts(
365
+ table: dict[str, str], new_canonicals: dict[str, str]
366
+ ) -> set[str]:
367
+ out: set[str] = set()
368
+ for canonical, short in new_canonicals.items():
369
+ if short in table and table[short] != canonical:
370
+ out.add(short)
371
+ return out
372
+
373
+
374
+ # ---------------------------------------------------------------- settings
375
+
376
+
377
+ def _build_settings(pack: Pack, settings: Optional[BaseModel]) -> BaseModel:
378
+ """Validate the user-supplied settings against pack.settings_schema."""
379
+ if settings is None:
380
+ try:
381
+ return pack.settings_schema()
382
+ except ValidationError as e:
383
+ raise PackSettingsMissingError(
384
+ f"pack {pack.name!r}: settings_schema {pack.settings_schema.__name__!r} "
385
+ f"requires values but `runtime.load_pack(...)` was called without "
386
+ f"settings=. Underlying error: {e}"
387
+ ) from e
388
+ if not isinstance(settings, pack.settings_schema):
389
+ # Allow dict-shaped settings to be coerced.
390
+ if isinstance(settings, dict):
391
+ try:
392
+ return pack.settings_schema(**settings)
393
+ except ValidationError as e:
394
+ raise PackSettingsMissingError(
395
+ f"pack {pack.name!r}: settings dict failed validation: {e}"
396
+ ) from e
397
+ raise PackSettingsMissingError(
398
+ f"pack {pack.name!r}: settings must be a {pack.settings_schema.__name__} "
399
+ f"instance (got {type(settings).__name__})"
400
+ )
401
+ return settings
402
+
403
+
404
+ # ---------------------------------------------------------------- wrap behavior
405
+
406
+
407
+ def _wrap_behavior_for_pack(b: Any, pack: Pack, settings_obj: BaseModel):
408
+ """Return a fresh Behavior/LLMBehavior/RelationBehavior whose `name`
409
+ is canonical and whose `fn`/`handler` has typed-settings injection
410
+ applied.
411
+
412
+ We do NOT mutate the original Behavior (the user kept a reference
413
+ in their pack module). Frozen pack contents stay untouched.
414
+ """
415
+ canonical = f"{pack.name}.{b.name}"
416
+ if isinstance(b, LLMBehavior):
417
+ new_handler = _wrap_with_injection(b.handler, settings_obj, pack)
418
+ new_b = LLMBehavior(
419
+ name=canonical,
420
+ fn=b.fn, # the placeholder; runtime uses handler, not fn
421
+ on=list(b.on),
422
+ where=dict(b.where) if b.where else None,
423
+ view_spec=dict(b.view_spec) if b.view_spec else None,
424
+ creates=list(b.creates),
425
+ budget=dict(b.budget) if b.budget else None,
426
+ priority=b.priority,
427
+ pattern=b.pattern,
428
+ pattern_matcher=b.pattern_matcher,
429
+ activate_after=b.activate_after,
430
+ handler=new_handler,
431
+ description=_resolve_description(b, pack),
432
+ model=b.model,
433
+ output_schema=b.output_schema,
434
+ deterministic=b.deterministic,
435
+ max_tokens=b.max_tokens,
436
+ temperature=b.temperature,
437
+ top_p=b.top_p,
438
+ timeout_seconds=b.timeout_seconds,
439
+ prompt_template=_resolve_prompt_template(b, pack),
440
+ tools=_resolve_pack_tool_refs(b.tools, pack),
441
+ max_tool_turns=b.max_tool_turns,
442
+ )
443
+ new_b._pack_local = True # type: ignore[attr-defined]
444
+ new_b._pack_owner = pack.name # type: ignore[attr-defined]
445
+ new_b._short_name = b.name # type: ignore[attr-defined]
446
+ return new_b
447
+ if isinstance(b, RelationBehavior):
448
+ new_fn = _wrap_with_injection(b.fn, settings_obj, pack, kind="relation")
449
+ new_b = RelationBehavior(
450
+ name=canonical,
451
+ fn=new_fn,
452
+ relation_type=b.relation_type,
453
+ on=list(b.on),
454
+ where=dict(b.where) if b.where else None,
455
+ view_spec=dict(b.view_spec) if b.view_spec else None,
456
+ creates=list(b.creates),
457
+ budget=dict(b.budget) if b.budget else None,
458
+ priority=b.priority,
459
+ pattern=b.pattern,
460
+ pattern_matcher=b.pattern_matcher,
461
+ activate_after=b.activate_after,
462
+ )
463
+ new_b._pack_local = True # type: ignore[attr-defined]
464
+ new_b._pack_owner = pack.name # type: ignore[attr-defined]
465
+ new_b._short_name = b.name # type: ignore[attr-defined]
466
+ return new_b
467
+ # plain Behavior
468
+ new_fn = _wrap_with_injection(b.fn, settings_obj, pack)
469
+ new_b = Behavior(
470
+ name=canonical,
471
+ fn=new_fn,
472
+ on=list(b.on),
473
+ where=dict(b.where) if b.where else None,
474
+ view_spec=dict(b.view_spec) if b.view_spec else None,
475
+ creates=list(b.creates),
476
+ budget=dict(b.budget) if b.budget else None,
477
+ priority=b.priority,
478
+ pattern=b.pattern,
479
+ pattern_matcher=b.pattern_matcher,
480
+ activate_after=b.activate_after,
481
+ )
482
+ new_b._pack_local = True # type: ignore[attr-defined]
483
+ new_b._pack_owner = pack.name # type: ignore[attr-defined]
484
+ new_b._short_name = b.name # type: ignore[attr-defined]
485
+ return new_b
486
+
487
+
488
+ def _resolve_prompt_template(b: LLMBehavior, pack: Pack) -> Optional[str]:
489
+ """Pack prompts are NOT used as `prompt_template` because markdown
490
+ prompts commonly contain literal `{...}` patterns (JSON sketches,
491
+ inline code) that would crash `str.format`. Pack prompts augment
492
+ the behavior's `description` via `_resolve_description` below.
493
+
494
+ This function preserves the developer's explicit prompt_template=
495
+ on the @llm_behavior if they set one — that's their intent and
496
+ the framework respects it.
497
+ """
498
+ return b.prompt_template
499
+
500
+
501
+ def _resolve_description(b: LLMBehavior, pack: Pack) -> str:
502
+ """Compose the behavior's effective description from the decorator's
503
+ `description=` plus the pack's same-named prompt body. The result
504
+ goes into the system prompt under "Role:". The view + event are
505
+ auto-injected into the USER message by the runtime's normal
506
+ prompt assembly path (so the LLM still gets the live graph
507
+ context without us mangling markdown through str.format).
508
+ """
509
+ parts: list[str] = []
510
+ if b.description:
511
+ parts.append(b.description.strip())
512
+ for p in pack.prompts:
513
+ if p.name == b.name:
514
+ parts.append(p.body.strip())
515
+ break
516
+ return "\n\n".join(parts)
517
+
518
+
519
+ def _resolve_pack_tool_refs(tool_refs: list, pack: Pack) -> list:
520
+ """Rename pack-local tool references on this behavior to their
521
+ canonical form. Strings stay as-is — name resolution happens in
522
+ Runtime._ensure_registry via the short-name table.
523
+ """
524
+ out = []
525
+ for t in tool_refs:
526
+ if isinstance(t, Tool) and getattr(t, "_pack_local", False):
527
+ # Try to find the matching pack tool and use its canonical
528
+ # name. We can't actually rename the Tool object here (the
529
+ # canonical version will live in rt.tool_registry); the
530
+ # behavior should reference by name, so substitute.
531
+ short = t.name
532
+ out.append(f"{pack.name}.{short}")
533
+ else:
534
+ out.append(t)
535
+ return out
536
+
537
+
538
+ def _rename_tool(t: Tool, canonical: str) -> Tool:
539
+ """Return a copy of the tool with name=canonical."""
540
+ new_t = Tool(
541
+ name=canonical,
542
+ fn=t.fn,
543
+ description=t.description,
544
+ input_schema=t.input_schema,
545
+ output_schema=t.output_schema,
546
+ cost_per_call=t.cost_per_call,
547
+ timeout_seconds=t.timeout_seconds,
548
+ deterministic=t.deterministic,
549
+ )
550
+ new_t._pack_local = True # type: ignore[attr-defined]
551
+ new_t._short_name = t.name # type: ignore[attr-defined]
552
+ new_t._export_globally = getattr(t, "_export_globally", False) # type: ignore[attr-defined]
553
+ return new_t
554
+
555
+
556
+ def _wrap_with_injection(
557
+ fn: Optional[Callable],
558
+ settings_obj: BaseModel,
559
+ pack: Pack,
560
+ *,
561
+ kind: str = "default",
562
+ ) -> Optional[Callable]:
563
+ """Wrap `fn` so that any extra parameter whose type annotation
564
+ matches `type(settings_obj)` is passed by keyword at call time.
565
+
566
+ The standard signatures are:
567
+ - behavior: (event, graph, ctx, **extras)
568
+ - llm_behavior: (event, graph, ctx, out, **extras)
569
+ - relation_behavior: (relation, event, graph, ctx, **extras)
570
+
571
+ Uses `typing.get_type_hints` (not raw `param.annotation`) so that
572
+ PEP 563 / `from __future__ import annotations` string annotations
573
+ are resolved to the actual classes.
574
+ """
575
+ import typing
576
+ if fn is None:
577
+ return None
578
+ try:
579
+ sig = inspect.signature(fn)
580
+ except (TypeError, ValueError):
581
+ return fn
582
+ # Resolve string annotations to actual classes. `get_type_hints`
583
+ # works when the type is visible in the function's module globals;
584
+ # for tests with locally-scoped classes (inside a test function)
585
+ # we fall back to a name-match against the settings class.
586
+ try:
587
+ hints = typing.get_type_hints(fn)
588
+ except Exception:
589
+ hints = {}
590
+ settings_cls = type(settings_obj)
591
+ settings_cls_name = settings_cls.__name__
592
+ inject_kwargs: dict[str, BaseModel] = {}
593
+ standard_params = (
594
+ {"relation", "event", "graph", "ctx"} if kind == "relation"
595
+ else {"event", "graph", "ctx", "out"}
596
+ )
597
+ for pname, param in sig.parameters.items():
598
+ if pname in standard_params:
599
+ continue
600
+ if param.kind in (
601
+ inspect.Parameter.VAR_POSITIONAL,
602
+ inspect.Parameter.VAR_KEYWORD,
603
+ ):
604
+ continue
605
+ ann = hints.get(pname, param.annotation)
606
+ if ann is inspect.Parameter.empty:
607
+ continue
608
+ # Resolved-class match (the canonical path).
609
+ if isinstance(ann, type) and issubclass(ann, BaseModel) and ann is settings_cls:
610
+ inject_kwargs[pname] = settings_obj
611
+ continue
612
+ # String-annotation fallback for locally-scoped settings classes
613
+ # that `get_type_hints` couldn't resolve. Safe because conflicts
614
+ # would surface as a normal Python runtime error.
615
+ if isinstance(ann, str) and ann == settings_cls_name:
616
+ inject_kwargs[pname] = settings_obj
617
+ if not inject_kwargs:
618
+ return fn
619
+
620
+ def wrapped(*args, **kwargs):
621
+ merged = dict(inject_kwargs)
622
+ merged.update(kwargs)
623
+ return fn(*args, **merged)
624
+
625
+ wrapped.__wrapped__ = fn # type: ignore[attr-defined]
626
+ wrapped.__name__ = getattr(fn, "__name__", "wrapped_pack_behavior")
627
+ wrapped._pack_owner = pack.name # type: ignore[attr-defined]
628
+ return wrapped
629
+
630
+
631
+ # ---------------------------------------------------------------- graph validators
632
+
633
+
634
+ def _install_graph_validators(graph, state: PackRuntimeState) -> None:
635
+ """Attach a schema validator hook on the graph so add_object /
636
+ add_relation validate against loaded pack schemas.
637
+
638
+ The hook is idempotent — calling again with the same state object
639
+ replaces the previous validator.
640
+ """
641
+ graph._pack_object_validator = _make_object_validator(state) # type: ignore[attr-defined]
642
+ graph._pack_relation_validator = _make_relation_validator(state) # type: ignore[attr-defined]
643
+
644
+
645
+ def _make_object_validator(state: PackRuntimeState):
646
+ def _validate(object_type: str, data: dict) -> dict:
647
+ schema = state.object_type_schemas.get(object_type)
648
+ if schema is None:
649
+ return data
650
+ try:
651
+ validated = schema(**data)
652
+ except ValidationError as e:
653
+ pack_name = state.object_type_owners.get(object_type)
654
+ raise PackSchemaViolation.for_object(
655
+ object_type=object_type,
656
+ validation_error=e,
657
+ pack_name=pack_name,
658
+ ) from e
659
+ return validated.model_dump()
660
+ return _validate
661
+
662
+
663
+ def _make_relation_validator(state: PackRuntimeState):
664
+ def _validate(relation_type: str, source_type: Optional[str], target_type: Optional[str]) -> None:
665
+ spec = state.relation_type_specs.get(relation_type)
666
+ if spec is None:
667
+ return
668
+ pack_name = state.relation_type_owners.get(relation_type)
669
+ if spec.source_types and source_type is not None and source_type not in spec.source_types:
670
+ raise PackSchemaViolation.for_relation_source(
671
+ relation_type=relation_type,
672
+ source_type=source_type,
673
+ allowed=list(spec.source_types),
674
+ pack_name=pack_name,
675
+ )
676
+ if spec.target_types and target_type is not None and target_type not in spec.target_types:
677
+ raise PackSchemaViolation.for_relation_target(
678
+ relation_type=relation_type,
679
+ target_type=target_type,
680
+ allowed=list(spec.target_types),
681
+ pack_name=pack_name,
682
+ )
683
+ return _validate
684
+
685
+
686
+ # ---------------------------------------------------------------- pack.loaded payload
687
+
688
+
689
+ def _build_pack_loaded_payload(pack: Pack, settings_obj: BaseModel) -> dict:
690
+ return {
691
+ "name": pack.name,
692
+ "version": pack.version,
693
+ "description": pack.description,
694
+ "object_types": [ot.name for ot in pack.object_types],
695
+ "relation_types": [rt_.name for rt_ in pack.relation_types],
696
+ "behaviors": [f"{pack.name}.{b.name}" for b in pack.behaviors],
697
+ "tools": [f"{pack.name}.{t.name}" for t in pack.tools],
698
+ "policies": [f"{pack.name}.{p.name}" for p in pack.policies],
699
+ "prompts": pack.prompt_manifest(),
700
+ "settings": _canonical_settings_dump(settings_obj),
701
+ }
702
+
703
+
704
+ def _canonical_settings_dump(settings_obj: BaseModel) -> dict:
705
+ """Settings dict, JSON-canonical (sorted keys, no datetime objects)."""
706
+ raw = settings_obj.model_dump(mode="json")
707
+ # Pass through json.loads(json.dumps(..., sort_keys=True, default=str))
708
+ # so the result is byte-stable across runs.
709
+ return json.loads(json.dumps(raw, sort_keys=True, default=str))