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,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
+ ]