agentic-dev-loop 0.2.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.
@@ -0,0 +1,133 @@
1
+ """Extension registry — the seam between core and consumer/bundled extensions.
2
+
3
+ Core pre-registers the built-ins (action `commit`, document loader `plan`);
4
+ everything else arrives via `load_extensions` from the pipeline's
5
+ `extensions:` list — `module:Class` strings, explicit only, no
6
+ auto-discovery.
7
+ """
8
+
9
+ import argparse
10
+ import importlib
11
+ from collections.abc import Callable
12
+ from typing import Protocol
13
+
14
+
15
+ class DocumentLoader(Protocol):
16
+ """Supplies the pipeline's doc bundle + subject. Selected by its CLI
17
+ flag at invocation; `defaultable` loaders may also run with no flag."""
18
+
19
+ name: str
20
+ defaultable: bool
21
+
22
+ def add_cli_args(self, parser: argparse.ArgumentParser) -> None: ...
23
+
24
+ def resolve(self, args: argparse.Namespace) -> tuple[dict[str, str], str]:
25
+ """Return (docs, subject). Empty docs fail fast at the call site."""
26
+ ...
27
+
28
+
29
+ class Extension(Protocol):
30
+ name: str
31
+
32
+ def register(self, registry: "Registry") -> None: ...
33
+
34
+
35
+ def _contributed_options(loader: DocumentLoader) -> list[tuple[str, str]]:
36
+ """The (option string, dest) pairs a loader would add — probed with a
37
+ throwaway parser so collisions are caught at load time, not parse time,
38
+ and the selection flow can map parsed args back to their loader."""
39
+ probe = argparse.ArgumentParser(add_help=False)
40
+ loader.add_cli_args(probe)
41
+ return [(opt, action.dest) for action in probe._actions
42
+ for opt in action.option_strings]
43
+
44
+
45
+ class Registry:
46
+ def __init__(self) -> None:
47
+ self.actions: dict[str, Callable] = {}
48
+ self.document_loaders: list[DocumentLoader] = []
49
+ self.cli_option_owners: dict[str, str] = {} # option string -> loader name
50
+ self.cli_dests: dict[str, str] = {} # argparse dest -> loader name
51
+ self._loading_extension: str | None = None
52
+
53
+ def add_action(self, name: str, fn: Callable) -> None:
54
+ """Register a node-shaped action: (state, params) -> state update."""
55
+ if name in self.actions:
56
+ raise ValueError(f"Duplicate action name {name!r}")
57
+ self.actions[name] = fn
58
+
59
+ def add_document_loader(self, loader: DocumentLoader) -> None:
60
+ pairs = _contributed_options(loader)
61
+ for opt, _dest in pairs:
62
+ owner = self.cli_option_owners.get(opt)
63
+ if owner is not None:
64
+ raise ValueError(
65
+ f"CLI option {opt!r} is contributed by both document "
66
+ f"loader {owner!r} and {loader.name!r}")
67
+ for opt, dest in pairs:
68
+ self.cli_option_owners[opt] = loader.name
69
+ self.cli_dests[dest] = loader.name
70
+ self.document_loaders.append(loader)
71
+
72
+ def add_tool(self, name: str, tool: type) -> None:
73
+ """Register an SDK tool class (agent frontmatter names resolve
74
+ against the SDK's global registry). The name is validated first
75
+ (BR-3): a non-empty identifier that does not collide with any name
76
+ already in the SDK registry — the SDK silently overwrites duplicates,
77
+ so agentic-dev-loop rejects before ``register_tool`` ever runs."""
78
+ from openhands.sdk.tool import register_tool
79
+ from openhands.sdk.tool import registry as sdk_registry
80
+
81
+ if not name or name != name.strip():
82
+ raise ValueError(
83
+ f"tool name {name!r} is not a valid identifier — a non-empty "
84
+ "name with no leading/trailing whitespace is required")
85
+ if name in sdk_registry.list_registered_tools():
86
+ existing = sdk_registry.get_tool_module_qualnames().get(name)
87
+ existing = f" (already owned by {existing!r})" if existing else ""
88
+ owner = self._loading_extension
89
+ if owner is not None:
90
+ raise ValueError(
91
+ f"extension {owner!r} tried to register tool {name!r}, which "
92
+ f"is already in the SDK registry{existing} — refusing to "
93
+ "silently overwrite it")
94
+ raise ValueError(
95
+ f"tool name {name!r} is already in the SDK registry{existing} — "
96
+ "refusing to silently overwrite it")
97
+ register_tool(name, tool)
98
+
99
+
100
+ def default_registry() -> Registry:
101
+ """Core built-ins: action `commit`, document loader `plan`
102
+ (flag `--plan PATH`)."""
103
+ from . import actions as _actions
104
+ from .loaders import PlanDocumentLoader
105
+
106
+ reg = Registry()
107
+ reg.add_action("commit", _actions.commit)
108
+ reg.add_document_loader(PlanDocumentLoader())
109
+ return reg
110
+
111
+
112
+ def load_extensions(refs: list[str], registry: Registry) -> None:
113
+ """Import, instantiate, and register each `module:Class` ref. Any failure
114
+ is a loud error naming the ref."""
115
+ for ref in refs:
116
+ module_name, sep, class_name = ref.partition(":")
117
+ if not sep or not module_name or not class_name:
118
+ raise ValueError(
119
+ f"Invalid extension ref {ref!r} — expected 'module:Class'")
120
+ try:
121
+ module = importlib.import_module(module_name)
122
+ cls = getattr(module, class_name)
123
+ except (ImportError, AttributeError) as e:
124
+ raise ValueError(f"Cannot load extension {ref!r}: {e}") from e
125
+ try:
126
+ ext = cls()
127
+ except Exception as e:
128
+ raise ValueError(f"Cannot instantiate extension {ref!r}: {e}") from e
129
+ registry._loading_extension = getattr(ext, "name", None)
130
+ try:
131
+ ext.register(registry)
132
+ finally:
133
+ registry._loading_extension = None
@@ -0,0 +1,41 @@
1
+ """Typed result value object.
2
+
3
+ Expected conditions are modelled as values, never exceptions (DDD rule):
4
+ callers decide routing based on the error code. `unwrap()` exists for
5
+ programming errors only — calling it on an error is a bug.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Generic, TypeVar
12
+
13
+ T = TypeVar("T")
14
+ E = TypeVar("E")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Result(Generic[T, E]):
19
+ """Exactly one of `value` / `error` is meaningful; see factory methods."""
20
+
21
+ value: T | None
22
+ error: E | None
23
+
24
+ @staticmethod
25
+ def ok(value: T | None) -> "Result[T, E]":
26
+ return Result(value=value, error=None)
27
+
28
+ @staticmethod
29
+ def err(error: E) -> "Result[T, E]":
30
+ return Result(value=None, error=error)
31
+
32
+ def is_ok(self) -> bool:
33
+ return self.error is None
34
+
35
+ def is_err(self) -> bool:
36
+ return self.error is not None
37
+
38
+ def unwrap(self) -> T:
39
+ if self.error is not None:
40
+ raise ValueError(f"unwrap called on an error Result: {self.error!r}")
41
+ return self.value
@@ -0,0 +1,423 @@
1
+ """Stage nodes, routers, and the run_agent port.
2
+
3
+ The yosk loop's hardcoded stages become per-step closures built from the
4
+ pipeline config; the fixed policies stay here, not in config:
5
+
6
+ - per-attempt hygiene: every declared artifact/produces file + last-error.md
7
+ is deleted at attempt start, so stale files can't be misread
8
+ - capture-first feedback: the pipeline captures each agent's final response
9
+ and writes the artifact itself — agents no longer file their own reports
10
+ - verdict-first truncation: a clear verdict stands even if the run was cut
11
+ short before the agent formally finished
12
+ - empty response / unclear verdict → implicit, attempt-bounded retry with the
13
+ captured text surfaced into notes (never routable — policy, not config)
14
+ - notes accumulate across attempts into the next attempt's context
15
+ - command steps are mechanical: the exit code is the verdict (0 ⇒ pass,
16
+ anything else ⇒ fail), a timeout kills the whole process group, and the
17
+ output tail rides into the retry note — "unclear" is impossible
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import signal
24
+ import subprocess
25
+ import sys
26
+ import traceback
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING
30
+
31
+ from langgraph.graph import END
32
+ from openhands.sdk import Conversation
33
+
34
+ from .agents import load_agent
35
+ from .config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
36
+ from .outputs import CapturedOutput, Verdict
37
+ from .proc import child_env, needs_shell, split_command
38
+ from .registry import Registry
39
+
40
+ if TYPE_CHECKING:
41
+ from openhands.sdk import LLM
42
+
43
+ DEFAULT_MAX_ITERATIONS = 500
44
+ DEFAULT_COMMAND_TIMEOUT = 600.0 # seconds — a pytest-class suite budget
45
+ LAST_ERROR = "last-error.md"
46
+
47
+
48
+ @dataclass
49
+ class Runtime:
50
+ """Everything stage nodes need that isn't in LoopState: pipeline config,
51
+ extension registry, the shared LLM, and resolved (absolute) paths."""
52
+ cfg: PipelineConfig
53
+ registry: Registry
54
+ llm: "LLM"
55
+ agents_dir: Path
56
+ state_dir: Path
57
+ workspace: Path
58
+
59
+
60
+ def prompt_dir(runtime: Runtime) -> str:
61
+ """state_dir as agents must address it. Agents resolve prompt paths
62
+ against their workspace, so a bare `state_dir.name` only reaches the real
63
+ state_dir when the pipeline file sits at the workspace root — yosk runs
64
+ agentic-dev-loop/pipeline.yaml from the repo root, where `.pr` and
65
+ `agentic-dev-loop/.pr` diverge. Workspace-relative when under the workspace,
66
+ the absolute path when outside it."""
67
+ try:
68
+ return str(runtime.state_dir.relative_to(runtime.workspace))
69
+ except ValueError:
70
+ return str(runtime.state_dir)
71
+
72
+
73
+ @dataclass
74
+ class AgentResult:
75
+ """Outcome of a single agent stage run.
76
+
77
+ ok=False only on unhandled exceptions. truncated=True when the run ended
78
+ without the agent reaching FINISHED — the turn cap (ERROR) or the SDK's
79
+ stuck detector (STUCK) both land here. response carries the captured final
80
+ agent message (capture-applying steps only); capture_error is set when the
81
+ agent finished but produced no final response ("empty_response")."""
82
+ ok: bool = False
83
+ truncated: bool = False
84
+ response: str | None = None
85
+ capture_error: str | None = None
86
+
87
+
88
+ def run_agent(agent_name: str, message: str, runtime: Runtime,
89
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
90
+ step: StepConfig | None = None) -> AgentResult:
91
+ """Run one agent stage. The agent file's body is delivered as a system
92
+ message suffix (AgentContext); the user turn carries only the task.
93
+ Any failure — setup or run — is reported to <state_dir>/last-error.md.
94
+
95
+ For steps declaring outputs (produces/artifact), the final response is
96
+ captured from conv.state.events (before conv.close()) and written by the
97
+ pipeline to the declared artifact path — the agent never files its own
98
+ report."""
99
+ conv = None
100
+ try:
101
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
102
+
103
+ agent = load_agent(runtime.agents_dir, agent_name, runtime.llm)
104
+ conv = Conversation(agent=agent, workspace=str(runtime.workspace),
105
+ max_iteration_per_run=max_iterations)
106
+ conv.send_message(message)
107
+ conv.run()
108
+
109
+ truncated = conv.state.execution_status != ConversationExecutionStatus.FINISHED
110
+
111
+ if step is not None and (step.artifact is not None or step.produces):
112
+ artifact = step.artifact if step.artifact is not None else step.produces[0]
113
+ captured = CapturedOutput.from_agent_result(conv, artifact, runtime.state_dir)
114
+ if captured.is_err():
115
+ return AgentResult(ok=True, truncated=truncated,
116
+ capture_error=captured.error.value)
117
+ write_result = captured.unwrap().write()
118
+ if write_result.is_err():
119
+ raise RuntimeError(
120
+ f"captured output write failed: {write_result.error.value}")
121
+ return AgentResult(ok=True, truncated=truncated,
122
+ response=captured.unwrap().raw_text)
123
+ return AgentResult(ok=True, truncated=truncated)
124
+ except Exception:
125
+ err = traceback.format_exc()
126
+ print(err[-4000:], file=sys.stderr, flush=True)
127
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
128
+ error_path = runtime.state_dir / LAST_ERROR
129
+ error_path.write_text(f"# Agent run failed\n\n```\n{err[-4000:]}\n```")
130
+ print(f"Agent run failed — error details written to {error_path}", flush=True)
131
+ return AgentResult(ok=False, truncated=False)
132
+ finally:
133
+ if conv is not None:
134
+ conv.close()
135
+
136
+
137
+ def _run_command(command: str, workspace: Path, timeout: float,
138
+ env: dict[str, str] | None = None) -> tuple[int | None, str, bool]:
139
+ """Run a command step in the workspace, capturing combined stdout/stderr.
140
+
141
+ A command without shell metacharacters is ``shlex``-split and run
142
+ argv-style (``shell=False``); one that uses them runs via the shell
143
+ (``shell=True``) — backward compatible. Both paths get the scrubbed child
144
+ environment built by ``child_env`` (BR-4).
145
+
146
+ Returns ``(exit_code, output, timed_out)``. ``exit_code`` is None when the
147
+ command was SIGKILLed for exceeding ``timeout``; the whole process group is
148
+ killed (``start_new_session=True``) so grandchildren can't outlive the
149
+ step."""
150
+ if needs_shell(command):
151
+ argv: str | list[str] = command
152
+ shell = True
153
+ else:
154
+ argv = split_command(command)
155
+ shell = False
156
+ proc = subprocess.Popen(
157
+ argv, cwd=workspace, shell=shell, env=env, start_new_session=True,
158
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
159
+ try:
160
+ out, _ = proc.communicate(timeout=timeout)
161
+ return proc.returncode, out.decode("utf-8", "replace"), False
162
+ except subprocess.TimeoutExpired:
163
+ os.killpg(proc.pid, signal.SIGKILL)
164
+ out, _ = proc.communicate()
165
+ return None, out.decode("utf-8", "replace"), True
166
+
167
+
168
+ def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
169
+ notes: list[str] | None = None) -> str:
170
+ """Assemble the attempt's shared message: subject, doc bundle, the
171
+ expected-output-paths section generated from step defs (a prompt merely
172
+ naming files lets agents skip them), and previous-attempt notes."""
173
+ parts = [f"You are working on {subject}.\n"]
174
+ for name, content in docs.items():
175
+ parts.append(f"## {name}\n\n{content}")
176
+ lines = []
177
+ for step in runtime.cfg.steps:
178
+ if step.agent is None:
179
+ continue
180
+ for produced in step.produces:
181
+ lines.append(f"{step.name} agent writes: `{prompt_dir(runtime)}/{produced}`")
182
+ if step.artifact is not None:
183
+ lines.append(f"{step.name} agent writes: `{prompt_dir(runtime)}/{step.artifact}`")
184
+ if lines:
185
+ parts.append("## Expected output paths\n\n" + "\n".join(lines))
186
+ if notes:
187
+ parts.append("## Previous attempt feedback\n\n" + "\n\n".join(notes))
188
+ return "\n\n".join(parts)
189
+
190
+
191
+ def declared_artifacts(cfg: PipelineConfig) -> list[str]:
192
+ files = []
193
+ for step in cfg.steps:
194
+ files.extend(step.produces)
195
+ if step.artifact is not None:
196
+ files.append(step.artifact)
197
+ return files
198
+
199
+
200
+ def clear_attempt_artifacts(runtime: Runtime) -> None:
201
+ """Per-attempt hygiene (fixed policy): delete every declared
202
+ artifact/produces file + last-error.md so stale files from earlier
203
+ attempts or runs can't be mistaken for this attempt's output."""
204
+ for name in [*declared_artifacts(runtime.cfg), LAST_ERROR]:
205
+ (runtime.state_dir / name).unlink(missing_ok=True)
206
+
207
+
208
+ def _unclear_target(runtime: Runtime, step: StepConfig) -> str:
209
+ """Where the unclear-verdict retry loops to: on_fail's goto when that is
210
+ a step, else the entry step (yosk always looped back to dev)."""
211
+ target = route_target(step.on_fail)
212
+ if target is not None and target not in TERMINALS:
213
+ return target
214
+ return runtime.cfg.steps[0].name
215
+
216
+
217
+ def _unclear_note(runtime: Runtime, step: StepConfig, attempt: int,
218
+ text: str | None, truncated: bool, max_iterations: int,
219
+ error: str | None = None) -> str:
220
+ """Explain an unclear verdict to the next attempt — and surface the
221
+ captured response when it exists, so real findings are never swallowed."""
222
+ expected = ""
223
+ if step.verdicts is not None:
224
+ expected = (f" (expected `VERDICT: {step.verdicts.pass_}` or "
225
+ f"`VERDICT: {step.verdicts.fail}` on its own line)")
226
+ if truncated:
227
+ lead = (f"{step.name} agent was cut short on attempt {attempt} "
228
+ f"(hit the {max_iterations}-turn limit or was stopped "
229
+ f"by stuck detection)")
230
+ if text is not None:
231
+ return (f"## {step.name} output (attempt {attempt} — truncated)\n\n"
232
+ f"{lead}. Its findings so far:\n\n{text}")
233
+ return f"{lead} and produced no final response."
234
+ if text is None:
235
+ return f"{step.name} agent produced no final response on attempt {attempt}."
236
+ if error == "ambiguous":
237
+ return (f"{step.name} agent's final response on attempt {attempt} carries "
238
+ f"conflicting verdicts{expected}. Its findings so far:\n\n{text}")
239
+ return (f"{step.name} agent's final response on attempt {attempt} contains no "
240
+ f"verdict{expected}. Its findings so far:\n\n{text}")
241
+
242
+
243
+ def make_agent_node(runtime: Runtime, step: StepConfig):
244
+ """Node closure for an agent step. The pipeline captures the final
245
+ response and writes the declared artifact itself; verdict steps parse the
246
+ captured text (decoration-tolerant) and record pass/fail/None into
247
+ step_verdicts. A finished agent with no final response, and a verdict the
248
+ parser can't pin down, both fall into the attempt-bounded unclear retry."""
249
+ verdicts_map = None
250
+ if step.verdicts is not None:
251
+ verdicts_map = {step.verdicts.pass_: "pass", step.verdicts.fail: "fail"}
252
+ captures = step.artifact is not None or bool(step.produces)
253
+
254
+ def node(state: dict) -> dict:
255
+ attempt = state["attempt"]
256
+ subject = state["subject"]
257
+ max_iterations = step.max_iterations or DEFAULT_MAX_ITERATIONS
258
+ print(f"[{subject}] Running {step.agent} agent...", flush=True)
259
+ result = run_agent(step.agent, state["context"], runtime,
260
+ max_iterations=max_iterations, step=step)
261
+ if not result.ok:
262
+ print(f"[{subject}] {step.name} agent failed on attempt {attempt}.", flush=True)
263
+ return {"failed": True, "failed_step": step.name}
264
+
265
+ if result.capture_error is not None:
266
+ print(f"[{subject}] {step.name} agent produced no final response on "
267
+ f"attempt {attempt}, retrying.", flush=True)
268
+ merged = {**state["step_verdicts"], step.name: None}
269
+ return {"step_verdicts": merged,
270
+ "notes": [_unclear_note(runtime, step, attempt, None,
271
+ result.truncated, max_iterations)],
272
+ "retry_target": _unclear_target(runtime, step)}
273
+
274
+ if verdicts_map is None:
275
+ merged = {**state["step_verdicts"], step.name: "pass"} if captures else \
276
+ state["step_verdicts"]
277
+ return {"step_verdicts": merged, "failed": False}
278
+
279
+ parsed = Verdict.from_text(result.response or "", verdicts_map)
280
+ merged = {**state["step_verdicts"], step.name: None}
281
+ if parsed.is_ok():
282
+ verdict = parsed.unwrap().value
283
+ merged[step.name] = verdict
284
+ if verdict == "pass":
285
+ print(f"[{subject}] {step.name} VERDICT: PASS on attempt {attempt}.",
286
+ flush=True)
287
+ return {"step_verdicts": merged, "failed": False}
288
+ print(f"[{subject}] {step.name} verdict: fail on attempt {attempt}.",
289
+ flush=True)
290
+ update = {"step_verdicts": merged,
291
+ "notes": [f"## {step.name} output (attempt {attempt})\n\n"
292
+ f"{result.response}"]}
293
+ if isinstance(step.on_fail, Route) and step.on_fail.retry:
294
+ update["retry_target"] = step.on_fail.goto
295
+ return update
296
+
297
+ print(f"[{subject}] {step.name} verdict unclear on attempt {attempt}, retrying.",
298
+ flush=True)
299
+ return {"step_verdicts": merged,
300
+ "notes": [_unclear_note(runtime, step, attempt, result.response,
301
+ result.truncated, max_iterations,
302
+ parsed.error.value)],
303
+ "retry_target": _unclear_target(runtime, step)}
304
+
305
+ return node
306
+
307
+
308
+ def make_command_node(runtime: Runtime, step: StepConfig):
309
+ """Node closure for a mechanical command step. The exit code is the sole
310
+ verdict: 0 ⇒ pass, anything else (nonzero, signal-killed, not-found, or
311
+ killed for exceeding timeout) ⇒ fail. Output is printed and its tail rides
312
+ into the retry note — mirroring the agent FAIL branch, so a retried dev
313
+ agent sees exactly what the gate emitted."""
314
+ timeout = step.timeout if step.timeout is not None else DEFAULT_COMMAND_TIMEOUT
315
+ env = child_env(runtime.cfg.env_allowlist, runtime.cfg.llm.api_key_env)
316
+
317
+ def node(state: dict) -> dict:
318
+ attempt = state["attempt"]
319
+ subject = state["subject"]
320
+ print(f"[{subject}] Running command `{step.command}`...", flush=True)
321
+ try:
322
+ exit_code, output, timed_out = _run_command(
323
+ step.command, runtime.workspace, timeout, env)
324
+ except Exception:
325
+ print(traceback.format_exc()[-4000:], file=sys.stderr, flush=True)
326
+ return {"failed": True, "failed_step": step.name}
327
+ if output:
328
+ print(output, end="" if output.endswith("\n") else "\n", flush=True)
329
+ merged = {**state["step_verdicts"],
330
+ step.name: "pass" if exit_code == 0 else "fail"}
331
+ if exit_code == 0:
332
+ print(f"[{subject}] {step.name} command passed on attempt {attempt}.",
333
+ flush=True)
334
+ return {"step_verdicts": merged, "failed": False}
335
+ print(f"[{subject}] {step.name} command failed on attempt {attempt}.",
336
+ flush=True)
337
+ label = f"attempt {attempt} — timed out" if timed_out else f"attempt {attempt}"
338
+ update = {"step_verdicts": merged,
339
+ "notes": [f"## {step.name} output ({label})\n\n{output[-4000:]}"]}
340
+ if isinstance(step.on_fail, Route) and step.on_fail.retry:
341
+ update["retry_target"] = step.on_fail.goto
342
+ return update
343
+
344
+ return node
345
+
346
+
347
+ def make_action_node(runtime: Runtime, step: StepConfig):
348
+ """Node closure for an action step: invoke the registered action with the
349
+ step's params. An action exception fails the run (same as an agent's)."""
350
+ fn = runtime.registry.actions[step.action]
351
+
352
+ def node(state: dict) -> dict:
353
+ try:
354
+ return fn(state, step.params) or {}
355
+ except Exception:
356
+ print(traceback.format_exc()[-4000:], file=sys.stderr, flush=True)
357
+ return {"failed": True, "failed_step": step.name}
358
+
359
+ return node
360
+
361
+
362
+ def make_router(runtime: Runtime, step: StepConfig):
363
+ """Router closure — a pure function of state, so control flow is
364
+ deterministic by construction. Return values are node names (resolved
365
+ dynamically by LangGraph), "bump", "success", or END (failure)."""
366
+ order = [s.name for s in runtime.cfg.steps]
367
+
368
+ def resolve(route) -> str:
369
+ target = route_target(route)
370
+ if target is None: # omitted on_pass: next step, or success at the end
371
+ idx = order.index(step.name)
372
+ return order[idx + 1] if idx + 1 < len(order) else "success"
373
+ if target == "failure":
374
+ return END
375
+ return target # a step name, or the success node
376
+
377
+ def router(state: dict) -> str:
378
+ if state["failed"]:
379
+ return END
380
+ if step.verdicts is None and step.command is None:
381
+ # Capture-applying steps without verdicts (produces-only) retry on
382
+ # empty responses: the node signals it by recording None plus a
383
+ # retry_target. Steps that don't capture never retry here.
384
+ if ((step.artifact is not None or step.produces)
385
+ and state["step_verdicts"].get(step.name) is None
386
+ and state.get("retry_target")):
387
+ return "bump" if state["attempt"] < state["max_attempts"] else END
388
+ return resolve(step.on_pass)
389
+ verdict = state["step_verdicts"].get(step.name)
390
+ if verdict == "pass":
391
+ return resolve(step.on_pass)
392
+ if verdict == "fail":
393
+ if step.on_fail is None:
394
+ return END
395
+ if isinstance(step.on_fail, Route) and step.on_fail.retry:
396
+ return "bump" if state["attempt"] < state["max_attempts"] else END
397
+ return resolve(step.on_fail)
398
+ # Unclear — implicit retry, attempt-bounded (fixed policy).
399
+ return "bump" if state["attempt"] < state["max_attempts"] else END
400
+
401
+ return router
402
+
403
+
404
+ def make_bump_node(runtime: Runtime):
405
+ """Close the retry cycle: consume one attempt, clear per-attempt
406
+ artifacts, and rebuild the context with the accumulated notes — the
407
+ attempt-start duties yosk's dev node owned, generalized to any target."""
408
+
409
+ def node(state: dict) -> dict:
410
+ clear_attempt_artifacts(runtime)
411
+ context = build_context(runtime, state["docs"], state["subject"],
412
+ state["notes"])
413
+ return {"attempt": state["attempt"] + 1,
414
+ "step_verdicts": {},
415
+ "context": context,
416
+ "failed": False}
417
+
418
+ return node
419
+
420
+
421
+ def success_node(_state: dict) -> dict:
422
+ """The success terminal as a real node — any step may route here."""
423
+ return {"outcome": "success"}