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,999 @@
|
|
|
1
|
+
"""Pack format. CONTRACT v0.9.
|
|
2
|
+
|
|
3
|
+
A pack is a bundle of object types, relation types, behaviors,
|
|
4
|
+
tools, prompts, and policies for a specific domain. This module
|
|
5
|
+
exposes:
|
|
6
|
+
|
|
7
|
+
- The `Pack` dataclass (frozen, equality by (name, version)).
|
|
8
|
+
- Pack-aware decorators: `@behavior`, `@llm_behavior`,
|
|
9
|
+
`@relation_behavior`, `@tool`. Identical signatures to the
|
|
10
|
+
decorators in `activegraph.*` except they DO NOT register
|
|
11
|
+
globally — a pack module is safe to import without a runtime.
|
|
12
|
+
- `ObjectType`, `RelationType`, `PackPolicy`, `PackPrompt` —
|
|
13
|
+
the value objects that go into a `Pack`.
|
|
14
|
+
- `EmptySettings` — Pydantic placeholder for packs with no
|
|
15
|
+
configurable settings.
|
|
16
|
+
- `load_prompts_from_dir(path)` — helper that scans a directory
|
|
17
|
+
of `.md` files with TOML frontmatter and returns a tuple of
|
|
18
|
+
`PackPrompt` objects with content hashes.
|
|
19
|
+
- `discover()` / `load_by_name(name)` — Python entry point
|
|
20
|
+
discovery (the `activegraph.packs` group).
|
|
21
|
+
- The pack exception hierarchy:
|
|
22
|
+
PackError (root)
|
|
23
|
+
PackValidationError
|
|
24
|
+
PackConflictError
|
|
25
|
+
PackVersionConflictError
|
|
26
|
+
PackSchemaViolation
|
|
27
|
+
PackSettingsMissingError
|
|
28
|
+
PackPromptLoadError
|
|
29
|
+
|
|
30
|
+
The full contract lives in CONTRACT.md v0.9. The pack authoring
|
|
31
|
+
guide is `docs/pack_authoring.md`. The reference implementation is
|
|
32
|
+
`activegraph.packs.diligence`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Callable, Optional, Union
|
|
43
|
+
|
|
44
|
+
# tomllib is stdlib in Python 3.11+. CONTRACT v0.9 #23 raises the
|
|
45
|
+
# Python floor to 3.11 specifically so we don't need to vendor or
|
|
46
|
+
# depend on tomli/tomli-w.
|
|
47
|
+
import tomllib
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from pydantic import BaseModel
|
|
51
|
+
except ImportError as _e: # pragma: no cover — pydantic is a hard dep for packs
|
|
52
|
+
from activegraph.errors import MissingOptionalDependency
|
|
53
|
+
raise MissingOptionalDependency(
|
|
54
|
+
package="pydantic",
|
|
55
|
+
feature="activegraph.packs (the pack format)",
|
|
56
|
+
extras="llm",
|
|
57
|
+
) from _e
|
|
58
|
+
|
|
59
|
+
from activegraph.behaviors.base import (
|
|
60
|
+
Behavior,
|
|
61
|
+
LLMBehavior,
|
|
62
|
+
RelationBehavior,
|
|
63
|
+
_llm_behavior_fn_placeholder,
|
|
64
|
+
)
|
|
65
|
+
from activegraph.tools.base import Tool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------- exceptions
|
|
69
|
+
#
|
|
70
|
+
# v1.0 PR-A re-homed PackError under `activegraph.errors.PackError` as the
|
|
71
|
+
# v1.0 category base.
|
|
72
|
+
#
|
|
73
|
+
# v1.0 PR-E multi-inherits the registration-time leaves with
|
|
74
|
+
# `RegistrationError` so `except RegistrationError` catches them
|
|
75
|
+
# alongside the LLM/tool/Pack-not-found leaves. The runtime-shape leaf
|
|
76
|
+
# (`PackSchemaViolation`) stays PackError-only and migrates in PR-G.
|
|
77
|
+
#
|
|
78
|
+
# The 28 existing `raise PackXError("message")` call sites continue
|
|
79
|
+
# using the legacy single-message ActiveGraphError __init__ branch
|
|
80
|
+
# (format-noncompliant but valid). PR-E migrates a few high-value
|
|
81
|
+
# sites (loader.py PackConflict / PackVersionConflict) to the
|
|
82
|
+
# structured form; the remainder is tracked as a v1.0-rc1 follow-on.
|
|
83
|
+
|
|
84
|
+
from activegraph.errors import (
|
|
85
|
+
MissingOptionalDependency,
|
|
86
|
+
PackError as PackError, # re-export for back-compat
|
|
87
|
+
RegistrationError,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PackNotFoundError(RegistrationError, LookupError):
|
|
92
|
+
"""`activegraph.packs.load_by_name(name)` could not find an installed
|
|
93
|
+
pack with that name in the entry-point registry.
|
|
94
|
+
|
|
95
|
+
Multi-inherits :class:`LookupError` for back-compat with user code
|
|
96
|
+
catching the builtin around pack discovery.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
_doc_slug = "pack-not-found-error"
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
name: str,
|
|
104
|
+
*,
|
|
105
|
+
installed: tuple[str, ...] = (),
|
|
106
|
+
) -> None:
|
|
107
|
+
self.name = name
|
|
108
|
+
self.installed = installed
|
|
109
|
+
ctx: dict[str, Any] = {"name": name}
|
|
110
|
+
if installed:
|
|
111
|
+
ctx["installed"] = list(installed)
|
|
112
|
+
installed_list = (
|
|
113
|
+
", ".join(repr(p) for p in installed)
|
|
114
|
+
if installed
|
|
115
|
+
else "(no packs installed)"
|
|
116
|
+
)
|
|
117
|
+
RegistrationError.__init__(
|
|
118
|
+
self,
|
|
119
|
+
f"no installed pack named {name!r}",
|
|
120
|
+
what_failed=(
|
|
121
|
+
f"activegraph.packs.load_by_name({name!r}) searched the "
|
|
122
|
+
f"`activegraph.packs` entry-point group and found no pack "
|
|
123
|
+
f"with that name.\n installed: {installed_list}"
|
|
124
|
+
),
|
|
125
|
+
why=(
|
|
126
|
+
"Packs register via Python entry points so the framework "
|
|
127
|
+
"can discover them without import-side-effect cost. A "
|
|
128
|
+
"missing pack means either the pack isn't installed "
|
|
129
|
+
"(pip install hasn't run), or the installed pack's "
|
|
130
|
+
"entry-point group is wrong, or the name is a typo."
|
|
131
|
+
),
|
|
132
|
+
how_to_fix=(
|
|
133
|
+
"Confirm the pack is installed:\n"
|
|
134
|
+
f" pip show <pack-distribution-name>\n"
|
|
135
|
+
"\n"
|
|
136
|
+
"List currently-discovered packs:\n"
|
|
137
|
+
" from activegraph.packs import discover\n"
|
|
138
|
+
" [p.name for p in discover()]\n"
|
|
139
|
+
"\n"
|
|
140
|
+
"If the pack is present but not discovered, its "
|
|
141
|
+
"pyproject.toml should declare:\n"
|
|
142
|
+
" [project.entry-points.\"activegraph.packs\"]\n"
|
|
143
|
+
" your_pack = \"your_pack_module:pack\""
|
|
144
|
+
),
|
|
145
|
+
context=ctx,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PackValidationError(RegistrationError, PackError):
|
|
150
|
+
"""A `Pack(...)` constructor argument failed validation.
|
|
151
|
+
|
|
152
|
+
Raised at construction time, not at load time. Covers things like
|
|
153
|
+
duplicate behavior names, an invalid pack name, an unhashable
|
|
154
|
+
settings_schema, etc. Multi-inherits RegistrationError (v1.0 PR-E)
|
|
155
|
+
and PackError (v0.9 base).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
_doc_slug = "pack-validation-error"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PackConflictError(RegistrationError, PackError):
|
|
162
|
+
"""Two loaded packs conflict on a declared identifier.
|
|
163
|
+
|
|
164
|
+
Raised at `runtime.load_pack` time. Pre-mutation: a failed
|
|
165
|
+
`load_pack` call leaves the runtime exactly as it was.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
_doc_slug = "pack-conflict-error"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PackVersionConflictError(RegistrationError, PackError):
|
|
172
|
+
"""Same pack name loaded with two different versions.
|
|
173
|
+
|
|
174
|
+
A runtime cannot hold two versions of the same pack. Pre-mutation,
|
|
175
|
+
same as `PackConflictError`.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
_doc_slug = "pack-version-conflict-error"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PackSchemaViolation(PackError, ValueError):
|
|
182
|
+
"""`graph.add_object` or `graph.add_relation` data failed schema
|
|
183
|
+
validation against a loaded pack's declared type.
|
|
184
|
+
|
|
185
|
+
Runtime-shape error (fires at add_object / add_relation, not at
|
|
186
|
+
pack load), so stays under PackError only — this is the lone
|
|
187
|
+
runtime-shape leaf in the PackError category. Subclass of
|
|
188
|
+
:class:`ValueError` so user code catching the builtin around graph
|
|
189
|
+
mutations continues to work.
|
|
190
|
+
|
|
191
|
+
v1.0 PR-G migrated to structured format. Three call sites are
|
|
192
|
+
served by three factory class methods (object validation,
|
|
193
|
+
relation source-type, relation target-type) so the recovery prose
|
|
194
|
+
can be specific to each shape. Direct construction with the
|
|
195
|
+
structured fields is also supported.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
_doc_slug = "pack-schema-violation"
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def for_object(
|
|
202
|
+
cls,
|
|
203
|
+
*,
|
|
204
|
+
object_type: str,
|
|
205
|
+
validation_error: Any,
|
|
206
|
+
pack_name: Optional[str] = None,
|
|
207
|
+
) -> "PackSchemaViolation":
|
|
208
|
+
"""Object data failed the pack's declared schema validation."""
|
|
209
|
+
pack_clause = (
|
|
210
|
+
f" (declared by pack {pack_name!r})" if pack_name else ""
|
|
211
|
+
)
|
|
212
|
+
return cls(
|
|
213
|
+
f"object_type {object_type!r}: schema validation failed",
|
|
214
|
+
what_failed=(
|
|
215
|
+
f"`graph.add_object({object_type!r}, data=...)` was rejected "
|
|
216
|
+
f"because the data did not match the pack's declared schema "
|
|
217
|
+
f"for {object_type!r}{pack_clause}.\n\n"
|
|
218
|
+
f"Validation error:\n {validation_error}"
|
|
219
|
+
),
|
|
220
|
+
why=(
|
|
221
|
+
"Packs declare object schemas to constrain what shape of data "
|
|
222
|
+
"can flow into objects of that type. The runtime validates "
|
|
223
|
+
"every add_object against the schema so downstream behaviors "
|
|
224
|
+
"can rely on the shape — a malformed add would silently "
|
|
225
|
+
"corrupt views and pattern matches that depend on the "
|
|
226
|
+
"declared fields. CONTRACT v0.9 #4 / #5 (object types are "
|
|
227
|
+
"declared; validation is post-load, not retroactive)."
|
|
228
|
+
),
|
|
229
|
+
how_to_fix=(
|
|
230
|
+
f"Adjust the data to match the schema. Common fixes:\n"
|
|
231
|
+
f" - missing required field: add it to the data dict\n"
|
|
232
|
+
f" - wrong type for a field: convert before passing\n"
|
|
233
|
+
f" - extra field rejected by a strict schema: remove it\n"
|
|
234
|
+
f"\n"
|
|
235
|
+
f"To see the declared schema for {object_type!r}:\n"
|
|
236
|
+
f" from activegraph.packs.diligence import pack as p\n"
|
|
237
|
+
f" next(ot for ot in p.object_types\n"
|
|
238
|
+
f" if ot.name == {object_type!r}).schema.model_json_schema()"
|
|
239
|
+
),
|
|
240
|
+
context={
|
|
241
|
+
"object_type": object_type,
|
|
242
|
+
"pack": pack_name,
|
|
243
|
+
"validation_error": str(validation_error),
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def for_relation_source(
|
|
249
|
+
cls,
|
|
250
|
+
*,
|
|
251
|
+
relation_type: str,
|
|
252
|
+
source_type: str,
|
|
253
|
+
allowed: list[str],
|
|
254
|
+
pack_name: Optional[str] = None,
|
|
255
|
+
) -> "PackSchemaViolation":
|
|
256
|
+
"""Relation source object type isn't in the allowed list."""
|
|
257
|
+
pack_clause = (
|
|
258
|
+
f" (declared by pack {pack_name!r})" if pack_name else ""
|
|
259
|
+
)
|
|
260
|
+
return cls(
|
|
261
|
+
f"relation_type {relation_type!r}: source type {source_type!r} not allowed",
|
|
262
|
+
what_failed=(
|
|
263
|
+
f"`graph.add_relation(source, target, {relation_type!r})` was "
|
|
264
|
+
f"rejected because the source object has type {source_type!r}, "
|
|
265
|
+
f"but the pack's declared relation type for {relation_type!r}"
|
|
266
|
+
f"{pack_clause} only allows source types: "
|
|
267
|
+
f"{', '.join(repr(t) for t in allowed)}."
|
|
268
|
+
),
|
|
269
|
+
why=(
|
|
270
|
+
"Relation types declare which object-type pairs they can "
|
|
271
|
+
"connect. The constraint serves the same purpose as object "
|
|
272
|
+
"schemas: behaviors and pattern subscriptions that follow "
|
|
273
|
+
"this relation rely on the source/target types being what "
|
|
274
|
+
"the pack documented. An out-of-spec relation would cause "
|
|
275
|
+
"pattern matches to silently miss or misfire."
|
|
276
|
+
),
|
|
277
|
+
how_to_fix=(
|
|
278
|
+
f"Either:\n"
|
|
279
|
+
f" - Pass a source of an allowed type "
|
|
280
|
+
f"({', '.join(repr(t) for t in allowed)}); or\n"
|
|
281
|
+
f" - Add {source_type!r} to the relation type's "
|
|
282
|
+
f"`source_types=` list when declaring the pack:\n"
|
|
283
|
+
f" RelationType(\n"
|
|
284
|
+
f" name={relation_type!r},\n"
|
|
285
|
+
f" source_types=[{', '.join(repr(t) for t in [*allowed, source_type])}],\n"
|
|
286
|
+
f" target_types=[...],\n"
|
|
287
|
+
f" )\n"
|
|
288
|
+
f" - Or, if the constraint is wrong, remove `source_types=` "
|
|
289
|
+
f"to accept any source type."
|
|
290
|
+
),
|
|
291
|
+
context={
|
|
292
|
+
"relation_type": relation_type,
|
|
293
|
+
"source_type": source_type,
|
|
294
|
+
"allowed_source_types": list(allowed),
|
|
295
|
+
"pack": pack_name,
|
|
296
|
+
"side": "source",
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def for_relation_target(
|
|
302
|
+
cls,
|
|
303
|
+
*,
|
|
304
|
+
relation_type: str,
|
|
305
|
+
target_type: str,
|
|
306
|
+
allowed: list[str],
|
|
307
|
+
pack_name: Optional[str] = None,
|
|
308
|
+
) -> "PackSchemaViolation":
|
|
309
|
+
"""Relation target object type isn't in the allowed list."""
|
|
310
|
+
pack_clause = (
|
|
311
|
+
f" (declared by pack {pack_name!r})" if pack_name else ""
|
|
312
|
+
)
|
|
313
|
+
return cls(
|
|
314
|
+
f"relation_type {relation_type!r}: target type {target_type!r} not allowed",
|
|
315
|
+
what_failed=(
|
|
316
|
+
f"`graph.add_relation(source, target, {relation_type!r})` was "
|
|
317
|
+
f"rejected because the target object has type {target_type!r}, "
|
|
318
|
+
f"but the pack's declared relation type for {relation_type!r}"
|
|
319
|
+
f"{pack_clause} only allows target types: "
|
|
320
|
+
f"{', '.join(repr(t) for t in allowed)}."
|
|
321
|
+
),
|
|
322
|
+
why=(
|
|
323
|
+
"Same rule as source-type validation: relation types "
|
|
324
|
+
"constrain both endpoints so behaviors and pattern "
|
|
325
|
+
"subscriptions that traverse the relation can rely on the "
|
|
326
|
+
"endpoint shapes. An out-of-spec relation would corrupt "
|
|
327
|
+
"pattern matches downstream."
|
|
328
|
+
),
|
|
329
|
+
how_to_fix=(
|
|
330
|
+
f"Either:\n"
|
|
331
|
+
f" - Pass a target of an allowed type "
|
|
332
|
+
f"({', '.join(repr(t) for t in allowed)}); or\n"
|
|
333
|
+
f" - Add {target_type!r} to the relation type's "
|
|
334
|
+
f"`target_types=` list in the pack declaration; or\n"
|
|
335
|
+
f" - Remove `target_types=` to accept any target type."
|
|
336
|
+
),
|
|
337
|
+
context={
|
|
338
|
+
"relation_type": relation_type,
|
|
339
|
+
"target_type": target_type,
|
|
340
|
+
"allowed_target_types": list(allowed),
|
|
341
|
+
"pack": pack_name,
|
|
342
|
+
"side": "target",
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class PackSettingsMissingError(RegistrationError, PackError):
|
|
348
|
+
"""`runtime.load_pack(pack)` called without `settings=` for a pack
|
|
349
|
+
whose `settings_schema` doesn't accept no-arg construction.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
_doc_slug = "pack-settings-missing-error"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class PackPromptLoadError(RegistrationError, PackError):
|
|
356
|
+
"""A prompt file is malformed, missing required frontmatter, or
|
|
357
|
+
unreadable. Pack registration time.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
_doc_slug = "pack-prompt-load-error"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ----------------------------------------------------- value objects (frozen)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class EmptySettings(BaseModel):
|
|
367
|
+
"""For packs with no configurable settings. Pydantic model so it
|
|
368
|
+
matches the rest of the settings API.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass(frozen=True)
|
|
373
|
+
class ObjectType:
|
|
374
|
+
"""A typed object the pack contributes.
|
|
375
|
+
|
|
376
|
+
`schema` is a Pydantic `BaseModel` subclass. When the pack is
|
|
377
|
+
loaded, `graph.add_object(name, data=...)` validates against it.
|
|
378
|
+
Validation applies only to objects created AFTER the pack loads
|
|
379
|
+
(CONTRACT v0.9 #5).
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
name: str
|
|
383
|
+
schema: type
|
|
384
|
+
description: str = ""
|
|
385
|
+
|
|
386
|
+
def __post_init__(self) -> None:
|
|
387
|
+
if not isinstance(self.name, str) or not self.name:
|
|
388
|
+
raise PackValidationError(f"ObjectType.name must be non-empty str, got {self.name!r}")
|
|
389
|
+
if not (isinstance(self.schema, type) and issubclass(self.schema, BaseModel)):
|
|
390
|
+
raise PackValidationError(
|
|
391
|
+
f"ObjectType {self.name!r}: schema must be a pydantic BaseModel subclass"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass(frozen=True)
|
|
396
|
+
class RelationType:
|
|
397
|
+
"""A typed relation the pack contributes.
|
|
398
|
+
|
|
399
|
+
`source_types` and `target_types` are tuples of object type names;
|
|
400
|
+
empty means "any".
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
name: str
|
|
404
|
+
source_types: tuple[str, ...] = ()
|
|
405
|
+
target_types: tuple[str, ...] = ()
|
|
406
|
+
description: str = ""
|
|
407
|
+
|
|
408
|
+
def __post_init__(self) -> None:
|
|
409
|
+
if not isinstance(self.name, str) or not self.name:
|
|
410
|
+
raise PackValidationError(f"RelationType.name must be non-empty str, got {self.name!r}")
|
|
411
|
+
if isinstance(self.source_types, list):
|
|
412
|
+
object.__setattr__(self, "source_types", tuple(self.source_types))
|
|
413
|
+
if isinstance(self.target_types, list):
|
|
414
|
+
object.__setattr__(self, "target_types", tuple(self.target_types))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class PackPolicy:
|
|
419
|
+
"""A policy declared by a pack.
|
|
420
|
+
|
|
421
|
+
`requires_approval`: tuple of object type names whose `add_object`
|
|
422
|
+
is gated until `runtime.approve(...)` is called.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
name: str
|
|
426
|
+
requires_approval: tuple[str, ...] = ()
|
|
427
|
+
auto_apply: tuple[str, ...] = ()
|
|
428
|
+
|
|
429
|
+
def __post_init__(self) -> None:
|
|
430
|
+
if isinstance(self.requires_approval, list):
|
|
431
|
+
object.__setattr__(self, "requires_approval", tuple(self.requires_approval))
|
|
432
|
+
if isinstance(self.auto_apply, list):
|
|
433
|
+
object.__setattr__(self, "auto_apply", tuple(self.auto_apply))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@dataclass(frozen=True)
|
|
437
|
+
class PackPrompt:
|
|
438
|
+
"""A versioned, content-hashed prompt.
|
|
439
|
+
|
|
440
|
+
`version` is the declared human-readable version (for changelogs
|
|
441
|
+
and operator messages). `content_hash` is the SHA-256 of the body
|
|
442
|
+
truncated to 16 hex chars; this is the **replay contract** (the
|
|
443
|
+
hash, not the version — see CONTRACT v0.9 #10).
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
name: str
|
|
447
|
+
version: str
|
|
448
|
+
body: str
|
|
449
|
+
content_hash: str
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def compute_hash(body: str) -> str:
|
|
453
|
+
h = hashlib.sha256(body.encode("utf-8")).hexdigest()[:16]
|
|
454
|
+
return f"sha256:{h}"
|
|
455
|
+
|
|
456
|
+
@classmethod
|
|
457
|
+
def from_body(cls, name: str, version: str, body: str) -> "PackPrompt":
|
|
458
|
+
return cls(name=name, version=version, body=body, content_hash=cls.compute_hash(body))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def load_prompts_from_dir(path: Union[str, Path]) -> tuple[PackPrompt, ...]:
|
|
462
|
+
"""Scan a directory of `*.md` prompt files with TOML frontmatter.
|
|
463
|
+
|
|
464
|
+
Each file MUST start with:
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
version = "1.0.0"
|
|
468
|
+
name = "optional_name" # defaults to filename without .md
|
|
469
|
+
---
|
|
470
|
+
<body>
|
|
471
|
+
|
|
472
|
+
Returns a tuple of `PackPrompt` sorted by name. Content hash is
|
|
473
|
+
computed over the body (everything after the second `---` line
|
|
474
|
+
and one separating newline), exactly as it will appear at runtime.
|
|
475
|
+
|
|
476
|
+
Errors:
|
|
477
|
+
- missing/malformed frontmatter -> PackPromptLoadError
|
|
478
|
+
- missing required `version` field -> PackPromptLoadError
|
|
479
|
+
- duplicate prompt name -> PackPromptLoadError
|
|
480
|
+
- I/O failure -> PackPromptLoadError
|
|
481
|
+
"""
|
|
482
|
+
p = Path(path)
|
|
483
|
+
if not p.exists():
|
|
484
|
+
raise PackPromptLoadError(f"prompts directory does not exist: {p}")
|
|
485
|
+
if not p.is_dir():
|
|
486
|
+
raise PackPromptLoadError(f"prompts path is not a directory: {p}")
|
|
487
|
+
|
|
488
|
+
out: dict[str, PackPrompt] = {}
|
|
489
|
+
for md_path in sorted(p.glob("*.md")):
|
|
490
|
+
prompt = _load_one_prompt(md_path)
|
|
491
|
+
if prompt.name in out:
|
|
492
|
+
raise PackPromptLoadError(
|
|
493
|
+
f"duplicate prompt name {prompt.name!r} (file: {md_path})"
|
|
494
|
+
)
|
|
495
|
+
out[prompt.name] = prompt
|
|
496
|
+
return tuple(out.values())
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.DOTALL)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _load_one_prompt(path: Path) -> PackPrompt:
|
|
503
|
+
try:
|
|
504
|
+
text = path.read_text(encoding="utf-8")
|
|
505
|
+
except OSError as e:
|
|
506
|
+
raise PackPromptLoadError(f"cannot read {path}: {e}") from e
|
|
507
|
+
m = _FRONTMATTER_RE.match(text)
|
|
508
|
+
if not m:
|
|
509
|
+
raise PackPromptLoadError(
|
|
510
|
+
f"{path}: missing TOML frontmatter (file must start with '---' line)"
|
|
511
|
+
)
|
|
512
|
+
fm_text, body = m.group(1), m.group(2)
|
|
513
|
+
try:
|
|
514
|
+
fm = tomllib.loads(fm_text)
|
|
515
|
+
except tomllib.TOMLDecodeError as e:
|
|
516
|
+
raise PackPromptLoadError(f"{path}: frontmatter is not valid TOML: {e}") from e
|
|
517
|
+
if "version" not in fm:
|
|
518
|
+
raise PackPromptLoadError(f"{path}: frontmatter missing required 'version' key")
|
|
519
|
+
version = str(fm["version"])
|
|
520
|
+
name = str(fm.get("name") or path.stem)
|
|
521
|
+
return PackPrompt.from_body(name=name, version=version, body=body)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ----------------------------------------------------- the Pack itself
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
_PACK_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@dataclass(frozen=True, eq=False)
|
|
531
|
+
class Pack:
|
|
532
|
+
"""A frozen bundle of pack contents.
|
|
533
|
+
|
|
534
|
+
Equality and hashing are by (name, version) — NOT by deep field
|
|
535
|
+
comparison. Behaviors and tools are dataclasses (not hashable);
|
|
536
|
+
full structural equality would not work and isn't what users
|
|
537
|
+
care about. The identity that matters is "is this the same pack
|
|
538
|
+
name and version" — that's what idempotent loading hinges on.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
name: str
|
|
542
|
+
version: str
|
|
543
|
+
description: str = ""
|
|
544
|
+
object_types: tuple = ()
|
|
545
|
+
relation_types: tuple = ()
|
|
546
|
+
behaviors: tuple = ()
|
|
547
|
+
tools: tuple = ()
|
|
548
|
+
policies: tuple = ()
|
|
549
|
+
prompts: tuple = ()
|
|
550
|
+
settings_schema: type = EmptySettings
|
|
551
|
+
|
|
552
|
+
def __post_init__(self) -> None:
|
|
553
|
+
# list → tuple conversion (frozen requires object.__setattr__)
|
|
554
|
+
for f in ("object_types", "relation_types", "behaviors", "tools", "policies", "prompts"):
|
|
555
|
+
v = getattr(self, f)
|
|
556
|
+
if isinstance(v, list):
|
|
557
|
+
object.__setattr__(self, f, tuple(v))
|
|
558
|
+
|
|
559
|
+
# name shape
|
|
560
|
+
if not isinstance(self.name, str) or not _PACK_NAME_RE.match(self.name):
|
|
561
|
+
raise PackValidationError(
|
|
562
|
+
f"Pack.name must match [a-z][a-z0-9_]*, got {self.name!r}"
|
|
563
|
+
)
|
|
564
|
+
if not isinstance(self.version, str) or not self.version:
|
|
565
|
+
raise PackValidationError(f"Pack.version must be non-empty str, got {self.version!r}")
|
|
566
|
+
|
|
567
|
+
# settings_schema shape
|
|
568
|
+
if not (isinstance(self.settings_schema, type) and issubclass(self.settings_schema, BaseModel)):
|
|
569
|
+
raise PackValidationError(
|
|
570
|
+
f"Pack {self.name!r}: settings_schema must be a pydantic BaseModel subclass"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# within-pack uniqueness
|
|
574
|
+
_check_unique([o.name for o in self.object_types], "object type", self.name)
|
|
575
|
+
_check_unique([r.name for r in self.relation_types], "relation type", self.name)
|
|
576
|
+
_check_unique([b.name for b in self.behaviors], "behavior", self.name)
|
|
577
|
+
_check_unique([t.name for t in self.tools], "tool", self.name)
|
|
578
|
+
_check_unique([p.name for p in self.policies], "policy", self.name)
|
|
579
|
+
_check_unique([p.name for p in self.prompts], "prompt", self.name)
|
|
580
|
+
|
|
581
|
+
# behaviors must be Behavior / LLMBehavior / RelationBehavior
|
|
582
|
+
for b in self.behaviors:
|
|
583
|
+
if not isinstance(b, (Behavior, RelationBehavior)):
|
|
584
|
+
raise PackValidationError(
|
|
585
|
+
f"Pack {self.name!r}: behavior {b!r} is not a Behavior or RelationBehavior "
|
|
586
|
+
f"instance (decorate with @behavior / @llm_behavior / @relation_behavior "
|
|
587
|
+
f"from activegraph.packs)"
|
|
588
|
+
)
|
|
589
|
+
if getattr(b, "_pack_local", False) is not True:
|
|
590
|
+
raise PackValidationError(
|
|
591
|
+
f"Pack {self.name!r}: behavior {b.name!r} was not declared via "
|
|
592
|
+
f"activegraph.packs decorators (looks like activegraph.behavior was used "
|
|
593
|
+
f"directly — that registers globally; import from activegraph.packs)"
|
|
594
|
+
)
|
|
595
|
+
for t in self.tools:
|
|
596
|
+
if not isinstance(t, Tool):
|
|
597
|
+
raise PackValidationError(
|
|
598
|
+
f"Pack {self.name!r}: tool {t!r} is not a Tool instance"
|
|
599
|
+
)
|
|
600
|
+
if getattr(t, "_pack_local", False) is not True:
|
|
601
|
+
raise PackValidationError(
|
|
602
|
+
f"Pack {self.name!r}: tool {t.name!r} was not declared via "
|
|
603
|
+
f"activegraph.packs.tool (use activegraph.packs.tool, not activegraph.tool)"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# identity by (name, version) — CONTRACT v0.9 #2 / #6
|
|
607
|
+
def __eq__(self, other: Any) -> bool:
|
|
608
|
+
if not isinstance(other, Pack):
|
|
609
|
+
return NotImplemented
|
|
610
|
+
return (self.name, self.version) == (other.name, other.version)
|
|
611
|
+
|
|
612
|
+
def __hash__(self) -> int:
|
|
613
|
+
return hash((self.name, self.version))
|
|
614
|
+
|
|
615
|
+
def prompt_manifest(self) -> dict[str, dict[str, str]]:
|
|
616
|
+
"""The `pack.loaded` payload's `prompts` block. Maps prompt
|
|
617
|
+
name to {"version", "hash"}. CONTRACT v0.9 #10.
|
|
618
|
+
"""
|
|
619
|
+
return {
|
|
620
|
+
p.name: {"version": p.version, "hash": p.content_hash}
|
|
621
|
+
for p in self.prompts
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _check_unique(names: list[str], kind: str, pack_name: str) -> None:
|
|
626
|
+
seen: set[str] = set()
|
|
627
|
+
for n in names:
|
|
628
|
+
if n in seen:
|
|
629
|
+
raise PackValidationError(
|
|
630
|
+
f"Pack {pack_name!r}: duplicate {kind} name {n!r}"
|
|
631
|
+
)
|
|
632
|
+
seen.add(n)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# ----------------------------------------------------- pack-aware decorators
|
|
636
|
+
#
|
|
637
|
+
# Identical signatures to the activegraph.* decorators; the ONLY
|
|
638
|
+
# difference is `_REGISTRY.append(...)` is skipped — packs collect
|
|
639
|
+
# their behaviors explicitly via `Pack(behaviors=[...])`, so global
|
|
640
|
+
# registration would be a bug. Each returned Behavior / Tool object
|
|
641
|
+
# carries `_pack_local = True` so the Pack constructor can verify
|
|
642
|
+
# the right decorator was used (CONTRACT v0.9 #3).
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def behavior(
|
|
646
|
+
name: Optional[str] = None,
|
|
647
|
+
on: Optional[list[str]] = None,
|
|
648
|
+
where: Optional[dict[str, Any]] = None,
|
|
649
|
+
view: Optional[dict[str, Any]] = None,
|
|
650
|
+
creates: Optional[list[str]] = None,
|
|
651
|
+
budget: Optional[dict[str, Any]] = None,
|
|
652
|
+
priority: int = 0,
|
|
653
|
+
*,
|
|
654
|
+
pattern: Optional[str] = None,
|
|
655
|
+
activate_after: Any = None,
|
|
656
|
+
) -> Callable[[Callable], Behavior]:
|
|
657
|
+
"""Pack-aware `@behavior`. Does not register globally."""
|
|
658
|
+
|
|
659
|
+
from activegraph.runtime.patterns import parse as _parse_pattern
|
|
660
|
+
from activegraph.runtime.scheduler import parse_activate_after as _parse_aa
|
|
661
|
+
|
|
662
|
+
compiled_matcher = None
|
|
663
|
+
if pattern is not None:
|
|
664
|
+
compiled_matcher = _parse_pattern(pattern).compile()
|
|
665
|
+
delay_n: Optional[int] = None
|
|
666
|
+
if activate_after is not None:
|
|
667
|
+
delay_n = _parse_aa(activate_after)
|
|
668
|
+
|
|
669
|
+
def wrap(fn: Callable) -> Behavior:
|
|
670
|
+
b = Behavior(
|
|
671
|
+
name=name or fn.__name__,
|
|
672
|
+
fn=fn,
|
|
673
|
+
on=list(on or []),
|
|
674
|
+
where=dict(where) if where else None,
|
|
675
|
+
view_spec=dict(view) if view else None,
|
|
676
|
+
creates=list(creates or []),
|
|
677
|
+
budget=dict(budget) if budget else None,
|
|
678
|
+
priority=priority,
|
|
679
|
+
pattern=pattern,
|
|
680
|
+
pattern_matcher=compiled_matcher,
|
|
681
|
+
activate_after=delay_n,
|
|
682
|
+
)
|
|
683
|
+
b._pack_local = True # type: ignore[attr-defined]
|
|
684
|
+
# Attach metadata to the underlying function so packs can inspect
|
|
685
|
+
# without instantiating a runtime.
|
|
686
|
+
fn.__pack_meta__ = { # type: ignore[attr-defined]
|
|
687
|
+
"kind": "behavior",
|
|
688
|
+
"name": b.name,
|
|
689
|
+
"on": b.on,
|
|
690
|
+
"where": b.where,
|
|
691
|
+
}
|
|
692
|
+
return b
|
|
693
|
+
|
|
694
|
+
return wrap
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def llm_behavior(
|
|
698
|
+
*,
|
|
699
|
+
name: Optional[str] = None,
|
|
700
|
+
on: Optional[list[str]] = None,
|
|
701
|
+
where: Optional[dict[str, Any]] = None,
|
|
702
|
+
description: str = "",
|
|
703
|
+
model: str = "claude-sonnet-4-5",
|
|
704
|
+
output_schema: Optional[type] = None,
|
|
705
|
+
view: Optional[dict[str, Any]] = None,
|
|
706
|
+
creates: Optional[list[str]] = None,
|
|
707
|
+
budget: Optional[dict[str, Any]] = None,
|
|
708
|
+
deterministic: bool = False,
|
|
709
|
+
max_tokens: int = 4096,
|
|
710
|
+
temperature: float = 0.7,
|
|
711
|
+
top_p: float = 1.0,
|
|
712
|
+
timeout_seconds: float = 60.0,
|
|
713
|
+
prompt_template: Optional[str] = None,
|
|
714
|
+
priority: int = 0,
|
|
715
|
+
pattern: Optional[str] = None,
|
|
716
|
+
activate_after: Any = None,
|
|
717
|
+
tools: Optional[list] = None,
|
|
718
|
+
max_tool_turns: int = 6,
|
|
719
|
+
) -> Callable[[Callable], LLMBehavior]:
|
|
720
|
+
"""Pack-aware `@llm_behavior`. Does not register globally."""
|
|
721
|
+
|
|
722
|
+
from activegraph.runtime.patterns import parse as _parse_pattern
|
|
723
|
+
from activegraph.runtime.scheduler import parse_activate_after as _parse_aa
|
|
724
|
+
|
|
725
|
+
compiled_matcher = None
|
|
726
|
+
if pattern is not None:
|
|
727
|
+
compiled_matcher = _parse_pattern(pattern).compile()
|
|
728
|
+
delay_n: Optional[int] = None
|
|
729
|
+
if activate_after is not None:
|
|
730
|
+
delay_n = _parse_aa(activate_after)
|
|
731
|
+
|
|
732
|
+
def wrap(fn: Callable) -> LLMBehavior:
|
|
733
|
+
b = LLMBehavior(
|
|
734
|
+
name=name or fn.__name__,
|
|
735
|
+
fn=_llm_behavior_fn_placeholder,
|
|
736
|
+
on=list(on or []),
|
|
737
|
+
where=dict(where) if where else None,
|
|
738
|
+
view_spec=dict(view) if view else None,
|
|
739
|
+
creates=list(creates or []),
|
|
740
|
+
budget=dict(budget) if budget else None,
|
|
741
|
+
priority=priority,
|
|
742
|
+
handler=fn,
|
|
743
|
+
description=description,
|
|
744
|
+
model=model,
|
|
745
|
+
output_schema=output_schema,
|
|
746
|
+
deterministic=deterministic,
|
|
747
|
+
max_tokens=max_tokens,
|
|
748
|
+
temperature=temperature,
|
|
749
|
+
top_p=top_p,
|
|
750
|
+
timeout_seconds=timeout_seconds,
|
|
751
|
+
prompt_template=prompt_template,
|
|
752
|
+
pattern=pattern,
|
|
753
|
+
pattern_matcher=compiled_matcher,
|
|
754
|
+
activate_after=delay_n,
|
|
755
|
+
tools=list(tools) if tools else [],
|
|
756
|
+
max_tool_turns=max_tool_turns,
|
|
757
|
+
)
|
|
758
|
+
b._pack_local = True # type: ignore[attr-defined]
|
|
759
|
+
fn.__pack_meta__ = { # type: ignore[attr-defined]
|
|
760
|
+
"kind": "llm_behavior",
|
|
761
|
+
"name": b.name,
|
|
762
|
+
"on": b.on,
|
|
763
|
+
"where": b.where,
|
|
764
|
+
"output_schema": getattr(output_schema, "__name__", None),
|
|
765
|
+
}
|
|
766
|
+
return b
|
|
767
|
+
|
|
768
|
+
return wrap
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def relation_behavior(
|
|
772
|
+
relation_type: str,
|
|
773
|
+
on: Optional[list[str]] = None,
|
|
774
|
+
name: Optional[str] = None,
|
|
775
|
+
where: Optional[dict[str, Any]] = None,
|
|
776
|
+
view: Optional[dict[str, Any]] = None,
|
|
777
|
+
creates: Optional[list[str]] = None,
|
|
778
|
+
budget: Optional[dict[str, Any]] = None,
|
|
779
|
+
priority: int = 0,
|
|
780
|
+
*,
|
|
781
|
+
pattern: Optional[str] = None,
|
|
782
|
+
activate_after: Any = None,
|
|
783
|
+
) -> Callable[[Callable], RelationBehavior]:
|
|
784
|
+
"""Pack-aware `@relation_behavior`. Does not register globally."""
|
|
785
|
+
|
|
786
|
+
from activegraph.runtime.patterns import parse as _parse_pattern
|
|
787
|
+
from activegraph.runtime.scheduler import parse_activate_after as _parse_aa
|
|
788
|
+
|
|
789
|
+
compiled_matcher = None
|
|
790
|
+
if pattern is not None:
|
|
791
|
+
compiled_matcher = _parse_pattern(pattern).compile()
|
|
792
|
+
delay_n: Optional[int] = None
|
|
793
|
+
if activate_after is not None:
|
|
794
|
+
delay_n = _parse_aa(activate_after)
|
|
795
|
+
|
|
796
|
+
def wrap(fn: Callable) -> RelationBehavior:
|
|
797
|
+
rb = RelationBehavior(
|
|
798
|
+
name=name or fn.__name__,
|
|
799
|
+
fn=fn,
|
|
800
|
+
relation_type=relation_type,
|
|
801
|
+
on=list(on or []),
|
|
802
|
+
where=dict(where) if where else None,
|
|
803
|
+
view_spec=dict(view) if view else None,
|
|
804
|
+
creates=list(creates or []),
|
|
805
|
+
budget=dict(budget) if budget else None,
|
|
806
|
+
priority=priority,
|
|
807
|
+
pattern=pattern,
|
|
808
|
+
pattern_matcher=compiled_matcher,
|
|
809
|
+
activate_after=delay_n,
|
|
810
|
+
)
|
|
811
|
+
rb._pack_local = True # type: ignore[attr-defined]
|
|
812
|
+
fn.__pack_meta__ = { # type: ignore[attr-defined]
|
|
813
|
+
"kind": "relation_behavior",
|
|
814
|
+
"name": rb.name,
|
|
815
|
+
"relation_type": relation_type,
|
|
816
|
+
}
|
|
817
|
+
return rb
|
|
818
|
+
|
|
819
|
+
return wrap
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def tool(
|
|
823
|
+
*,
|
|
824
|
+
name: Optional[str] = None,
|
|
825
|
+
description: str = "",
|
|
826
|
+
input_schema: Optional[type] = None,
|
|
827
|
+
output_schema: Optional[type] = None,
|
|
828
|
+
cost_per_call: Any = "0.0",
|
|
829
|
+
timeout_seconds: float = 30.0,
|
|
830
|
+
deterministic: bool = False,
|
|
831
|
+
export_globally: bool = False,
|
|
832
|
+
) -> Callable[[Callable], Tool]:
|
|
833
|
+
"""Pack-aware `@tool`. Does not register globally.
|
|
834
|
+
|
|
835
|
+
`export_globally=True` opts the tool into BOTH the pack-scoped
|
|
836
|
+
name (`{pack}.{name}`) AND the global short name. Default is
|
|
837
|
+
pack-scoped only.
|
|
838
|
+
"""
|
|
839
|
+
from decimal import Decimal
|
|
840
|
+
|
|
841
|
+
def wrap(fn: Callable) -> Tool:
|
|
842
|
+
t = Tool(
|
|
843
|
+
name=name or fn.__name__,
|
|
844
|
+
fn=fn,
|
|
845
|
+
description=description,
|
|
846
|
+
input_schema=input_schema,
|
|
847
|
+
output_schema=output_schema,
|
|
848
|
+
cost_per_call=Decimal(str(cost_per_call)),
|
|
849
|
+
timeout_seconds=timeout_seconds,
|
|
850
|
+
deterministic=deterministic,
|
|
851
|
+
)
|
|
852
|
+
t._pack_local = True # type: ignore[attr-defined]
|
|
853
|
+
t._export_globally = bool(export_globally) # type: ignore[attr-defined]
|
|
854
|
+
fn.__pack_meta__ = { # type: ignore[attr-defined]
|
|
855
|
+
"kind": "tool",
|
|
856
|
+
"name": t.name,
|
|
857
|
+
"deterministic": deterministic,
|
|
858
|
+
"export_globally": export_globally,
|
|
859
|
+
}
|
|
860
|
+
return t
|
|
861
|
+
|
|
862
|
+
return wrap
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ------------------------------------------------------ discovery + loading
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@dataclass(frozen=True)
|
|
869
|
+
class DiscoveredPack:
|
|
870
|
+
"""A pack discovered via Python entry points but not yet loaded."""
|
|
871
|
+
|
|
872
|
+
name: str
|
|
873
|
+
version: str
|
|
874
|
+
entry_point: str
|
|
875
|
+
pack: Pack
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
_DISCOVERY_CACHE: Optional[tuple[DiscoveredPack, ...]] = None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def discover() -> tuple[DiscoveredPack, ...]:
|
|
882
|
+
"""Enumerate installed packs via the `activegraph.packs` entry
|
|
883
|
+
point group. Cached per process; call `clear_discovery_cache()`
|
|
884
|
+
to force a re-scan.
|
|
885
|
+
"""
|
|
886
|
+
global _DISCOVERY_CACHE
|
|
887
|
+
if _DISCOVERY_CACHE is not None:
|
|
888
|
+
return _DISCOVERY_CACHE
|
|
889
|
+
from importlib.metadata import entry_points
|
|
890
|
+
|
|
891
|
+
found: list[DiscoveredPack] = []
|
|
892
|
+
eps = entry_points()
|
|
893
|
+
# Python 3.10+ API: entry_points() returns EntryPoints with select()
|
|
894
|
+
try:
|
|
895
|
+
selected = eps.select(group="activegraph.packs")
|
|
896
|
+
except AttributeError: # pragma: no cover — pre-3.10 fallback
|
|
897
|
+
selected = eps.get("activegraph.packs", []) # type: ignore[union-attr]
|
|
898
|
+
|
|
899
|
+
for ep in selected:
|
|
900
|
+
try:
|
|
901
|
+
obj = ep.load()
|
|
902
|
+
except Exception as e: # pragma: no cover
|
|
903
|
+
# Soft-fail discovery so a broken third-party pack doesn't
|
|
904
|
+
# poison the whole framework. The pack just doesn't show up.
|
|
905
|
+
import warnings
|
|
906
|
+
warnings.warn(
|
|
907
|
+
f"activegraph.packs: failed to load entry point {ep.name}: {e}",
|
|
908
|
+
stacklevel=2,
|
|
909
|
+
)
|
|
910
|
+
continue
|
|
911
|
+
if not isinstance(obj, Pack):
|
|
912
|
+
import warnings
|
|
913
|
+
warnings.warn(
|
|
914
|
+
f"activegraph.packs: entry point {ep.name} resolved to "
|
|
915
|
+
f"{type(obj).__name__}, not Pack — skipping",
|
|
916
|
+
stacklevel=2,
|
|
917
|
+
)
|
|
918
|
+
continue
|
|
919
|
+
found.append(
|
|
920
|
+
DiscoveredPack(
|
|
921
|
+
name=obj.name,
|
|
922
|
+
version=obj.version,
|
|
923
|
+
entry_point=ep.value,
|
|
924
|
+
pack=obj,
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
_DISCOVERY_CACHE = tuple(found)
|
|
928
|
+
return _DISCOVERY_CACHE
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def clear_discovery_cache() -> None:
|
|
932
|
+
"""Reset the cached entry-point scan. Tests that install packages
|
|
933
|
+
dynamically need to call this; normal usage does not.
|
|
934
|
+
"""
|
|
935
|
+
global _DISCOVERY_CACHE
|
|
936
|
+
_DISCOVERY_CACHE = None
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def load_by_name(name: str) -> Pack:
|
|
940
|
+
"""Find a discovered pack by name. Raises `LookupError` if not found."""
|
|
941
|
+
installed = tuple(entry.name for entry in discover())
|
|
942
|
+
if name in installed:
|
|
943
|
+
for entry in discover():
|
|
944
|
+
if entry.name == name:
|
|
945
|
+
return entry.pack
|
|
946
|
+
raise PackNotFoundError(name, installed=installed)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
# ----------------------------------------------------- approval primitives
|
|
950
|
+
#
|
|
951
|
+
# v0.9 ships a minimal approval surface so the diligence pack's
|
|
952
|
+
# memo_approval / risk_approval policies have something to gate on.
|
|
953
|
+
# A pending approval is a value object held in the runtime; user
|
|
954
|
+
# code (or a CLI subcommand) calls runtime.approve(id).
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
@dataclass(frozen=True)
|
|
958
|
+
class PendingApproval:
|
|
959
|
+
"""An object creation that's gated behind a policy approval.
|
|
960
|
+
|
|
961
|
+
The `id` is unique within the runtime instance and is reused as
|
|
962
|
+
the eventual object id once approved. `kind` is "object" in
|
|
963
|
+
v0.9; the field exists so v1.0 can extend it to relations or
|
|
964
|
+
patches without breaking the API.
|
|
965
|
+
"""
|
|
966
|
+
|
|
967
|
+
id: str
|
|
968
|
+
kind: str
|
|
969
|
+
object_type: str
|
|
970
|
+
data: dict
|
|
971
|
+
reason: str
|
|
972
|
+
pack: str # the pack whose policy gated this
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
__all__ = [
|
|
976
|
+
"Pack",
|
|
977
|
+
"ObjectType",
|
|
978
|
+
"RelationType",
|
|
979
|
+
"PackPolicy",
|
|
980
|
+
"PackPrompt",
|
|
981
|
+
"EmptySettings",
|
|
982
|
+
"DiscoveredPack",
|
|
983
|
+
"PendingApproval",
|
|
984
|
+
"PackError",
|
|
985
|
+
"PackValidationError",
|
|
986
|
+
"PackConflictError",
|
|
987
|
+
"PackVersionConflictError",
|
|
988
|
+
"PackSchemaViolation",
|
|
989
|
+
"PackSettingsMissingError",
|
|
990
|
+
"PackPromptLoadError",
|
|
991
|
+
"behavior",
|
|
992
|
+
"llm_behavior",
|
|
993
|
+
"relation_behavior",
|
|
994
|
+
"tool",
|
|
995
|
+
"load_prompts_from_dir",
|
|
996
|
+
"discover",
|
|
997
|
+
"load_by_name",
|
|
998
|
+
"clear_discovery_cache",
|
|
999
|
+
]
|