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,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))
|