flw-studio 0.1.0__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 (39) hide show
  1. agentic_flow/__init__.py +186 -0
  2. agentic_flow/cli.py +232 -0
  3. agentic_flow/console.py +114 -0
  4. agentic_flow/core/__init__.py +6 -0
  5. agentic_flow/core/agent.py +357 -0
  6. agentic_flow/core/authority.py +210 -0
  7. agentic_flow/core/checkpoint.py +162 -0
  8. agentic_flow/core/context.py +59 -0
  9. agentic_flow/core/cursor_format.py +208 -0
  10. agentic_flow/core/events.py +181 -0
  11. agentic_flow/core/graph.py +657 -0
  12. agentic_flow/core/limits.py +97 -0
  13. agentic_flow/core/providers/__init__.py +27 -0
  14. agentic_flow/core/providers/anthropic_provider.py +103 -0
  15. agentic_flow/core/providers/base.py +128 -0
  16. agentic_flow/core/providers/gemini_provider.py +157 -0
  17. agentic_flow/core/retry.py +92 -0
  18. agentic_flow/core/runtime.py +883 -0
  19. agentic_flow/core/schema.py +118 -0
  20. agentic_flow/core/secrets.py +241 -0
  21. agentic_flow/core/store.py +799 -0
  22. agentic_flow/core/templating.py +82 -0
  23. agentic_flow/core/tools/__init__.py +31 -0
  24. agentic_flow/core/tools/builtin/__init__.py +27 -0
  25. agentic_flow/core/tools/builtin/messaging.py +50 -0
  26. agentic_flow/core/tools/builtin/state.py +47 -0
  27. agentic_flow/core/tools/builtin/todos.py +115 -0
  28. agentic_flow/core/tools/core.py +282 -0
  29. agentic_flow/core/tools/mcp.py +176 -0
  30. agentic_flow/loader.py +1130 -0
  31. agentic_flow/log.py +108 -0
  32. agentic_flow/pipeline.py +145 -0
  33. agentic_flow/program.py +256 -0
  34. flw_studio-0.1.0.dist-info/METADATA +475 -0
  35. flw_studio-0.1.0.dist-info/RECORD +39 -0
  36. flw_studio-0.1.0.dist-info/WHEEL +5 -0
  37. flw_studio-0.1.0.dist-info/entry_points.txt +2 -0
  38. flw_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. flw_studio-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,186 @@
1
+ """agentic_flow: define agents (tools + system prompt) in YAML, run them as a pipeline."""
2
+ import logging as _logging
3
+
4
+ # Library default: attach a NullHandler to the package logger so a run is silent until
5
+ # the application calls configure_logging (or adds its own handler). Done here — not as
6
+ # an import-time side effect of the layer-2 `log` module — so it runs on any
7
+ # `agentic_flow.*` import and the engine never depends on `log` for it (ADR 0004).
8
+ _logging.getLogger("agentic_flow").addHandler(_logging.NullHandler())
9
+
10
+ from .core.authority import ReplanRejected, maybe_replan, parse_delta, validate_replan
11
+ from .core.agent import (
12
+ DEFAULT_MODEL,
13
+ DEFAULT_PROVIDER,
14
+ Agent,
15
+ AgentError,
16
+ AgentRefusal,
17
+ AgentTimeout,
18
+ MaxStepsExceeded,
19
+ OutputParseError,
20
+ TokenBudgetExceeded,
21
+ )
22
+ from .core.tools.builtin import register_builtins # registers shared-state tools on the default registry
23
+ from .core.checkpoint import (
24
+ Checkpointer,
25
+ InMemoryCheckpointer,
26
+ JsonFileCheckpointer,
27
+ get_checkpointer,
28
+ register_checkpointer,
29
+ )
30
+ from .console import console_tracer, print_final_state
31
+ from .core.context import Context
32
+ from .core.cursor_format import CheckpointRecord, Pending
33
+ from .core.events import EventBus, Subscription
34
+ from .core.graph import (
35
+ END,
36
+ AddEdge,
37
+ AddNode,
38
+ AgentBody,
39
+ Authority,
40
+ ConditionalEdge,
41
+ Graph,
42
+ GraphError,
43
+ HumanBody,
44
+ ModelDriven,
45
+ Node,
46
+ Prune,
47
+ Replan,
48
+ ReplanDelta,
49
+ StaticEdge,
50
+ SubgraphBody,
51
+ ToolBody,
52
+ render_graph,
53
+ )
54
+ from .core.limits import Limits
55
+ from .loader import LoadedProgram, LoaderError, build_program, lower_pipeline
56
+ from .log import configure_logging, event_logger, logger
57
+ from .pipeline import Loop, Step
58
+ from .program import Orchestrator
59
+ from .core.providers import (
60
+ AnthropicProvider,
61
+ Completion,
62
+ GeminiProvider,
63
+ Provider,
64
+ get_provider,
65
+ register_provider,
66
+ )
67
+ from .core.retry import RetryPolicy, run_with_retry
68
+ from .core.runtime import (
69
+ Budget,
70
+ FrontierDeadlock,
71
+ HumanInputRequired,
72
+ MaxVisitsExceeded,
73
+ NodeOutputError,
74
+ NodePaused,
75
+ RouteOutOfSet,
76
+ RunStepsExceeded,
77
+ RunTimeout,
78
+ RuntimeFailure,
79
+ resolve_out_edges,
80
+ resolve_route,
81
+ )
82
+ from .core.runtime import resume as resume_graph
83
+ from .core.runtime import run as run_graph
84
+ from .core.store import (
85
+ REDUCERS,
86
+ Channel,
87
+ MessageLog,
88
+ Reducer,
89
+ ReducerTypeError,
90
+ Store,
91
+ TodoBoard,
92
+ UnsupportedSnapshotVersion,
93
+ )
94
+ from .core.tools import Tool, ToolRegistry, registry, tool
95
+
96
+ __all__ = [
97
+ "AddEdge",
98
+ "AddNode",
99
+ "Agent",
100
+ "AgentBody",
101
+ "AgentError",
102
+ "AgentRefusal",
103
+ "AgentTimeout",
104
+ "AnthropicProvider",
105
+ "Authority",
106
+ "Budget",
107
+ "Channel",
108
+ "CheckpointRecord",
109
+ "Checkpointer",
110
+ "Completion",
111
+ "ConditionalEdge",
112
+ "Context",
113
+ "DEFAULT_MODEL",
114
+ "DEFAULT_PROVIDER",
115
+ "END",
116
+ "EventBus",
117
+ "FrontierDeadlock",
118
+ "GeminiProvider",
119
+ "Graph",
120
+ "GraphError",
121
+ "HumanBody",
122
+ "HumanInputRequired",
123
+ "InMemoryCheckpointer",
124
+ "JsonFileCheckpointer",
125
+ "Limits",
126
+ "LoadedProgram",
127
+ "LoaderError",
128
+ "Loop",
129
+ "MaxStepsExceeded",
130
+ "MaxVisitsExceeded",
131
+ "MessageLog",
132
+ "ModelDriven",
133
+ "Node",
134
+ "NodeOutputError",
135
+ "NodePaused",
136
+ "Orchestrator",
137
+ "OutputParseError",
138
+ "Pending",
139
+ "Provider",
140
+ "Prune",
141
+ "REDUCERS",
142
+ "Reducer",
143
+ "ReducerTypeError",
144
+ "Replan",
145
+ "ReplanDelta",
146
+ "ReplanRejected",
147
+ "RetryPolicy",
148
+ "RouteOutOfSet",
149
+ "RunStepsExceeded",
150
+ "RunTimeout",
151
+ "RuntimeFailure",
152
+ "StaticEdge",
153
+ "Step",
154
+ "Store",
155
+ "SubgraphBody",
156
+ "TodoBoard",
157
+ "UnsupportedSnapshotVersion",
158
+ "Subscription",
159
+ "Tool",
160
+ "ToolBody",
161
+ "render_graph",
162
+ "TokenBudgetExceeded",
163
+ "ToolRegistry",
164
+ "build_program",
165
+ "configure_logging",
166
+ "console_tracer",
167
+ "event_logger",
168
+ "print_final_state",
169
+ "get_checkpointer",
170
+ "get_provider",
171
+ "logger",
172
+ "lower_pipeline",
173
+ "maybe_replan",
174
+ "parse_delta",
175
+ "register_checkpointer",
176
+ "register_provider",
177
+ "register_builtins",
178
+ "registry",
179
+ "resolve_out_edges",
180
+ "resolve_route",
181
+ "resume_graph",
182
+ "run_graph",
183
+ "run_with_retry",
184
+ "tool",
185
+ "validate_replan",
186
+ ]
agentic_flow/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """Command-line entry point: run a pipeline YAML.
2
+
3
+ agentic-flow path/to/pipeline.yaml --tools mypkg.tools
4
+ agentic-flow examples/shared_state/pipeline.yaml --tools examples.shared_state.tools --dry-run
5
+
6
+ ``--tools`` imports modules that register ``@tool`` functions (their import is the
7
+ side effect that populates the registry). ``--set KEY=VALUE`` seeds initial state.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib
13
+ import json
14
+ import logging
15
+ import sys
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ from .core.checkpoint import get_checkpointer
20
+ from .console import console_tracer, print_final_state
21
+ from .core.graph import KIND_HUMAN, render_graph
22
+ from .log import configure_logging, event_logger
23
+ from .program import Orchestrator
24
+ from .core.runtime import HumanInputRequired, NodePaused
25
+ from .core.tools import registry
26
+
27
+
28
+ def _has_human_node(graph) -> bool:
29
+ """True if ``graph`` contains a ``human:`` (pause) node — such a run is only
30
+ resumable with a durable checkpoint."""
31
+ return any(node.kind == KIND_HUMAN for node in graph.nodes.values())
32
+
33
+
34
+ def _resume_command(pipeline: str, spec: str, run_id: str) -> str:
35
+ """A copy-pasteable command to resume this paused run (the human fills in the answer)."""
36
+ return (
37
+ f"agentic-flow {pipeline} --checkpoint {spec} --run-id {run_id} "
38
+ f'--resume --human-input "<your answer>"'
39
+ )
40
+
41
+
42
+ def _print_pause(prompt: str, pipeline: str, spec: str, run_id: str) -> None:
43
+ """Surface a pause: the rendered prompt + how to resume. (Exit code 2 follows.)"""
44
+ print(f"\n[paused] run {run_id!r} awaits human input:")
45
+ print(f" {prompt}")
46
+ print(f" resume: {_resume_command(pipeline, spec, run_id)}")
47
+
48
+
49
+ def _resolve_human_input(args, parser) -> object | None:
50
+ """The human's answer from at most one of the three --human-input* flags.
51
+
52
+ Returns the string / parsed-JSON value / file contents, or ``None`` when none
53
+ is supplied (the "not provided" sentinel — resume then prompts on a TTY or
54
+ continues straight through for a crash-resume). More than one is a usage error.
55
+ """
56
+ given = [
57
+ name for name, value in (
58
+ ("--human-input", args.human_input),
59
+ ("--human-input-json", args.human_input_json),
60
+ ("--human-input-file", args.human_input_file),
61
+ ) if value is not None
62
+ ]
63
+ if len(given) > 1:
64
+ parser.error(f"only one of {', '.join(given)} may be given")
65
+ if args.human_input is not None:
66
+ return args.human_input
67
+ if args.human_input_json is not None:
68
+ try:
69
+ return json.loads(args.human_input_json)
70
+ except json.JSONDecodeError as exc:
71
+ parser.error(f"--human-input-json is not valid JSON: {exc}")
72
+ if args.human_input_file is not None:
73
+ return Path(args.human_input_file).read_text(encoding="utf-8")
74
+ return None
75
+
76
+
77
+ def _run_to_exit(produce_state, *, pipeline: str, spec: str | None, run_id: str | None) -> int:
78
+ """Run ``produce_state()`` and map the engine's signals to a process exit code.
79
+
80
+ The single place the run/resume outcomes become exit codes: a ``human:`` pause
81
+ (``NodePaused``) or a non-interactive resume that still needs an answer
82
+ (``HumanInputRequired``) → 2 (print how to resume); any real failure → 1
83
+ (message on stderr); success → 0 (print the final state). Shared by all three
84
+ CLI run paths (plain, durable, resume) so the mapping lives once.
85
+ """
86
+ try:
87
+ state = produce_state()
88
+ except (NodePaused, HumanInputRequired) as paused:
89
+ _print_pause(paused.prompt, pipeline, spec, run_id)
90
+ return 2
91
+ except Exception as exc: # any real failure → exit 1
92
+ print(f"error: {exc}", file=sys.stderr)
93
+ return 1
94
+ print_final_state(state)
95
+ return 0
96
+
97
+
98
+ def main(argv: list[str] | None = None) -> int:
99
+ parser = argparse.ArgumentParser(prog="agentic-flow", description=__doc__.splitlines()[0])
100
+ parser.add_argument("pipeline", help="Path to a pipeline YAML file.")
101
+ parser.add_argument(
102
+ "-t", "--tools", action="append", default=[], metavar="MODULE",
103
+ help="Import a module that registers @tool functions (repeatable).",
104
+ )
105
+ parser.add_argument(
106
+ "--set", action="append", default=[], metavar="KEY=VALUE",
107
+ help="Seed an initial state value (repeatable).",
108
+ )
109
+ parser.add_argument(
110
+ "--dry-run", action="store_true",
111
+ help="Build and print the pipeline without calling any model.",
112
+ )
113
+ parser.add_argument(
114
+ "--log-level", metavar="LEVEL", default=None,
115
+ help="Trace through Python logging at this level (DEBUG/INFO/WARNING/…) "
116
+ "instead of the default console printer.",
117
+ )
118
+ parser.add_argument(
119
+ "--checkpoint", metavar="SPEC", default=None,
120
+ help="Persist the run durably via a named backend, e.g. 'json:./runs' or "
121
+ "'memory'. With no --run-id one is generated and printed.",
122
+ )
123
+ parser.add_argument(
124
+ "--run-id", metavar="ID", default=None,
125
+ help="Opaque key for the durable run (the checkpoint record key). "
126
+ "Required to --resume; auto-generated for a fresh --checkpoint run.",
127
+ )
128
+ parser.add_argument(
129
+ "--resume", action="store_true",
130
+ help="Continue an existing run from its checkpoint (needs --checkpoint + --run-id).",
131
+ )
132
+ parser.add_argument(
133
+ "--human-input", metavar="TEXT", default=None,
134
+ help="On --resume, the human's answer as a string.",
135
+ )
136
+ parser.add_argument(
137
+ "--human-input-json", metavar="JSON", default=None,
138
+ help="On --resume, the human's answer parsed as a JSON value (e.g. "
139
+ "'{\"approved\": true}').",
140
+ )
141
+ parser.add_argument(
142
+ "--human-input-file", metavar="PATH", default=None,
143
+ help="On --resume, read the human's answer from a file (large / multiline).",
144
+ )
145
+ args = parser.parse_args(argv)
146
+
147
+ for module in args.tools:
148
+ importlib.import_module(module)
149
+
150
+ # --log-level routes events through standard logging; otherwise use the console printer.
151
+ tracer = console_tracer()
152
+ if args.log_level:
153
+ level = getattr(logging, args.log_level.upper(), None)
154
+ if not isinstance(level, int):
155
+ parser.error(f"invalid --log-level {args.log_level!r} (use DEBUG/INFO/WARNING/ERROR)")
156
+ configure_logging(level)
157
+ tracer = event_logger()
158
+
159
+ orch = Orchestrator.from_yaml(
160
+ args.pipeline, registry, on_event=None if args.dry_run else tracer
161
+ )
162
+
163
+ if args.dry_run:
164
+ print(f"pipeline: {args.pipeline}")
165
+ for name, agent in orch.agents.items():
166
+ print(
167
+ f" agent {name}: provider={agent.provider.name} model={agent.model} "
168
+ f"tools={[t.name for t in agent.tools]}"
169
+ )
170
+ print(render_graph(orch.graph))
171
+ return 0
172
+
173
+ # --- RESUME: continue a checkpointed run; _run_to_exit maps the outcome ---
174
+ if args.resume:
175
+ if not args.checkpoint or not args.run_id:
176
+ parser.error("--resume requires --checkpoint and --run-id")
177
+ human = _resolve_human_input(args, parser) # at most one --human-input*; None if absent
178
+ cp = get_checkpointer(args.checkpoint)
179
+
180
+ def _resume() -> object:
181
+ nonlocal human
182
+ while True:
183
+ try:
184
+ return orch.resume(args.run_id, cp, human_input=human)
185
+ except HumanInputRequired as need:
186
+ # pending pause, no answer given. Non-interactive: re-raise so
187
+ # _run_to_exit prints how to resume and exits 2. On a TTY: prompt
188
+ # and re-resume with the typed answer. A *subsequent* gate raises
189
+ # NodePaused → exit 2 (distinct pauses loop across invocations,
190
+ # not within one process — the stateless resume model).
191
+ if not sys.stdin.isatty():
192
+ raise
193
+ print(need.prompt)
194
+ human = input("> ")
195
+
196
+ return _run_to_exit(
197
+ _resume, pipeline=args.pipeline, spec=args.checkpoint, run_id=args.run_id
198
+ )
199
+
200
+ # A human: gate pauses the run; with no durable checkpoint the pause cannot be
201
+ # resumed (and a printed resume command would name a throwaway in-memory sink).
202
+ # Fail fast with guidance rather than pausing into an unrecoverable state.
203
+ if not args.checkpoint and _has_human_node(orch.graph):
204
+ parser.error(
205
+ "this pipeline has a human: gate and needs --checkpoint to be resumable "
206
+ "(e.g. --checkpoint memory, or --checkpoint json:./runs for a durable run)"
207
+ )
208
+
209
+ initial = {}
210
+ for item in args.set:
211
+ key, _, value = item.partition("=")
212
+ initial[key] = value
213
+
214
+ # --- RUN with a checkpointer: durable run (pause → exit 2, completion → exit 0) ---
215
+ if args.checkpoint:
216
+ cp = get_checkpointer(args.checkpoint)
217
+ run_id = args.run_id or uuid.uuid4().hex
218
+ if not args.run_id:
219
+ print(f"run-id: {run_id}") # generated — print it so the user can resume
220
+ return _run_to_exit(
221
+ lambda: orch.run(initial, run_id=run_id, checkpointer=cp),
222
+ pipeline=args.pipeline, spec=args.checkpoint, run_id=run_id,
223
+ )
224
+
225
+ # --- RUN with no checkpointer: no I/O, run_id unnecessary ---
226
+ return _run_to_exit(
227
+ lambda: orch.run(initial), pipeline=args.pipeline, spec=None, run_id=None
228
+ )
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())
@@ -0,0 +1,114 @@
1
+ """The built-in console sink for the ``on_event`` seam.
2
+
3
+ A run reports progress through a single ``on_event(event, data)`` callback. This
4
+ module turns that stream into human-readable console lines — the **one** place the
5
+ event taxonomy is rendered for a terminal, shared by the CLI (``agentic-flow …``)
6
+ and every example's ``run.py``. Its sibling sink, :func:`agentic_flow.event_logger`,
7
+ routes the same events through Python :mod:`logging` (with per-event severity
8
+ instead of layout); both trim long values through :func:`clip`.
9
+
10
+ The event taxonomy this renders (the ``data`` keys each event carries — the graph
11
+ runtime's :func:`agentic_flow.runtime._emit` calls are the source of truth):
12
+
13
+ * ``node_start`` — ``node`` + ``kind`` (the node's body kind: agent/tool/human/subgraph)
14
+ * ``node_input`` — ``node`` + ``kind`` + ``input`` (the rendered prompt str / args dict; not rendered here — layer-2 tracing)
15
+ * ``node_end`` — ``node`` + ``output``
16
+ * ``node_error`` — ``node`` + ``error`` (exception class) + ``message`` (not rendered here — layer-2 tracing)
17
+ * ``checkpoint`` — ``run_id`` + ``frontier`` (the ready node ids saved at this boundary)
18
+ * ``human`` — ``node`` + ``output`` [+ ``prompt`` (pause) | ``resumed`` (resume)]
19
+ * ``route`` — ``src`` + ``dst`` + ``seq`` (a fresh model-driven authority-L1 route choice)
20
+ * ``replan`` — ``replans_used`` (an accepted authority-L2 graph amendment)
21
+ * ``replan_rejected`` — ``gate`` + ``message`` (the validation gate the planner's batch failed)
22
+ * ``tool_call`` — ``agent`` + ``tool`` + ``input``
23
+ * ``tool_result`` — ``agent`` + ``tool`` + ``result`` + ``is_error``
24
+ * ``messages_delivered`` — ``agent`` + ``count`` + ``todos``
25
+ * ``retry`` — ``label`` + ``attempt`` + ``attempts`` + ``error`` [+ ``sleep``]
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from typing import Any, Callable
31
+
32
+ EventHook = Callable[[str, dict[str, Any]], None]
33
+
34
+
35
+ def clip(value: Any, limit: int = 200) -> str:
36
+ """Collapse whitespace and truncate ``value`` to ``limit`` chars for a one-line trace."""
37
+ text = " ".join(str(value).split())
38
+ return text if len(text) <= limit else text[:limit] + "…"
39
+
40
+
41
+ def _render(value: Any) -> str:
42
+ """Pretty-print a structured (dict/list) output; leave text as-is."""
43
+ if isinstance(value, (dict, list)):
44
+ return json.dumps(value, indent=2, ensure_ascii=False)
45
+ return str(value)
46
+
47
+
48
+ def _indent(text: str, prefix: str = " ") -> str:
49
+ return "\n".join(prefix + line for line in str(text).splitlines())
50
+
51
+
52
+ def console_tracer(*, show_output: bool = False) -> EventHook:
53
+ """Return an ``on_event`` hook that prints each event to stdout.
54
+
55
+ Pass ``show_output=True`` to also print each node's output value inline,
56
+ pretty-printing ``dict``/``list`` results (handy for examples). The CLI leaves
57
+ it ``False`` and prints the final state once at the end via
58
+ :func:`print_final_state` instead.
59
+ """
60
+
61
+ def on_event(event: str, data: dict[str, Any]) -> None:
62
+ if event == "node_start":
63
+ print(f"\n=== node '{data['node']}' ({data['kind']}) ===")
64
+ elif event == "checkpoint":
65
+ frontier = ", ".join(data.get("frontier") or []) or "(empty)"
66
+ print(f" ~ checkpoint {data['run_id']} | frontier: {frontier}")
67
+ elif event == "human":
68
+ prompt = f": {clip(data['prompt'])}" if data.get("prompt") else ""
69
+ verb = "resumed" if data.get("resumed") else "paused"
70
+ print(f" ? human {verb} at '{data['node']}'{prompt}")
71
+ elif event == "replan":
72
+ print(f" * replan accepted (replans used: {data['replans_used']})")
73
+ elif event == "replan_rejected":
74
+ print(f" * replan rejected at gate '{data['gate']}': {clip(data['message'])}")
75
+ elif event == "route":
76
+ print(f" > route {data['src']} → {data['dst']}")
77
+ elif event == "tool_call":
78
+ print(f" [{data['agent']}] -> {data['tool']}({clip(data['input'])})")
79
+ elif event == "tool_result":
80
+ tag = "ERROR" if data["is_error"] else "ok"
81
+ print(f" [{data['agent']}] <- {data['tool']} [{tag}]: {clip(data['result'])}")
82
+ elif event == "messages_delivered":
83
+ bits = []
84
+ if data.get("count"):
85
+ bits.append(f"{data['count']} message(s)")
86
+ if data.get("todos"):
87
+ bits.append(f"{data['todos']} todo(s)")
88
+ print(f" [{data['agent']}] inbox: {', '.join(bits) or 'nothing'} delivered")
89
+ elif event == "retry":
90
+ sleep = f"; sleeping {data['sleep']}s" if data.get("sleep") is not None else ""
91
+ print(
92
+ f" ! retry {data['label']} ({data['attempt']}/{data['attempts']}) "
93
+ f"after {data['error']}{sleep}"
94
+ )
95
+ elif event == "node_end":
96
+ if show_output:
97
+ print(f" = node {data['node']!r} done:\n{_indent(_render(data['output']))}")
98
+ else:
99
+ print(f" = node {data['node']!r} done")
100
+
101
+ return on_event
102
+
103
+
104
+ def print_final_state(state: Any) -> None:
105
+ """Print the final shared state: store keys, the message log, and the todo board."""
106
+ print("\n=== final state ===")
107
+ for key in state:
108
+ print(f" {key}: {clip(state[key])}")
109
+ if state.log:
110
+ print(f" messages: {state.log}")
111
+ if state.todos:
112
+ print(" todos:")
113
+ for t in state.todos:
114
+ print(f" [{t['status']:>11}] #{t['id']} ({t['owner'] or '-'}) {t['text']}")
@@ -0,0 +1,6 @@
1
+ """The core engine: the self-sufficient graph executor (layers 0+1).
2
+
3
+ Given a ``Graph``, a ``Store``, agents, and tools, ``core.runtime.run(...)``
4
+ executes and checkpoints with no layer-2 module imported. Imports nothing
5
+ upward (ADR 0004) — the one-way boundary enforced by ``tests/test_layering.py``.
6
+ """