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.
- agentic_dev_loop/__init__.py +1 -0
- agentic_dev_loop/actions.py +189 -0
- agentic_dev_loop/agents.py +49 -0
- agentic_dev_loop/cli.py +82 -0
- agentic_dev_loop/config.py +156 -0
- agentic_dev_loop/extensions/__init__.py +0 -0
- agentic_dev_loop/extensions/aidlc.py +152 -0
- agentic_dev_loop/graph.py +275 -0
- agentic_dev_loop/llm.py +60 -0
- agentic_dev_loop/loaders.py +60 -0
- agentic_dev_loop/outputs.py +147 -0
- agentic_dev_loop/proc.py +54 -0
- agentic_dev_loop/py.typed +0 -0
- agentic_dev_loop/registry.py +133 -0
- agentic_dev_loop/results.py +41 -0
- agentic_dev_loop/stages.py +423 -0
- agentic_dev_loop/viz.py +1148 -0
- agentic_dev_loop-0.2.0.dist-info/METADATA +316 -0
- agentic_dev_loop-0.2.0.dist-info/RECORD +23 -0
- agentic_dev_loop-0.2.0.dist-info/WHEEL +5 -0
- agentic_dev_loop-0.2.0.dist-info/entry_points.txt +2 -0
- agentic_dev_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
- agentic_dev_loop-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"}
|