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,257 @@
|
|
|
1
|
+
"""LLM-side errors. v1.0 PR-D — migrated to ExecutionError.
|
|
2
|
+
|
|
3
|
+
Two surface types in the LLM layer:
|
|
4
|
+
|
|
5
|
+
- :class:`MissingProviderError` — raised at registration time when an
|
|
6
|
+
``@llm_behavior`` is invoked but no provider is wired. Stays a
|
|
7
|
+
``RuntimeError`` subclass through PR-D; PR-E (RegistrationError)
|
|
8
|
+
re-parents it.
|
|
9
|
+
|
|
10
|
+
- :class:`LLMBehaviorError` — structured failure from inside an
|
|
11
|
+
``@llm_behavior`` wrapper. Carries a ``reason`` code from CONTRACT
|
|
12
|
+
v0.6 #11 plus a free-form ``message``. The runtime's ``_invoke``
|
|
13
|
+
catch reads ``reason`` and ``payload_extras`` off this exception and
|
|
14
|
+
includes them in the emitted ``behavior.failed`` event.
|
|
15
|
+
|
|
16
|
+
PR-D re-parents ``LLMBehaviorError`` under
|
|
17
|
+
:class:`activegraph.errors.ExecutionError` so it joins the v1.0
|
|
18
|
+
hierarchy and produces a structured-format message when rendered to a
|
|
19
|
+
user. The ``(reason, message, payload_extras)`` constructor signature
|
|
20
|
+
is preserved so the ~8 internal raise sites in providers do not
|
|
21
|
+
change. The structured fields are auto-derived from ``reason`` via a
|
|
22
|
+
per-reason prose table — same pattern as PR-B's
|
|
23
|
+
``_KEYWORD_WORKAROUNDS``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from activegraph.errors import ExecutionError, RegistrationError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Per-reason prose for LLMBehaviorError. The voice principle from
|
|
34
|
+
# CONTRACT v1.0 #3: explain the invariant being protected, not the
|
|
35
|
+
# mechanism of the check. Each entry produces what_failed / why /
|
|
36
|
+
# how_to_fix triples that the constructor uses to build the
|
|
37
|
+
# structured-format body.
|
|
38
|
+
#
|
|
39
|
+
# `message` from the call site is interpolated into what_failed; `why`
|
|
40
|
+
# and `how_to_fix` are reason-specific and stable across instances.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _llm_prose_parse_error(message: str) -> tuple[str, str, str]:
|
|
44
|
+
return (
|
|
45
|
+
f"The LLM provider returned a response that the framework could not "
|
|
46
|
+
f"parse as JSON:\n {message}",
|
|
47
|
+
"LLM behaviors with a structured `output_schema` expect the model to "
|
|
48
|
+
"return JSON that matches the schema; the framework parses the "
|
|
49
|
+
"response and constructs typed objects from it. A response that "
|
|
50
|
+
"isn't valid JSON breaks the contract that downstream behaviors "
|
|
51
|
+
"depend on — they receive typed objects, not raw strings — so the "
|
|
52
|
+
"framework fails the call rather than guess at structure.",
|
|
53
|
+
"If the provider is real, the model's response is non-deterministic; "
|
|
54
|
+
"try raising the prompt's emphasis on JSON-only output, or lowering "
|
|
55
|
+
"temperature. If the provider is a fixture (RecordedLLMProvider), "
|
|
56
|
+
"the recorded response is malformed — re-record from a clean run.\n"
|
|
57
|
+
"\n"
|
|
58
|
+
"The full response is in the `behavior.failed` event's `payload_extras`; "
|
|
59
|
+
"inspect it with:\n"
|
|
60
|
+
" activegraph inspect <store> --event <behavior.failed-id>",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _llm_prose_schema_violation(message: str) -> tuple[str, str, str]:
|
|
65
|
+
return (
|
|
66
|
+
f"The LLM provider returned valid JSON, but the JSON did not match "
|
|
67
|
+
f"the behavior's declared `output_schema`:\n {message}",
|
|
68
|
+
"Pydantic validates every LLM response against the schema declared on "
|
|
69
|
+
"`@llm_behavior(output_schema=...)`. Schema-violating responses are "
|
|
70
|
+
"refused at the boundary so downstream behaviors receive only objects "
|
|
71
|
+
"that obey the schema — replay determinism depends on this.",
|
|
72
|
+
"Check whether the schema's required fields match what the model "
|
|
73
|
+
"actually produces. Common causes: a required field is missing in "
|
|
74
|
+
"the response, an enum value is out-of-range, or a list field "
|
|
75
|
+
"contains items of the wrong type. Add the missing fields to the "
|
|
76
|
+
"prompt's example output, or relax the schema (e.g. `Optional[X]`) "
|
|
77
|
+
"if the field genuinely can be absent.",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _llm_prose_fixture_missing(message: str) -> tuple[str, str, str]:
|
|
82
|
+
return (
|
|
83
|
+
f"The RecordedLLMProvider has no fixture for this prompt:\n {message}",
|
|
84
|
+
"RecordedLLMProvider replays a directory of recorded LLM responses keyed "
|
|
85
|
+
"by prompt content hash. A missing fixture means the live prompt's hash "
|
|
86
|
+
"doesn't match any recorded response — either the prompt changed since "
|
|
87
|
+
"the fixtures were recorded (a behavior edit, a template change, a "
|
|
88
|
+
"tool input difference), or this is a new prompt that was never "
|
|
89
|
+
"recorded.",
|
|
90
|
+
"Re-record the fixture from a live run with the current prompt:\n"
|
|
91
|
+
" 1. Switch to AnthropicProvider (set ANTHROPIC_API_KEY)\n"
|
|
92
|
+
" 2. Run the goal once to produce live LLM responses\n"
|
|
93
|
+
" 3. The provider records each response to the fixture directory\n"
|
|
94
|
+
" 4. Subsequent runs against RecordedLLMProvider replay them\n"
|
|
95
|
+
"\n"
|
|
96
|
+
"Or diff the prompt against the recorded version to find the drift.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _llm_prose_rate_limited(message: str) -> tuple[str, str, str]:
|
|
101
|
+
return (
|
|
102
|
+
f"The LLM provider rejected the request as rate-limited:\n {message}",
|
|
103
|
+
"Providers cap requests per minute / tokens per minute. When the cap "
|
|
104
|
+
"is hit, retries within the rate-limit window will fail again — the "
|
|
105
|
+
"framework refuses to silently retry-loop without an explicit "
|
|
106
|
+
"operator decision, because a long retry loop can quietly exhaust "
|
|
107
|
+
"the behavior's budget.",
|
|
108
|
+
"Wait until the rate-limit window resets (provider-specific — usually "
|
|
109
|
+
"60 seconds), then re-run. For long-running goals, set a higher "
|
|
110
|
+
"`max_seconds` budget so the runtime tolerates intermittent "
|
|
111
|
+
"rate-limits. If this happens during fork/replay, the cache hit "
|
|
112
|
+
"should normally prevent the live call — check whether the prompt "
|
|
113
|
+
"hash matches the recorded one.",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _llm_prose_network_error(message: str) -> tuple[str, str, str]:
|
|
118
|
+
return (
|
|
119
|
+
f"The LLM provider call failed with a network error:\n {message}",
|
|
120
|
+
"The framework treats network failures as transient but not "
|
|
121
|
+
"automatically retryable: a silent retry could mask a real outage "
|
|
122
|
+
"or burn budget on a flaky network. The error escapes to the caller "
|
|
123
|
+
"so the operator decides what to do.",
|
|
124
|
+
"If the network is unreliable, re-run the goal — fork-and-replay "
|
|
125
|
+
"from the last successful event:\n"
|
|
126
|
+
" activegraph fork <run> --at-event <last-good> --record\n"
|
|
127
|
+
"For systematic outages, the provider's status page is the canonical "
|
|
128
|
+
"source. Switching to RecordedLLMProvider with previously-recorded "
|
|
129
|
+
"fixtures lets the run complete offline.",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_LLM_REASON_PROSE: dict[str, Any] = {
|
|
134
|
+
"llm.parse_error": _llm_prose_parse_error,
|
|
135
|
+
"llm.schema_violation": _llm_prose_schema_violation,
|
|
136
|
+
"llm.fixture_missing": _llm_prose_fixture_missing,
|
|
137
|
+
"llm.rate_limited": _llm_prose_rate_limited,
|
|
138
|
+
"llm.network_error": _llm_prose_network_error,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _llm_fallback_prose(reason: str, message: str) -> tuple[str, str, str]:
|
|
143
|
+
"""Default prose for a reason code the table doesn't enumerate. The
|
|
144
|
+
voice still names the invariant ("the framework surfaces structured
|
|
145
|
+
failures from @llm_behavior wrappers"); recovery prose is generic
|
|
146
|
+
and points the operator at the trace.
|
|
147
|
+
"""
|
|
148
|
+
return (
|
|
149
|
+
f"An @llm_behavior wrapper failed with reason {reason!r}:\n {message}",
|
|
150
|
+
f"The runtime catches structured failures from @llm_behavior bodies "
|
|
151
|
+
f"and merges them into the emitted `behavior.failed` event, where "
|
|
152
|
+
f"downstream code can read `reason={reason!r}` and decide how to "
|
|
153
|
+
f"proceed. The exception you're seeing is the underlying carrier.",
|
|
154
|
+
f"Inspect the `behavior.failed` event in the trace:\n"
|
|
155
|
+
f" activegraph inspect <store> --tail 50\n"
|
|
156
|
+
f"\n"
|
|
157
|
+
f"The full message is preserved verbatim above; check the LLM "
|
|
158
|
+
f"provider's documentation for reason {reason!r}.",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class MissingProviderError(RegistrationError, RuntimeError):
|
|
163
|
+
"""Raised when an @llm_behavior is invoked but no LLM provider is
|
|
164
|
+
wired on the Runtime.
|
|
165
|
+
|
|
166
|
+
Fires at registration / startup, not at every invocation — the
|
|
167
|
+
runtime validates the configuration once. Multi-inherits
|
|
168
|
+
:class:`RuntimeError` for back-compat with user code catching the
|
|
169
|
+
builtin around runtime construction.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
_doc_slug = "missing-provider-error"
|
|
173
|
+
|
|
174
|
+
def __init__(self, behavior_name: Optional[str] = None) -> None:
|
|
175
|
+
self.behavior_name = behavior_name
|
|
176
|
+
what = (
|
|
177
|
+
f"An LLM-backed behavior ({behavior_name!r}) was registered, "
|
|
178
|
+
f"but Runtime(...) was constructed without an `llm_provider=` "
|
|
179
|
+
f"argument."
|
|
180
|
+
if behavior_name
|
|
181
|
+
else (
|
|
182
|
+
"An @llm_behavior was registered, but Runtime(...) was "
|
|
183
|
+
"constructed without an `llm_provider=` argument."
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
ctx: dict[str, Any] = {}
|
|
187
|
+
if behavior_name:
|
|
188
|
+
ctx["behavior_name"] = behavior_name
|
|
189
|
+
RegistrationError.__init__(
|
|
190
|
+
self,
|
|
191
|
+
"no LLM provider configured for @llm_behavior",
|
|
192
|
+
what_failed=what,
|
|
193
|
+
why=(
|
|
194
|
+
"@llm_behavior dispatches LLM calls through the provider "
|
|
195
|
+
"attached to the runtime at construction. Failing loud at "
|
|
196
|
+
"registration rather than at first invocation is the v0.6 "
|
|
197
|
+
"contract — silently no-op'ing the behavior would corrupt "
|
|
198
|
+
"the audit trail (behaviors fire and produce events; a "
|
|
199
|
+
"missing provider would produce events that claim to "
|
|
200
|
+
"depend on an LLM call that never happened)."
|
|
201
|
+
),
|
|
202
|
+
how_to_fix=(
|
|
203
|
+
"Pass `llm_provider=` to the Runtime constructor:\n"
|
|
204
|
+
" from activegraph.llm.anthropic import AnthropicProvider\n"
|
|
205
|
+
" rt = Runtime(graph, llm_provider=AnthropicProvider())\n"
|
|
206
|
+
"\n"
|
|
207
|
+
"For offline replay or tests, use a recorded or scripted "
|
|
208
|
+
"provider:\n"
|
|
209
|
+
" from activegraph.llm.recorded import RecordedLLMProvider\n"
|
|
210
|
+
" rt = Runtime(graph, llm_provider=RecordedLLMProvider(...))"
|
|
211
|
+
),
|
|
212
|
+
context=ctx,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class LLMBehaviorError(ExecutionError, Exception):
|
|
217
|
+
"""Structured failure from inside an @llm_behavior wrapper.
|
|
218
|
+
|
|
219
|
+
The runtime's ``_invoke`` catch reads ``reason`` and ``payload_extras``
|
|
220
|
+
off this exception and includes them in the emitted
|
|
221
|
+
``behavior.failed`` event. Other exception types fall through to
|
|
222
|
+
the existing CONTRACT v0.6 #13 path unchanged.
|
|
223
|
+
|
|
224
|
+
Constructor signature ``(reason, message, *, payload_extras=)`` is
|
|
225
|
+
preserved from v0.6 so the ~8 internal raise sites in providers do
|
|
226
|
+
not change. The structured-format fields are auto-derived from
|
|
227
|
+
``reason`` via the per-reason prose table above.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
_doc_slug = "llm-behavior-error"
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
reason: str,
|
|
235
|
+
message: str,
|
|
236
|
+
*,
|
|
237
|
+
payload_extras: Optional[dict[str, Any]] = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
self.reason = reason
|
|
240
|
+
self.payload_extras = dict(payload_extras or {})
|
|
241
|
+
prose_fn = _LLM_REASON_PROSE.get(reason)
|
|
242
|
+
if prose_fn is None:
|
|
243
|
+
what_failed, why, how_to_fix = _llm_fallback_prose(reason, message)
|
|
244
|
+
else:
|
|
245
|
+
what_failed, why, how_to_fix = prose_fn(message)
|
|
246
|
+
ExecutionError.__init__(
|
|
247
|
+
self,
|
|
248
|
+
f"{reason}: {message}",
|
|
249
|
+
what_failed=what_failed,
|
|
250
|
+
why=why,
|
|
251
|
+
how_to_fix=how_to_fix,
|
|
252
|
+
context={
|
|
253
|
+
"reason": reason,
|
|
254
|
+
"message": message,
|
|
255
|
+
"payload_extras": self.payload_extras,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Prompt assembler + view serializer.
|
|
2
|
+
|
|
3
|
+
CONTRACT v0.6 #6, #13, #20. This is the load-bearing module of v0.6:
|
|
4
|
+
|
|
5
|
+
* Developers don't write prompts in user code. The runtime assembles
|
|
6
|
+
every prompt from four locked sources, in this order:
|
|
7
|
+
|
|
8
|
+
1. system — frame goal, frame constraints, behavior role
|
|
9
|
+
description, output-schema reminder
|
|
10
|
+
2. view — serialized scoped graph view (objects + relations
|
|
11
|
+
+ recent events)
|
|
12
|
+
3. event — the triggering event, serialized as JSON
|
|
13
|
+
4. instruction — a single sentence derived from `creates=` and
|
|
14
|
+
`output_schema=`
|
|
15
|
+
|
|
16
|
+
* The format of (2), the view serialization, is part of the public
|
|
17
|
+
contract per decision #13. It is snapshot-tested. Changing it is a
|
|
18
|
+
breaking change to the framework.
|
|
19
|
+
|
|
20
|
+
* `AssembledPrompt.hash()` returns a stable SHA-256 over the
|
|
21
|
+
canonical JSON of {model, system, messages, output_schema_name,
|
|
22
|
+
temperature, max_tokens, top_p, deterministic}. This is the cache
|
|
23
|
+
key used by the replay layer. Hash stability matters; tests
|
|
24
|
+
snapshot it.
|
|
25
|
+
|
|
26
|
+
* `behavior.build_prompt(event, graph)` is public for debugging
|
|
27
|
+
(decision #20). A developer should be able to inspect the exact
|
|
28
|
+
bytes that would have gone to the model, without making a call.
|
|
29
|
+
|
|
30
|
+
`prompt_template=` (str.format-style with {system}, {view}, {event},
|
|
31
|
+
{instruction}) is the only escape hatch — and it still receives the
|
|
32
|
+
same four runtime-assembled inputs. There is no raw string-concat
|
|
33
|
+
path in user code.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import hashlib
|
|
39
|
+
import json
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Any, Optional
|
|
42
|
+
|
|
43
|
+
from activegraph.core.event import Event
|
|
44
|
+
from activegraph.core.view import View
|
|
45
|
+
from activegraph.frame import Frame
|
|
46
|
+
from activegraph.llm.types import LLMMessage
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------- AssembledPrompt -------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class AssembledPrompt:
|
|
54
|
+
"""A fully-assembled prompt + provider-call parameters.
|
|
55
|
+
|
|
56
|
+
Returned by `assemble_prompt(...)` and by
|
|
57
|
+
`LLMBehavior.build_prompt(event, graph)`. The runtime hashes this
|
|
58
|
+
to look up a cached response before deciding to call the provider.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
system: str
|
|
62
|
+
messages: list[LLMMessage]
|
|
63
|
+
model: str
|
|
64
|
+
max_tokens: int
|
|
65
|
+
temperature: float
|
|
66
|
+
top_p: float
|
|
67
|
+
output_schema_name: Optional[str]
|
|
68
|
+
output_schema_json: Optional[dict[str, Any]]
|
|
69
|
+
deterministic: bool
|
|
70
|
+
|
|
71
|
+
# Source-by-source breakdown — useful for debugging and for
|
|
72
|
+
# snapshot tests that target a single section.
|
|
73
|
+
sections: dict[str, str] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
def to_hashable(self) -> dict[str, Any]:
|
|
76
|
+
"""Canonical content used for hashing. Recorded-at timestamps,
|
|
77
|
+
latencies, and other run-specific data are NOT included."""
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"model": self.model,
|
|
81
|
+
"system": self.system,
|
|
82
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
83
|
+
"output_schema_name": self.output_schema_name,
|
|
84
|
+
"output_schema_json": self.output_schema_json,
|
|
85
|
+
"max_tokens": int(self.max_tokens),
|
|
86
|
+
"temperature": float(self.temperature),
|
|
87
|
+
"top_p": float(self.top_p),
|
|
88
|
+
"deterministic": bool(self.deterministic),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def canonical_json(self) -> str:
|
|
92
|
+
return json.dumps(
|
|
93
|
+
self.to_hashable(), sort_keys=True, separators=(",", ":")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def hash(self) -> str:
|
|
97
|
+
return hashlib.sha256(self.canonical_json().encode("utf-8")).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------- view serialization (CONTRACT v0.6 #13 — format is locked) -------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def serialize_view(
|
|
104
|
+
view: View,
|
|
105
|
+
*,
|
|
106
|
+
around: Optional[str] = None,
|
|
107
|
+
depth: Optional[int] = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Render a scoped view as the prompt-ready Markdown block.
|
|
110
|
+
|
|
111
|
+
Format is locked — snapshot-tested in `tests/test_llm_prompt.py`.
|
|
112
|
+
Changing this is a breaking change to the v0.6 contract.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
header_bits: list[str] = []
|
|
116
|
+
if depth is not None:
|
|
117
|
+
header_bits.append(f"depth={depth}")
|
|
118
|
+
if around:
|
|
119
|
+
header_bits.append(f"around={around}")
|
|
120
|
+
header_suffix = f" ({', '.join(header_bits)})" if header_bits else ""
|
|
121
|
+
|
|
122
|
+
lines: list[str] = [f"## Graph context{header_suffix}", ""]
|
|
123
|
+
|
|
124
|
+
objects = view.objects()
|
|
125
|
+
lines.append("### Objects")
|
|
126
|
+
if not objects:
|
|
127
|
+
lines.append("- (none)")
|
|
128
|
+
else:
|
|
129
|
+
for o in objects:
|
|
130
|
+
lines.append(f"- {o.id} ({o.type}): {_canonical(o.data)}")
|
|
131
|
+
lines.append("")
|
|
132
|
+
|
|
133
|
+
relations = view.relations()
|
|
134
|
+
lines.append("### Relations")
|
|
135
|
+
if not relations:
|
|
136
|
+
lines.append("- (none)")
|
|
137
|
+
else:
|
|
138
|
+
for r in relations:
|
|
139
|
+
lines.append(f"- {r.source} --{r.type}--> {r.target}")
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
events = view.events()
|
|
143
|
+
lines.append("### Recent events")
|
|
144
|
+
if not events:
|
|
145
|
+
lines.append("- (none)")
|
|
146
|
+
else:
|
|
147
|
+
for e in events:
|
|
148
|
+
summary = _event_summary(e)
|
|
149
|
+
lines.append(f"- {e.id} {e.type}{summary}")
|
|
150
|
+
|
|
151
|
+
return "\n".join(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _event_summary(e: Event) -> str:
|
|
155
|
+
"""One-line tail for an event in the view block.
|
|
156
|
+
|
|
157
|
+
Stays short on purpose — the prompt should expose enough for the
|
|
158
|
+
model to reason about recent activity without becoming a transcript.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
p = e.payload or {}
|
|
162
|
+
if e.type == "object.created":
|
|
163
|
+
oid = (p.get("object") or {}).get("id", "?")
|
|
164
|
+
return f" {oid}"
|
|
165
|
+
if e.type == "relation.created":
|
|
166
|
+
r = p.get("relation") or {}
|
|
167
|
+
return f' {r.get("source", "?")} --{r.get("type", "?")}--> {r.get("target", "?")}'
|
|
168
|
+
if e.type == "patch.applied":
|
|
169
|
+
return f' {p.get("target", "?")}'
|
|
170
|
+
if e.type == "goal.created":
|
|
171
|
+
return f' "{p.get("goal", "")}"'
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _canonical(value: Any) -> str:
|
|
176
|
+
"""Stable JSON for embedding inside the view block."""
|
|
177
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------- system prompt ---------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_system_prompt(
|
|
184
|
+
*,
|
|
185
|
+
behavior_name: str,
|
|
186
|
+
description: str,
|
|
187
|
+
frame: Optional[Frame],
|
|
188
|
+
output_schema_name: Optional[str],
|
|
189
|
+
output_schema_json: Optional[dict[str, Any]],
|
|
190
|
+
) -> str:
|
|
191
|
+
"""The system prompt is assembled — never hand-written by the user.
|
|
192
|
+
|
|
193
|
+
Source order: frame.goal → frame.constraints → behavior.description
|
|
194
|
+
→ output schema reminder. If a section is absent it is omitted; the
|
|
195
|
+
section headers themselves are stable so snapshot tests are tight.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
blocks: list[str] = []
|
|
199
|
+
|
|
200
|
+
blocks.append(
|
|
201
|
+
f'You are an active-graph behavior named "{behavior_name}".'
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if frame is not None and frame.goal:
|
|
205
|
+
blocks.append(f"Mission: {frame.goal}")
|
|
206
|
+
|
|
207
|
+
if frame is not None and frame.constraints:
|
|
208
|
+
bullets = "\n".join(f"- {c}" for c in frame.constraints)
|
|
209
|
+
blocks.append(f"Constraints:\n{bullets}")
|
|
210
|
+
|
|
211
|
+
if description:
|
|
212
|
+
blocks.append(f"Role: {description}")
|
|
213
|
+
|
|
214
|
+
if output_schema_name and output_schema_json is not None:
|
|
215
|
+
schema_block = json.dumps(output_schema_json, indent=2, sort_keys=True)
|
|
216
|
+
blocks.append(
|
|
217
|
+
f"Respond with JSON that matches the `{output_schema_name}` "
|
|
218
|
+
f"schema:\n{schema_block}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return "\n\n".join(blocks)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------- user message ----------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_user_message(
|
|
228
|
+
*,
|
|
229
|
+
view_block: str,
|
|
230
|
+
event: Event,
|
|
231
|
+
instruction: str,
|
|
232
|
+
) -> str:
|
|
233
|
+
event_block = _serialize_event(event)
|
|
234
|
+
return (
|
|
235
|
+
f"{view_block}\n\n"
|
|
236
|
+
f"## Triggering event\n"
|
|
237
|
+
f"{event_block}\n\n"
|
|
238
|
+
f"## Task\n"
|
|
239
|
+
f"{instruction}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _serialize_event(event: Event) -> str:
|
|
244
|
+
# Strip volatile fields (provenance, run-specific timestamps, run_id)
|
|
245
|
+
# so the prompt is content-stable across runs and forks. Without
|
|
246
|
+
# this, the cache would miss on every fork because the embedded
|
|
247
|
+
# object's provenance carries the parent's run_id.
|
|
248
|
+
clean_payload = _strip_volatile(event.payload)
|
|
249
|
+
payload_json = json.dumps(clean_payload, sort_keys=True, indent=2, default=str)
|
|
250
|
+
return (
|
|
251
|
+
f"- id: {event.id}\n"
|
|
252
|
+
f"- type: {event.type}\n"
|
|
253
|
+
f"- actor: {event.actor or '?'}\n"
|
|
254
|
+
f"- payload:\n```\n{payload_json}\n```"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_VOLATILE_KEYS = frozenset({"provenance", "timestamp", "run_id"})
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _strip_volatile(value: Any) -> Any:
|
|
262
|
+
"""Recursively drop keys whose values vary across runs/forks.
|
|
263
|
+
|
|
264
|
+
Provenance carries `run_id` and `timestamp`; both leak into the
|
|
265
|
+
embedded object payload of `object.created` events and would
|
|
266
|
+
otherwise destabilize the prompt hash. Stable cache lookup
|
|
267
|
+
requires content-equivalence over the parts of the prompt the
|
|
268
|
+
model actually reasons about.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
if isinstance(value, dict):
|
|
272
|
+
return {
|
|
273
|
+
k: _strip_volatile(v)
|
|
274
|
+
for k, v in value.items()
|
|
275
|
+
if k not in _VOLATILE_KEYS
|
|
276
|
+
}
|
|
277
|
+
if isinstance(value, list):
|
|
278
|
+
return [_strip_volatile(v) for v in value]
|
|
279
|
+
return value
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------- task instruction ------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def build_instruction(
|
|
286
|
+
*,
|
|
287
|
+
creates: list[str],
|
|
288
|
+
output_schema_name: Optional[str],
|
|
289
|
+
) -> str:
|
|
290
|
+
"""One-sentence summary of what the LLM is supposed to produce.
|
|
291
|
+
|
|
292
|
+
Auto-derived from the decorator metadata so it cannot drift from
|
|
293
|
+
the runtime's expectations. If the developer is creating multiple
|
|
294
|
+
object types, list them; if there is a schema, name it.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
if output_schema_name and creates:
|
|
298
|
+
creates_str = ", ".join(sorted(set(creates)))
|
|
299
|
+
return (
|
|
300
|
+
f"Return JSON matching the `{output_schema_name}` schema. "
|
|
301
|
+
f"Your output will be used to create objects of type: {creates_str}."
|
|
302
|
+
)
|
|
303
|
+
if output_schema_name:
|
|
304
|
+
return f"Return JSON matching the `{output_schema_name}` schema."
|
|
305
|
+
if creates:
|
|
306
|
+
creates_str = ", ".join(sorted(set(creates)))
|
|
307
|
+
return (
|
|
308
|
+
f"Describe what objects of type {creates_str} should be created "
|
|
309
|
+
f"in response to this event."
|
|
310
|
+
)
|
|
311
|
+
return "Describe what should happen in response to this event."
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------- schema rendering ------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def schema_to_json(schema: Optional[type]) -> Optional[dict[str, Any]]:
|
|
318
|
+
"""Serialize a Pydantic BaseModel class to a JSON schema dict.
|
|
319
|
+
|
|
320
|
+
Returns None if `schema` is None. Falls back to a name-only shell
|
|
321
|
+
if `schema` does not look like a Pydantic v2 model — that lets
|
|
322
|
+
third-party schema systems plug in without us hard-depending on
|
|
323
|
+
Pydantic at import time.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
if schema is None:
|
|
327
|
+
return None
|
|
328
|
+
fn = getattr(schema, "model_json_schema", None)
|
|
329
|
+
if callable(fn):
|
|
330
|
+
return fn()
|
|
331
|
+
return {"type": "object", "title": getattr(schema, "__name__", "Unknown")}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------- top-level assembly ---------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def assemble_prompt(
|
|
338
|
+
*,
|
|
339
|
+
behavior_name: str,
|
|
340
|
+
description: str,
|
|
341
|
+
model: str,
|
|
342
|
+
output_schema: Optional[type],
|
|
343
|
+
creates: list[str],
|
|
344
|
+
view: View,
|
|
345
|
+
event: Event,
|
|
346
|
+
frame: Optional[Frame],
|
|
347
|
+
around: Optional[str],
|
|
348
|
+
depth: Optional[int],
|
|
349
|
+
max_tokens: int,
|
|
350
|
+
temperature: float,
|
|
351
|
+
top_p: float,
|
|
352
|
+
deterministic: bool,
|
|
353
|
+
prompt_template: Optional[str] = None,
|
|
354
|
+
) -> AssembledPrompt:
|
|
355
|
+
"""Assemble a prompt for one behavior invocation.
|
|
356
|
+
|
|
357
|
+
Returns an `AssembledPrompt` containing the system prompt, the user
|
|
358
|
+
messages, and every provider-call parameter that contributes to the
|
|
359
|
+
prompt-hash cache key. Pure function over its arguments — no I/O,
|
|
360
|
+
no provider calls.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
schema_json = schema_to_json(output_schema)
|
|
364
|
+
schema_name = (
|
|
365
|
+
getattr(output_schema, "__name__", None) if output_schema else None
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
system = build_system_prompt(
|
|
369
|
+
behavior_name=behavior_name,
|
|
370
|
+
description=description,
|
|
371
|
+
frame=frame,
|
|
372
|
+
output_schema_name=schema_name,
|
|
373
|
+
output_schema_json=schema_json,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
view_block = serialize_view(view, around=around, depth=depth)
|
|
377
|
+
instruction = build_instruction(
|
|
378
|
+
creates=list(creates), output_schema_name=schema_name
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if prompt_template is not None:
|
|
382
|
+
event_block = _serialize_event(event)
|
|
383
|
+
try:
|
|
384
|
+
user_text = prompt_template.format(
|
|
385
|
+
system=system,
|
|
386
|
+
view=view_block,
|
|
387
|
+
event=event_block,
|
|
388
|
+
instruction=instruction,
|
|
389
|
+
)
|
|
390
|
+
except KeyError as e:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"prompt_template references unknown placeholder {e!r}. "
|
|
393
|
+
f"Allowed: {{system}}, {{view}}, {{event}}, {{instruction}}"
|
|
394
|
+
) from e
|
|
395
|
+
else:
|
|
396
|
+
user_text = build_user_message(
|
|
397
|
+
view_block=view_block, event=event, instruction=instruction
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
eff_temperature = 0.0 if deterministic else float(temperature)
|
|
401
|
+
eff_top_p = 1.0 if deterministic else float(top_p)
|
|
402
|
+
|
|
403
|
+
return AssembledPrompt(
|
|
404
|
+
system=system,
|
|
405
|
+
messages=[LLMMessage(role="user", content=user_text)],
|
|
406
|
+
model=model,
|
|
407
|
+
max_tokens=int(max_tokens),
|
|
408
|
+
temperature=eff_temperature,
|
|
409
|
+
top_p=eff_top_p,
|
|
410
|
+
output_schema_name=schema_name,
|
|
411
|
+
output_schema_json=schema_json,
|
|
412
|
+
deterministic=bool(deterministic),
|
|
413
|
+
sections={
|
|
414
|
+
"system": system,
|
|
415
|
+
"view": view_block,
|
|
416
|
+
"event": _serialize_event(event),
|
|
417
|
+
"instruction": instruction,
|
|
418
|
+
"user": user_text,
|
|
419
|
+
},
|
|
420
|
+
)
|