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,556 @@
|
|
|
1
|
+
"""``activegraph quickstart`` — the v1.0 onboarding command.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
- ``activegraph quickstart`` — fixture-backed diligence demo. No API
|
|
6
|
+
key, no network. Always-deterministic (FrozenClock + fixed run id +
|
|
7
|
+
seeded behaviors) so the output is byte-identical across machines.
|
|
8
|
+
- ``activegraph quickstart --interactive`` — guided REPL that walks
|
|
9
|
+
the developer through writing their first behavior.
|
|
10
|
+
|
|
11
|
+
The transcript at ``examples/quickstart_session.txt`` is the contract
|
|
12
|
+
for both modes. Every behavior of the command matches a line in the
|
|
13
|
+
transcript. The trace lines in the output come from the canonical
|
|
14
|
+
:class:`activegraph.trace.printer.Trace` — the quickstart command
|
|
15
|
+
does not reformat trace output. The prose framing ("what just
|
|
16
|
+
happened", "try next") is quickstart-specific.
|
|
17
|
+
|
|
18
|
+
CONTRACT v1.0 #1 (the spec), #C3 (no ``--live`` mode), #4d (every
|
|
19
|
+
``More:`` link in error messages resolves; same discipline applies
|
|
20
|
+
to the "try next" footer's doc links).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional, TextIO
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Always-deterministic demo setup. The quickstart command is a demo;
|
|
36
|
+
# its output should be the same on every machine. Real-run behavior
|
|
37
|
+
# is what examples/diligence_real_run.py already provides for
|
|
38
|
+
# developers who graduate past quickstart.
|
|
39
|
+
#
|
|
40
|
+
# The frozen timestamp is deliberately stylized (2026-01-01) so it
|
|
41
|
+
# reads as obviously synthetic rather than "this demo is from a year
|
|
42
|
+
# ago" — relevant when a developer runs quickstart in 2027 or later.
|
|
43
|
+
_QUICKSTART_FROZEN_TIMESTAMP = "2026-01-01T00:00:00Z"
|
|
44
|
+
_QUICKSTART_RUN_ID = "quickstart_demo_run"
|
|
45
|
+
_QUICKSTART_DB_DIR = "/tmp/activegraph_quickstart"
|
|
46
|
+
_QUICKSTART_DB_PATH = f"{_QUICKSTART_DB_DIR}/{_QUICKSTART_RUN_ID}.db"
|
|
47
|
+
|
|
48
|
+
# Interactive mode's behavior-file location: cwd-subdir for
|
|
49
|
+
# discoverability (per CONTRACT v1.0 #1 prompt review — better than
|
|
50
|
+
# tempdir because the developer wants to refer back to what they
|
|
51
|
+
# just wrote).
|
|
52
|
+
_INTERACTIVE_SUBDIR = "activegraph_quickstart"
|
|
53
|
+
_INTERACTIVE_BEHAVIOR_FILENAME = "my_first_behavior.py"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------- fixture-backed mode ------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_fixture_mode(stream: Optional[TextIO] = None) -> int:
|
|
60
|
+
"""Run the fixture-backed diligence demo.
|
|
61
|
+
|
|
62
|
+
All output is written to ``stream`` (default stdout). Returns 0.
|
|
63
|
+
Always-deterministic; safe to snapshot-test.
|
|
64
|
+
"""
|
|
65
|
+
out = stream if stream is not None else sys.stdout
|
|
66
|
+
write = lambda s: out.write(s + "\n")
|
|
67
|
+
|
|
68
|
+
from activegraph import Graph, IDGen, FrozenClock, Runtime, configure_logging
|
|
69
|
+
from activegraph.packs.diligence import (
|
|
70
|
+
DiligenceSettings,
|
|
71
|
+
pack as diligence_pack,
|
|
72
|
+
)
|
|
73
|
+
from activegraph.packs.diligence.fixtures import (
|
|
74
|
+
RecordedDiligenceProvider,
|
|
75
|
+
THREE_COMPANIES,
|
|
76
|
+
company_goal,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Header — matches transcript BEAT 2.
|
|
80
|
+
write("activegraph quickstart — running the bundled Diligence pack on fixtures.")
|
|
81
|
+
write("")
|
|
82
|
+
write("This will take about 20 seconds. No API key required.")
|
|
83
|
+
write(f" pack: {diligence_pack.name} v{diligence_pack.version} (no external network calls)")
|
|
84
|
+
write(f" companies: {', '.join(c['name'] for c in THREE_COMPANIES)}")
|
|
85
|
+
write(" provider: RecordedDiligenceProvider (fixture-backed)")
|
|
86
|
+
write("")
|
|
87
|
+
|
|
88
|
+
# Suppress structured logging so the only output is the quickstart's.
|
|
89
|
+
configure_logging(level="ERROR", json_output=False)
|
|
90
|
+
|
|
91
|
+
# Fresh DB on every run. The fixed run_id means re-running quickstart
|
|
92
|
+
# overwrites the previous demo — correct for a demo; we don't want
|
|
93
|
+
# quickstart leaving N database files in /tmp.
|
|
94
|
+
Path(_QUICKSTART_DB_DIR).mkdir(parents=True, exist_ok=True)
|
|
95
|
+
if os.path.exists(_QUICKSTART_DB_PATH):
|
|
96
|
+
os.remove(_QUICKSTART_DB_PATH)
|
|
97
|
+
|
|
98
|
+
provider = RecordedDiligenceProvider(companies=THREE_COMPANIES)
|
|
99
|
+
graph = Graph(
|
|
100
|
+
ids=IDGen(),
|
|
101
|
+
clock=FrozenClock(_QUICKSTART_FROZEN_TIMESTAMP),
|
|
102
|
+
run_id=_QUICKSTART_RUN_ID,
|
|
103
|
+
)
|
|
104
|
+
rt = Runtime(
|
|
105
|
+
graph,
|
|
106
|
+
llm_provider=provider,
|
|
107
|
+
persist_to=_QUICKSTART_DB_PATH,
|
|
108
|
+
budget={
|
|
109
|
+
"max_llm_calls": 80,
|
|
110
|
+
"max_tool_calls": 100,
|
|
111
|
+
"max_cost_usd": "5.00",
|
|
112
|
+
},
|
|
113
|
+
seed=0,
|
|
114
|
+
)
|
|
115
|
+
rt.load_pack(
|
|
116
|
+
diligence_pack,
|
|
117
|
+
settings=DiligenceSettings(
|
|
118
|
+
llm_model="claude-sonnet-4-5",
|
|
119
|
+
max_documents_per_company=5,
|
|
120
|
+
max_claims_per_document=10,
|
|
121
|
+
confidence_threshold_for_review=0.7,
|
|
122
|
+
min_questions=8,
|
|
123
|
+
max_questions=12,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
for company in THREE_COMPANIES:
|
|
128
|
+
rt.run_goal(company_goal(company))
|
|
129
|
+
|
|
130
|
+
rt.save_state()
|
|
131
|
+
|
|
132
|
+
# Trace — canonical printer output. The quickstart does not reformat;
|
|
133
|
+
# any drift between the trace shown here and the trace documented on
|
|
134
|
+
# the doc site is a bug in this code path, not the printer.
|
|
135
|
+
for line in rt.trace.lines():
|
|
136
|
+
write(line)
|
|
137
|
+
write("")
|
|
138
|
+
|
|
139
|
+
# Pull the memos. Print the first in full, mention the others.
|
|
140
|
+
memos = [o for o in rt.graph.all_objects() if o.type == "memo"]
|
|
141
|
+
if memos:
|
|
142
|
+
_print_memo_section(write, rt, memos[0])
|
|
143
|
+
if len(memos) > 1:
|
|
144
|
+
others = [_company_name_for_memo(rt, m) for m in memos[1:]]
|
|
145
|
+
write(
|
|
146
|
+
f"Memos for {', '.join(others)} were produced under the same "
|
|
147
|
+
f"contract. Output written to sqlite:///{_QUICKSTART_DB_PATH}."
|
|
148
|
+
)
|
|
149
|
+
write("")
|
|
150
|
+
|
|
151
|
+
_print_what_just_happened(write)
|
|
152
|
+
_print_try_next(write)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _company_name_for_memo(rt, memo) -> str:
|
|
157
|
+
"""Resolve a memo's company_id to a human name for the prose."""
|
|
158
|
+
cid = (memo.data or {}).get("company_id")
|
|
159
|
+
if cid is None:
|
|
160
|
+
return "<unknown>"
|
|
161
|
+
co = rt.graph.get_object(cid)
|
|
162
|
+
if co is None:
|
|
163
|
+
return cid
|
|
164
|
+
return (co.data or {}).get("name") or cid
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _print_memo_section(write, rt, memo) -> None:
|
|
168
|
+
write("-" * 76)
|
|
169
|
+
company = _company_name_for_memo(rt, memo)
|
|
170
|
+
write(f" Memo: {company}")
|
|
171
|
+
write("-" * 76)
|
|
172
|
+
write("")
|
|
173
|
+
data = memo.data or {}
|
|
174
|
+
|
|
175
|
+
summary = data.get("summary", "")
|
|
176
|
+
if summary:
|
|
177
|
+
write("Summary:")
|
|
178
|
+
for line in _wrap_indented(summary, indent=" ", width=74):
|
|
179
|
+
write(line)
|
|
180
|
+
write("")
|
|
181
|
+
|
|
182
|
+
claims = data.get("key_claims") or []
|
|
183
|
+
if claims:
|
|
184
|
+
write("Key claims:")
|
|
185
|
+
for c in claims:
|
|
186
|
+
text = c.get("text") or c.get("claim", "")
|
|
187
|
+
evidence_ids = c.get("evidence_ids") or []
|
|
188
|
+
ev_clause = (
|
|
189
|
+
f" (evidence: {', '.join(evidence_ids)})" if evidence_ids else ""
|
|
190
|
+
)
|
|
191
|
+
write(f" - {text}{ev_clause}")
|
|
192
|
+
write("")
|
|
193
|
+
|
|
194
|
+
contradictions = data.get("open_contradictions") or []
|
|
195
|
+
note = data.get("contradictions_note")
|
|
196
|
+
write("Open contradictions:")
|
|
197
|
+
if contradictions:
|
|
198
|
+
for c in contradictions:
|
|
199
|
+
write(f" - {c}")
|
|
200
|
+
elif note:
|
|
201
|
+
write(f" ({note})")
|
|
202
|
+
else:
|
|
203
|
+
write(" (none surfaced for this company)")
|
|
204
|
+
write("")
|
|
205
|
+
|
|
206
|
+
risks = data.get("risks") or []
|
|
207
|
+
write("Risks:")
|
|
208
|
+
if risks:
|
|
209
|
+
for r in risks:
|
|
210
|
+
title = r.get("title", "")
|
|
211
|
+
severity = r.get("severity", "")
|
|
212
|
+
related = r.get("related_claim_ids") or []
|
|
213
|
+
sev_clause = f"; severity: {severity}" if severity else ""
|
|
214
|
+
rel_clause = (
|
|
215
|
+
f" (related claims: {', '.join(related)}{sev_clause})"
|
|
216
|
+
if related
|
|
217
|
+
else (f" ({sev_clause.lstrip('; ')})" if severity else "")
|
|
218
|
+
)
|
|
219
|
+
write(f" - {title}{rel_clause}")
|
|
220
|
+
else:
|
|
221
|
+
write(" (none identified)")
|
|
222
|
+
write("")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _wrap_indented(text: str, *, indent: str, width: int) -> list[str]:
|
|
226
|
+
"""Wrap prose to a width with a leading indent. Standalone (no
|
|
227
|
+
textwrap import) to keep output stable across Python versions."""
|
|
228
|
+
out: list[str] = []
|
|
229
|
+
current = indent
|
|
230
|
+
for word in text.split():
|
|
231
|
+
if len(current) + len(word) + 1 > width and current.strip():
|
|
232
|
+
out.append(current.rstrip())
|
|
233
|
+
current = indent + word
|
|
234
|
+
else:
|
|
235
|
+
current = (current + " " + word) if current.strip() else (indent + word)
|
|
236
|
+
if current.strip():
|
|
237
|
+
out.append(current.rstrip())
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _print_what_just_happened(write) -> None:
|
|
242
|
+
write("-" * 76)
|
|
243
|
+
write(" What just happened")
|
|
244
|
+
write("-" * 76)
|
|
245
|
+
write("")
|
|
246
|
+
write(" 1. You loaded a pack. A pack is a Python package that registers")
|
|
247
|
+
write(" object types, behaviors, tools, and prompts. The Diligence pack")
|
|
248
|
+
write(" ships with activegraph.")
|
|
249
|
+
write("")
|
|
250
|
+
write(" 2. The runtime received three goals (one per company) and reactive")
|
|
251
|
+
write(" behaviors fired automatically as objects appeared on the graph.")
|
|
252
|
+
write(" You did not write a workflow. The behaviors are pattern-matched")
|
|
253
|
+
write(" against event types and object shapes.")
|
|
254
|
+
write("")
|
|
255
|
+
write(" 3. Every LLM call was served by the bundled fixture provider")
|
|
256
|
+
write(" (RecordedDiligenceProvider), so the run is deterministic and runs")
|
|
257
|
+
write(" offline. The trace above shows cost and latency on each llm.responded")
|
|
258
|
+
write(" line — those are the fixture's recorded numbers. Production runs")
|
|
259
|
+
write(" against a real provider would show real costs and latencies.")
|
|
260
|
+
write("")
|
|
261
|
+
write(" (The framework also has a separate replay cache that records")
|
|
262
|
+
write(" llm.responded events and serves them back under strict-replay mode")
|
|
263
|
+
write(" or in-process Runtime.fork() — that's where `cache_hit=true` appears")
|
|
264
|
+
write(" in the trace. Different layer from the provider; see the")
|
|
265
|
+
write(" concepts/replay and concepts/forking pages for the deep dive.)")
|
|
266
|
+
write("")
|
|
267
|
+
write(" 4. Each memo cites evidence for every claim, surfaces at least one")
|
|
268
|
+
write(" risk, and either lists open contradictions or states explicitly")
|
|
269
|
+
write(" that none were found. This is the pack's \"verifiable memo bar\" —")
|
|
270
|
+
write(" a memo without these properties is a bug in the pack.")
|
|
271
|
+
write("")
|
|
272
|
+
write(" 5. Every event is on disk. The full causal chain from goal to memo")
|
|
273
|
+
write(" is reconstructable from the event log alone.")
|
|
274
|
+
write("")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _print_try_next(write) -> None:
|
|
278
|
+
from activegraph.errors import DOCS_BASE_URL
|
|
279
|
+
write("-" * 76)
|
|
280
|
+
write(" Try next")
|
|
281
|
+
write("-" * 76)
|
|
282
|
+
write("")
|
|
283
|
+
write(f" See your run: activegraph inspect sqlite:///{_QUICKSTART_DB_PATH}")
|
|
284
|
+
write(" Write a behavior: activegraph quickstart --interactive")
|
|
285
|
+
write(f" Read the tutorial: {DOCS_BASE_URL}/quickstart")
|
|
286
|
+
write(f" Concept: the graph: {DOCS_BASE_URL}/concepts/graph")
|
|
287
|
+
write(f" Concept: behaviors: {DOCS_BASE_URL}/concepts/behaviors")
|
|
288
|
+
write(f" Common patterns: {DOCS_BASE_URL}/cookbook/common-patterns")
|
|
289
|
+
write("")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------- interactive mode ---------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
_INTERACTIVE_SCAFFOLD = '''\
|
|
296
|
+
"""Your first activegraph behavior — scaffolded by `activegraph quickstart --interactive`.
|
|
297
|
+
|
|
298
|
+
This behavior fires whenever a claim object is created and emits a
|
|
299
|
+
`growth.flagged` event for claims that mention revenue growth above 25%.
|
|
300
|
+
|
|
301
|
+
Edit the TODO below, save, then type `continue` in the quickstart prompt.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
import re
|
|
305
|
+
|
|
306
|
+
from activegraph import behavior
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@behavior(
|
|
310
|
+
name="growth_flagger",
|
|
311
|
+
on=["object.created"],
|
|
312
|
+
where={"object.type": "claim"},
|
|
313
|
+
)
|
|
314
|
+
def growth_flagger(event, graph, ctx):
|
|
315
|
+
"""Flag claims that mention revenue growth above 25%."""
|
|
316
|
+
text = event.payload["object"]["data"].get("text", "")
|
|
317
|
+
# TODO: parse the text for a growth percentage and emit
|
|
318
|
+
# `growth.flagged` with the claim id when the growth is > 25%.
|
|
319
|
+
#
|
|
320
|
+
# Hint: a regex like r"(\\d+)%\\s+YoY" captures the percentage.
|
|
321
|
+
# Then: graph.emit("growth.flagged", {"claim_id": ..., "growth": ...})
|
|
322
|
+
pass
|
|
323
|
+
'''
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _prepare_interactive_subdir(stream: TextIO, prompt_fn) -> Optional[Path]:
|
|
327
|
+
"""Set up the activegraph_quickstart/ subdir, handling collision
|
|
328
|
+
helpfully (CONTRACT v1.0 #1 errata clarified: the quickstart is in
|
|
329
|
+
onboarding mode, not debug mode; fail-loud is wrong here).
|
|
330
|
+
|
|
331
|
+
Returns the Path to the behavior file, or None if the developer
|
|
332
|
+
chose to quit.
|
|
333
|
+
"""
|
|
334
|
+
subdir = Path.cwd() / _INTERACTIVE_SUBDIR
|
|
335
|
+
behavior_file = subdir / _INTERACTIVE_BEHAVIOR_FILENAME
|
|
336
|
+
if not subdir.exists():
|
|
337
|
+
subdir.mkdir(parents=True)
|
|
338
|
+
return behavior_file
|
|
339
|
+
|
|
340
|
+
# Collision. Offer overwrite / suffix / quit.
|
|
341
|
+
stream.write(
|
|
342
|
+
f"\nAn `{_INTERACTIVE_SUBDIR}/` directory already exists in the\n"
|
|
343
|
+
f"current working directory. What should I do?\n"
|
|
344
|
+
f" [o] overwrite the existing behavior file\n"
|
|
345
|
+
f" [s] suffix the new one (my_first_behavior_2.py, etc.)\n"
|
|
346
|
+
f" [q] quit\n"
|
|
347
|
+
)
|
|
348
|
+
# Re-prompt on unrecognized input. Pre-rc2 behavior fell through
|
|
349
|
+
# to suffix on any non-o/q input, which swallowed typeahead from
|
|
350
|
+
# the next step's prompt (CONTRACT v1.0-rc2 finding M1). Mirrors
|
|
351
|
+
# the iteration loop's "(unrecognized: ...; type X or Y)" pattern
|
|
352
|
+
# below so the interactive flow's two prompts have the same voice.
|
|
353
|
+
while True:
|
|
354
|
+
choice = prompt_fn("choose [o/s/q]: ", default="s").strip().lower()
|
|
355
|
+
if choice == "q":
|
|
356
|
+
return None
|
|
357
|
+
if choice == "o":
|
|
358
|
+
return behavior_file
|
|
359
|
+
if choice == "s" or choice == "":
|
|
360
|
+
n = 2
|
|
361
|
+
while True:
|
|
362
|
+
candidate = subdir / f"my_first_behavior_{n}.py"
|
|
363
|
+
if not candidate.exists():
|
|
364
|
+
return candidate
|
|
365
|
+
n += 1
|
|
366
|
+
stream.write(f"(unrecognized: {choice!r}; choose o, s, or q)\n")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def run_interactive_mode(
|
|
370
|
+
stream: Optional[TextIO] = None,
|
|
371
|
+
*,
|
|
372
|
+
prompt_fn=None,
|
|
373
|
+
) -> int:
|
|
374
|
+
"""Guided REPL for writing the developer's first behavior.
|
|
375
|
+
|
|
376
|
+
``prompt_fn`` is injectable for testing — production passes
|
|
377
|
+
:func:`click.prompt`; tests pass a scripted-stdin function.
|
|
378
|
+
Returns 0 on success, 1 if the developer chose to quit.
|
|
379
|
+
"""
|
|
380
|
+
out = stream if stream is not None else sys.stdout
|
|
381
|
+
prompt_fn = prompt_fn if prompt_fn is not None else _default_prompt
|
|
382
|
+
write = lambda s: out.write(s + "\n")
|
|
383
|
+
|
|
384
|
+
write("activegraph quickstart --interactive — write your first behavior.")
|
|
385
|
+
write("")
|
|
386
|
+
write("We'll add a behavior that flags claims about revenue growth above")
|
|
387
|
+
write("25%. The behavior will fire whenever a claim is created and emit a")
|
|
388
|
+
write("`growth.flagged` event with the claim id.")
|
|
389
|
+
write("")
|
|
390
|
+
write("Step 1 of 4 — create a file.")
|
|
391
|
+
write("")
|
|
392
|
+
|
|
393
|
+
behavior_file = _prepare_interactive_subdir(out, prompt_fn)
|
|
394
|
+
if behavior_file is None:
|
|
395
|
+
write("Goodbye.")
|
|
396
|
+
return 1
|
|
397
|
+
|
|
398
|
+
behavior_file.write_text(_INTERACTIVE_SCAFFOLD)
|
|
399
|
+
write(f"Created {behavior_file}.")
|
|
400
|
+
write("")
|
|
401
|
+
write("Step 2 of 4 — fill in the TODO.")
|
|
402
|
+
write("")
|
|
403
|
+
write(
|
|
404
|
+
f"Open {behavior_file} in your editor and replace the TODO with the\n"
|
|
405
|
+
f"parsing logic. When you've saved the file, type `continue` to test\n"
|
|
406
|
+
f"it. (We don't watch your filesystem — explicit over magic.)"
|
|
407
|
+
)
|
|
408
|
+
write("")
|
|
409
|
+
|
|
410
|
+
while True:
|
|
411
|
+
cmd = prompt_fn("[continue / quit]: ", default="continue").strip().lower()
|
|
412
|
+
if cmd in ("quit", "q"):
|
|
413
|
+
write("")
|
|
414
|
+
write(
|
|
415
|
+
f"Goodbye. Your behavior is at {behavior_file} — keep it, modify\n"
|
|
416
|
+
f"it, or delete the {_INTERACTIVE_SUBDIR}/ directory when you're done."
|
|
417
|
+
)
|
|
418
|
+
return 0
|
|
419
|
+
if cmd not in ("continue", "c", ""):
|
|
420
|
+
write(f"(unrecognized: {cmd!r}; type `continue` or `quit`)")
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
# Run the goal against the developer's behavior. The run is
|
|
424
|
+
# silent except for the per-fire summary at the end — we don't
|
|
425
|
+
# repeat the full trace from fixture mode here.
|
|
426
|
+
n_fires = _run_user_behavior(behavior_file, write)
|
|
427
|
+
|
|
428
|
+
write("")
|
|
429
|
+
write(f"Step 3 of 4 — your behavior fired {n_fires} time(s) across the run.")
|
|
430
|
+
write("")
|
|
431
|
+
write("Edit the file again and type `continue` to re-test, or `quit` to")
|
|
432
|
+
write("finish.")
|
|
433
|
+
write("")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _run_user_behavior(behavior_file: Path, write) -> int:
|
|
437
|
+
"""Import the developer's behavior in a fresh subprocess-equivalent
|
|
438
|
+
(importlib mechanics — we deliberately do NOT use importlib.reload;
|
|
439
|
+
auto-reload was rejected per CONTRACT v1.0 BEAT 5 errata). Run a
|
|
440
|
+
single-company goal against the fixture data and report how many
|
|
441
|
+
times the developer's behavior fired.
|
|
442
|
+
|
|
443
|
+
For simplicity in v1.0-rc1, this uses importlib.util.spec_from_file_location
|
|
444
|
+
to load the file fresh on each `continue` — each invocation gets a
|
|
445
|
+
fresh module object, so the @behavior decorator's side effects re-fire
|
|
446
|
+
each round.
|
|
447
|
+
"""
|
|
448
|
+
import importlib.util
|
|
449
|
+
from activegraph import (
|
|
450
|
+
Graph,
|
|
451
|
+
IDGen,
|
|
452
|
+
FrozenClock,
|
|
453
|
+
Runtime,
|
|
454
|
+
clear_registry,
|
|
455
|
+
configure_logging,
|
|
456
|
+
)
|
|
457
|
+
from activegraph.packs.diligence import (
|
|
458
|
+
DiligenceSettings,
|
|
459
|
+
pack as diligence_pack,
|
|
460
|
+
)
|
|
461
|
+
from activegraph.packs.diligence.fixtures import (
|
|
462
|
+
RecordedDiligenceProvider,
|
|
463
|
+
THREE_COMPANIES,
|
|
464
|
+
company_goal,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# Fresh registry per round so the developer's behavior re-registers
|
|
468
|
+
# cleanly. The diligence pack's behaviors re-register via load_pack
|
|
469
|
+
# in the same shape they did in fixture mode.
|
|
470
|
+
clear_registry()
|
|
471
|
+
spec = importlib.util.spec_from_file_location(
|
|
472
|
+
"_activegraph_quickstart_user_behavior", str(behavior_file)
|
|
473
|
+
)
|
|
474
|
+
if spec is None or spec.loader is None:
|
|
475
|
+
write(f"(could not load {behavior_file}; check for syntax errors)")
|
|
476
|
+
return 0
|
|
477
|
+
module = importlib.util.module_from_spec(spec)
|
|
478
|
+
try:
|
|
479
|
+
spec.loader.exec_module(module)
|
|
480
|
+
except Exception as e:
|
|
481
|
+
write(f"(error loading your behavior: {type(e).__name__}: {e})")
|
|
482
|
+
return 0
|
|
483
|
+
|
|
484
|
+
configure_logging(level="ERROR", json_output=False)
|
|
485
|
+
provider = RecordedDiligenceProvider(companies=[THREE_COMPANIES[0]])
|
|
486
|
+
graph = Graph(ids=IDGen(), clock=FrozenClock(_QUICKSTART_FROZEN_TIMESTAMP))
|
|
487
|
+
rt = Runtime(
|
|
488
|
+
graph,
|
|
489
|
+
llm_provider=provider,
|
|
490
|
+
budget={
|
|
491
|
+
"max_llm_calls": 80,
|
|
492
|
+
"max_tool_calls": 100,
|
|
493
|
+
"max_cost_usd": "5.00",
|
|
494
|
+
},
|
|
495
|
+
seed=0,
|
|
496
|
+
)
|
|
497
|
+
rt.load_pack(
|
|
498
|
+
diligence_pack,
|
|
499
|
+
settings=DiligenceSettings(
|
|
500
|
+
llm_model="claude-sonnet-4-5",
|
|
501
|
+
max_documents_per_company=5,
|
|
502
|
+
max_claims_per_document=10,
|
|
503
|
+
),
|
|
504
|
+
)
|
|
505
|
+
rt.run_goal(company_goal(THREE_COMPANIES[0]))
|
|
506
|
+
|
|
507
|
+
# Count behavior.completed events for the developer's behavior name.
|
|
508
|
+
# If the user renamed it, this returns 0 — that's a finding worth
|
|
509
|
+
# surfacing in v1.1 (read all behavior.completed events, list any
|
|
510
|
+
# that aren't pack-prefixed). For rc1, the scaffold's name is
|
|
511
|
+
# 'growth_flagger'; matching by name keeps the count honest.
|
|
512
|
+
return sum(
|
|
513
|
+
1 for e in rt.graph.events
|
|
514
|
+
if e.type == "behavior.completed"
|
|
515
|
+
and (e.payload or {}).get("behavior") == "growth_flagger"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _default_prompt(question: str, *, default: str = "") -> str:
|
|
520
|
+
"""click.prompt wrapper. Separate function so tests can inject a
|
|
521
|
+
scripted-stdin replacement without touching click internals."""
|
|
522
|
+
return click.prompt(question, default=default, show_default=False)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ---------- click command registration -----------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@click.command("quickstart")
|
|
529
|
+
@click.option(
|
|
530
|
+
"--interactive",
|
|
531
|
+
is_flag=True,
|
|
532
|
+
help=(
|
|
533
|
+
"Walk through writing your first behavior with a guided REPL. "
|
|
534
|
+
"Writes a scaffolded behavior file to ./activegraph_quickstart/ "
|
|
535
|
+
"in the current directory; the directory persists so you can "
|
|
536
|
+
"keep what you wrote."
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
def cmd_quickstart(interactive: bool) -> None:
|
|
540
|
+
"""Run the bundled diligence demo, or walk through writing your first behavior.
|
|
541
|
+
|
|
542
|
+
Default mode runs the diligence pack against bundled fixtures with
|
|
543
|
+
no API key and no network. The run is byte-deterministic across
|
|
544
|
+
machines (FrozenClock + fixed run id + seeded behaviors); see the
|
|
545
|
+
transcript at ``examples/quickstart_session.txt`` for the locked
|
|
546
|
+
output shape.
|
|
547
|
+
|
|
548
|
+
``--interactive`` walks the developer through writing a custom
|
|
549
|
+
behavior with prompts and explanations.
|
|
550
|
+
"""
|
|
551
|
+
if interactive:
|
|
552
|
+
rc = run_interactive_mode()
|
|
553
|
+
else:
|
|
554
|
+
rc = run_fixture_mode()
|
|
555
|
+
if rc != 0:
|
|
556
|
+
sys.exit(rc)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core primitives. Knows nothing about runtime or behaviors."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Clock abstraction. CONTRACT #8: behaviors get time via ctx.clock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Clock:
|
|
9
|
+
"""Real wall-clock UTC. ISO 8601 second precision, Z suffix."""
|
|
10
|
+
|
|
11
|
+
def now(self) -> str:
|
|
12
|
+
return (
|
|
13
|
+
datetime.now(tz=timezone.utc)
|
|
14
|
+
.isoformat(timespec="seconds")
|
|
15
|
+
.replace("+00:00", "Z")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FrozenClock(Clock):
|
|
20
|
+
"""Always returns the same timestamp. For tests and snapshots."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, t: str = "2026-05-15T10:32:01Z") -> None:
|
|
23
|
+
self._t = t
|
|
24
|
+
|
|
25
|
+
def now(self) -> str:
|
|
26
|
+
return self._t
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TickingClock(Clock):
|
|
30
|
+
"""Monotonically advances by `step` seconds on every call. For tests that
|
|
31
|
+
care about ordering but don't want wall-clock noise."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
start: str = "2026-05-15T10:32:01Z",
|
|
36
|
+
step_seconds: int = 1,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._t = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
|
39
|
+
self._step = step_seconds
|
|
40
|
+
|
|
41
|
+
def now(self) -> str:
|
|
42
|
+
out = self._t.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
43
|
+
from datetime import timedelta
|
|
44
|
+
|
|
45
|
+
self._t = self._t + timedelta(seconds=self._step)
|
|
46
|
+
return out
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Event records. CONTRACT #3: append-only, never modified.
|
|
2
|
+
|
|
3
|
+
Events are dataclasses, frozen at the Python level. Their `payload` dict is
|
|
4
|
+
not deeply frozen — by convention nothing mutates it after `emit`. The runtime
|
|
5
|
+
treats the event log as the source of truth (CONTRACT #2).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Event:
|
|
16
|
+
id: str
|
|
17
|
+
type: str
|
|
18
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
actor: Optional[str] = None
|
|
20
|
+
frame_id: Optional[str] = None
|
|
21
|
+
caused_by: Optional[str] = None
|
|
22
|
+
timestamp: str = ""
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"id": self.id,
|
|
27
|
+
"type": self.type,
|
|
28
|
+
"payload": self.payload,
|
|
29
|
+
"actor": self.actor,
|
|
30
|
+
"frame_id": self.frame_id,
|
|
31
|
+
"caused_by": self.caused_by,
|
|
32
|
+
"timestamp": self.timestamp,
|
|
33
|
+
}
|