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 @@
|
|
|
1
|
+
"""Opinionated LangGraph engine for OpenHands agent pipelines."""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Built-in pipeline actions. Node-shaped: (state, params) -> state update.
|
|
2
|
+
|
|
3
|
+
`commit` policy (ported unchanged from yosk): agents never commit mid-run;
|
|
4
|
+
the pipeline commits exactly once, and only when the worktree was clean at
|
|
5
|
+
run start — a dirty-at-start run is committed by the human, so the
|
|
6
|
+
auto-commit can never swallow pre-existing unrelated changes (run 004: the
|
|
7
|
+
dev agent's b4acb62a swept up an unrelated .gitignore edit).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .proc import child_env, is_pid_token
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
21
|
+
"""Best-effort git invocation — callers inspect returncode/stdout rather
|
|
22
|
+
than raising, so a git problem never crashes a verified run. ``git`` is
|
|
23
|
+
resolved via ``shutil.which`` and run argv-style with a scrubbed child
|
|
24
|
+
environment (BR-5)."""
|
|
25
|
+
git = shutil.which("git")
|
|
26
|
+
if git is None:
|
|
27
|
+
return subprocess.CompletedProcess(["git", *args], 127, "", "")
|
|
28
|
+
return subprocess.run([git, *args], cwd=cwd, capture_output=True,
|
|
29
|
+
text=True, check=False, shell=False,
|
|
30
|
+
env=child_env())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def git_worktree_clean(path: Path) -> bool:
|
|
34
|
+
"""True only when `path` is a git repo with no uncommitted changes.
|
|
35
|
+
Outside a repo there is no clean baseline — False (fail-safe, keeps the
|
|
36
|
+
commit node disabled)."""
|
|
37
|
+
result = _git(["status", "--porcelain"], cwd=path)
|
|
38
|
+
return result.returncode == 0 and not result.stdout.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_cwd_output(out: str) -> str | None:
|
|
42
|
+
"""The cwd path from `lsof -a -p PID -d cwd -Fn` output, or None when the
|
|
43
|
+
payload doesn't carry one. Two formats occur in the wild: macOS prints
|
|
44
|
+
`p<PID>` / `fcwd` / `n<path>` (the path is the `n` line immediately after
|
|
45
|
+
`fcwd`); Linux omits the fd line and prints `p<PID>` / `n<path>` (the
|
|
46
|
+
path is the first `n` line)."""
|
|
47
|
+
lines = out.splitlines()
|
|
48
|
+
for i, line in enumerate(lines):
|
|
49
|
+
if line == "fcwd" and i + 1 < len(lines):
|
|
50
|
+
nxt = lines[i + 1]
|
|
51
|
+
if nxt.startswith("n"):
|
|
52
|
+
return nxt[1:]
|
|
53
|
+
if line.startswith("n"):
|
|
54
|
+
return line[1:]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pid_cwd(pid: str) -> str | None:
|
|
59
|
+
"""The working directory of `pid`, or None on any failure (lsof missing,
|
|
60
|
+
the process gone, or an unreadable cwd). Positive-only attribution: a pid
|
|
61
|
+
whose cwd can't be determined is spared by the caller, never killed.
|
|
62
|
+
Only a validated pid token reaches ``lsof`` (BR-5.2)."""
|
|
63
|
+
if not is_pid_token(pid):
|
|
64
|
+
return None
|
|
65
|
+
lsof = shutil.which("lsof")
|
|
66
|
+
if lsof is None:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
out = subprocess.run(
|
|
70
|
+
[lsof, "-a", "-p", pid, "-d", "cwd", "-Fn"],
|
|
71
|
+
capture_output=True, text=True, check=False, shell=False,
|
|
72
|
+
env=child_env()).stdout
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
return None
|
|
75
|
+
return _parse_cwd_output(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _listening_pids(port: int, match: str | None = None,
|
|
79
|
+
cwd: Path | str | None = None) -> list[str]:
|
|
80
|
+
"""PIDs of processes listening on `port`, optionally only those whose
|
|
81
|
+
command line contains `match` and whose working directory resolves to
|
|
82
|
+
`cwd`. The current process is always excluded — a pipeline must never
|
|
83
|
+
sweep its own listener (e.g. the live viz server), only orphaned
|
|
84
|
+
listeners left behind by earlier runs or agent subprocesses.
|
|
85
|
+
|
|
86
|
+
`cwd` is positive-only: a pid whose cwd can't be determined is spared,
|
|
87
|
+
so a multi-instance setup (two agentic-dev-loop runs from different
|
|
88
|
+
workspaces) never kills the other run's server.
|
|
89
|
+
|
|
90
|
+
Every token from ``lsof`` output is validated numeric before it can reach
|
|
91
|
+
``ps``/``kill`` (BR-5.2); binaries resolve via ``shutil.which`` and run
|
|
92
|
+
argv-style with a scrubbed environment (BR-5.1, BR-5.3, BR-5.4)."""
|
|
93
|
+
lsof = shutil.which("lsof")
|
|
94
|
+
if lsof is None:
|
|
95
|
+
return []
|
|
96
|
+
out = subprocess.run(
|
|
97
|
+
[lsof, "-nP", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
|
|
98
|
+
capture_output=True, text=True, check=False, shell=False,
|
|
99
|
+
env=child_env()).stdout
|
|
100
|
+
self_pid = str(os.getpid())
|
|
101
|
+
ps = shutil.which("ps")
|
|
102
|
+
pids = []
|
|
103
|
+
for token in out.split():
|
|
104
|
+
if not is_pid_token(token) or token == self_pid:
|
|
105
|
+
continue
|
|
106
|
+
if match is not None:
|
|
107
|
+
if ps is None:
|
|
108
|
+
continue
|
|
109
|
+
cmd = subprocess.run(
|
|
110
|
+
[ps, "-p", token, "-o", "command="],
|
|
111
|
+
capture_output=True, text=True, check=False, shell=False,
|
|
112
|
+
env=child_env()).stdout
|
|
113
|
+
if match not in cmd:
|
|
114
|
+
continue
|
|
115
|
+
if cwd is not None:
|
|
116
|
+
pid_cwd = _pid_cwd(token)
|
|
117
|
+
if pid_cwd is None or Path(pid_cwd).resolve() != Path(cwd).resolve():
|
|
118
|
+
continue
|
|
119
|
+
pids.append(token)
|
|
120
|
+
return pids
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def kill_listeners_on_port(port: int, match: str | None = None,
|
|
124
|
+
term_timeout: float = 3.0,
|
|
125
|
+
cwd: Path | str | None = None) -> None:
|
|
126
|
+
"""Kill processes listening on `port`, optionally only those whose command
|
|
127
|
+
line contains `match` and whose working directory resolves to `cwd`. A
|
|
128
|
+
stage that was cut short can orphan its server; the next attempt's server
|
|
129
|
+
would fail to bind and probes would hit the stale process serving a
|
|
130
|
+
previous attempt's build.
|
|
131
|
+
|
|
132
|
+
SIGTERM first, then SIGKILL whatever still holds the port after
|
|
133
|
+
`term_timeout` — a server that ignores TERM must not hold the port
|
|
134
|
+
indefinitely. Port freedom is judged by re-running lsof, so zombies
|
|
135
|
+
(dead, unreaped children) never look like survivors."""
|
|
136
|
+
if shutil.which("lsof") is None:
|
|
137
|
+
print(f"WARNING: lsof not found — cannot sweep orphaned listeners on "
|
|
138
|
+
f"port {port}.", file=sys.stderr, flush=True)
|
|
139
|
+
return
|
|
140
|
+
kill = shutil.which("kill")
|
|
141
|
+
pids = _listening_pids(port, match, cwd)
|
|
142
|
+
for pid in pids:
|
|
143
|
+
if kill is not None:
|
|
144
|
+
subprocess.run([kill, pid], capture_output=True, check=False,
|
|
145
|
+
shell=False, env=child_env())
|
|
146
|
+
survivors = pids
|
|
147
|
+
deadline = time.monotonic() + term_timeout
|
|
148
|
+
while survivors and time.monotonic() < deadline:
|
|
149
|
+
survivors = _listening_pids(port, match, cwd)
|
|
150
|
+
if survivors:
|
|
151
|
+
time.sleep(0.05)
|
|
152
|
+
for pid in survivors:
|
|
153
|
+
if kill is not None:
|
|
154
|
+
subprocess.run([kill, "-9", pid], capture_output=True, check=False,
|
|
155
|
+
shell=False, env=child_env())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def commit(state: dict, params: dict) -> dict:
|
|
159
|
+
"""The pipeline's only commit point. Skipped loudly when the worktree was
|
|
160
|
+
dirty at run start, so the auto-commit can never swallow pre-existing
|
|
161
|
+
unrelated changes; a dirty-at-start run is committed by the human.
|
|
162
|
+
|
|
163
|
+
`params["message"]` is required — a template rendered with `{subject}`.
|
|
164
|
+
Runs in the process's cwd (the consumer repo the pipeline was launched
|
|
165
|
+
from)."""
|
|
166
|
+
subject = state["subject"]
|
|
167
|
+
if not state.get("commit_allowed", False):
|
|
168
|
+
print(f"[{subject}] Skipping auto-commit — worktree was dirty at run "
|
|
169
|
+
f"start; commit the constructed work manually.", flush=True)
|
|
170
|
+
return {}
|
|
171
|
+
if "message" not in params:
|
|
172
|
+
raise ValueError("commit action requires params.message "
|
|
173
|
+
"(a template rendered with {subject})")
|
|
174
|
+
cwd = Path.cwd()
|
|
175
|
+
_git(["add", "-A"], cwd=cwd)
|
|
176
|
+
if not _git(["status", "--porcelain"], cwd=cwd).stdout.strip():
|
|
177
|
+
print(f"[{subject}] Nothing to commit.", flush=True)
|
|
178
|
+
return {}
|
|
179
|
+
message = params["message"].format(subject=subject)
|
|
180
|
+
result = _git(["commit", "-m", message], cwd=cwd)
|
|
181
|
+
if result.returncode != 0:
|
|
182
|
+
# The work is built and verified — a git error is a loud warning,
|
|
183
|
+
# not a pipeline failure.
|
|
184
|
+
print(f"WARNING: [{subject}] auto-commit failed — commit manually.\n"
|
|
185
|
+
f"{result.stderr.strip()}", file=sys.stderr, flush=True)
|
|
186
|
+
return {}
|
|
187
|
+
short = _git(["rev-parse", "--short", "HEAD"], cwd=cwd).stdout.strip()
|
|
188
|
+
print(f"[{subject}] Committed constructed work as {short}.", flush=True)
|
|
189
|
+
return {}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Agent file loading + SDK factory mechanics.
|
|
2
|
+
|
|
3
|
+
The SDK's built-in tools are registered at import (its registry is global);
|
|
4
|
+
consumer tools are registered by extensions via `Registry.add_tool`."""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from openhands.sdk import LLM
|
|
10
|
+
from openhands.tools.file_editor import FileEditorTool # noqa: F401 — registers "file_editor"
|
|
11
|
+
from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 — registers "task_tracker"
|
|
12
|
+
from openhands.tools.terminal import TerminalTool # noqa: F401 — registers "terminal"
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from openhands.sdk.agent.agent import Agent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
|
|
19
|
+
"""Load `<agents_dir>/<name>.agent.md` and build the Agent via the SDK
|
|
20
|
+
factory (frontmatter tool names resolved against the registry, unknown →
|
|
21
|
+
ValueError; the markdown body is delivered as the system prompt suffix).
|
|
22
|
+
|
|
23
|
+
Tool surface (BR-1): the frontmatter `tools:` list is the exact
|
|
24
|
+
capability allowlist — every name must resolve to a registered SDK tool.
|
|
25
|
+
`FinishTool` (done signal) and `ThinkTool` (reasoning scaffold) are added
|
|
26
|
+
by the SDK as default infra, never part of the allowlist and never
|
|
27
|
+
removable via `tools:`. There is no regex or default-tools knob.
|
|
28
|
+
|
|
29
|
+
Two pipeline policies on top of the SDK's native AgentDefinition loader:
|
|
30
|
+
the file must exist (fail fast — the SDK's dir loader would silently
|
|
31
|
+
skip it) and its frontmatter name must match its filename. A file with
|
|
32
|
+
no frontmatter or no name gets the SDK's stem fallback ("<name>.agent"),
|
|
33
|
+
which can never match the stem — so both degenerate cases fail the name
|
|
34
|
+
check."""
|
|
35
|
+
from openhands.sdk.subagent.registry import agent_definition_to_factory
|
|
36
|
+
from openhands.sdk.subagent.schema import AgentDefinition
|
|
37
|
+
|
|
38
|
+
path = Path(agents_dir) / f"{name}.agent.md"
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise FileNotFoundError(f"Agent file not found: {path}")
|
|
41
|
+
|
|
42
|
+
agent_def = AgentDefinition.load(path)
|
|
43
|
+
|
|
44
|
+
stem = path.name.removesuffix(".agent.md")
|
|
45
|
+
if agent_def.name != stem:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Agent file {path} name {agent_def.name!r} does not match "
|
|
48
|
+
f"filename {stem!r}")
|
|
49
|
+
return agent_definition_to_factory(agent_def)(llm)
|
agentic_dev_loop/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Two-phase CLI: phase 1 finds the pipeline YAML; phase 2 builds the full
|
|
2
|
+
parser — core flags plus every document loader's contributed args (the
|
|
3
|
+
built-in `plan` loader plus whatever the pipeline's `extensions:` register)
|
|
4
|
+
— and runs the pipeline. A loader's flag therefore exists only when its
|
|
5
|
+
extension is listed in that pipeline's YAML."""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import load_config
|
|
13
|
+
from .graph import run_pipeline
|
|
14
|
+
from .registry import default_registry, load_extensions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version() -> str:
|
|
18
|
+
"""The installed distribution version — the single source of truth."""
|
|
19
|
+
try:
|
|
20
|
+
return importlib.metadata.version("agentic-dev-loop")
|
|
21
|
+
except importlib.metadata.PackageNotFoundError:
|
|
22
|
+
print("agentic-dev-loop is not installed — version unavailable; "
|
|
23
|
+
"run `pip install -e .`", file=sys.stderr)
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_version_flag(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument("--version", action="version",
|
|
29
|
+
version=f"agentic-dev-loop {_version()}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _core_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="agentic-dev-loop",
|
|
35
|
+
description="Run an OpenHands agent pipeline defined in YAML. "
|
|
36
|
+
"Document-loader flags contributed by the pipeline's "
|
|
37
|
+
"extensions appear once PIPELINE is known — see "
|
|
38
|
+
"`agentic-dev-loop PIPELINE --help`.")
|
|
39
|
+
parser.add_argument("pipeline", type=Path,
|
|
40
|
+
help="path to the pipeline YAML")
|
|
41
|
+
parser.add_argument("--resume", metavar="THREAD_ID", default=None,
|
|
42
|
+
help="resume a checkpointed run by thread id")
|
|
43
|
+
parser.add_argument("--max-attempts", type=int, default=None,
|
|
44
|
+
help="override the pipeline's max_attempts")
|
|
45
|
+
parser.add_argument("--no-viz", action="store_true",
|
|
46
|
+
help="suppress the live graph view (served by "
|
|
47
|
+
"default)")
|
|
48
|
+
parser.add_argument("--viz-port", type=int, default=8321,
|
|
49
|
+
help="preferred port for the live graph view "
|
|
50
|
+
"(default: 8321; falls back to a free port when "
|
|
51
|
+
"busy)")
|
|
52
|
+
_add_version_flag(parser)
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_parser(cfg_path) -> argparse.ArgumentParser:
|
|
57
|
+
"""The full parser for one pipeline: core flags + loader-contributed."""
|
|
58
|
+
cfg = load_config(cfg_path)
|
|
59
|
+
registry = default_registry()
|
|
60
|
+
load_extensions(cfg.extensions, registry)
|
|
61
|
+
parser = _core_parser()
|
|
62
|
+
for loader in registry.document_loaders:
|
|
63
|
+
loader.add_cli_args(parser)
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
probe = argparse.ArgumentParser(add_help=False)
|
|
69
|
+
probe.add_argument("pipeline", type=Path, nargs="?")
|
|
70
|
+
_add_version_flag(probe)
|
|
71
|
+
known, _ = probe.parse_known_args(argv)
|
|
72
|
+
if known.pipeline is None:
|
|
73
|
+
# No pipeline → the extension surface is unknowable; the core parser
|
|
74
|
+
# handles --help and the missing-positional error itself (both exit).
|
|
75
|
+
_core_parser().parse_args(argv)
|
|
76
|
+
return 2 # unreachable — parse_args exits first
|
|
77
|
+
args = build_parser(known.pipeline).parse_args(argv)
|
|
78
|
+
success, _attempts = run_pipeline(args.pipeline, args)
|
|
79
|
+
if not success:
|
|
80
|
+
print("agentic-dev-loop: pipeline failed — exiting with code 1",
|
|
81
|
+
file=sys.stderr)
|
|
82
|
+
return 0 if success else 1
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Pipeline YAML schema and loader. Strict: unknown keys fail at every level."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
TERMINALS = frozenset({"success", "failure"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VerdictSpec(BaseModel):
|
|
13
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
14
|
+
|
|
15
|
+
pass_: str = Field(alias="pass")
|
|
16
|
+
fail: str = Field(alias="fail")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Route(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
goto: str
|
|
23
|
+
retry: bool = False # True consumes an attempt; exhaustion routes to failure
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
RouteTarget = str | Route
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def route_target(route: RouteTarget | None) -> str | None:
|
|
30
|
+
if route is None:
|
|
31
|
+
return None
|
|
32
|
+
return route.goto if isinstance(route, Route) else route
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StepConfig(BaseModel):
|
|
36
|
+
"""One pipeline step. Its type IS the key it carries: exactly one of
|
|
37
|
+
`agent`, `action`, or `command` — empty values do not count, anything
|
|
38
|
+
else fails to load. See the `kind` property (the single discriminator
|
|
39
|
+
graph-building dispatches on)."""
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
agent: str | None = None
|
|
44
|
+
action: str | None = None
|
|
45
|
+
command: str | None = None # mechanical step: exit code is the verdict
|
|
46
|
+
params: dict[str, Any] = {}
|
|
47
|
+
produces: list[str] = [] # hygiene-tracked, no verdict parsing
|
|
48
|
+
artifact: str | None = None # hygiene-tracked AND verdict-parsed
|
|
49
|
+
verdicts: VerdictSpec | None = None
|
|
50
|
+
max_iterations: int | None = None
|
|
51
|
+
timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
|
|
52
|
+
on_pass: RouteTarget | None = None
|
|
53
|
+
on_fail: RouteTarget | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def kind(self) -> str:
|
|
57
|
+
"""The step's type: 'agent', 'command', or 'action'. Which of the
|
|
58
|
+
three keys is set is the ONLY signal; validation has already
|
|
59
|
+
guaranteed exactly one is set and non-empty by the time any instance
|
|
60
|
+
exists."""
|
|
61
|
+
if self.agent is not None:
|
|
62
|
+
return "agent"
|
|
63
|
+
if self.command is not None:
|
|
64
|
+
return "command"
|
|
65
|
+
return "action"
|
|
66
|
+
|
|
67
|
+
@field_validator("produces", mode="before")
|
|
68
|
+
@classmethod
|
|
69
|
+
def _coerce_produces(cls, v: Any) -> Any:
|
|
70
|
+
return [v] if isinstance(v, str) else v
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _check_shape(self) -> "StepConfig":
|
|
74
|
+
for key in ("agent", "action", "command"):
|
|
75
|
+
value = getattr(self, key)
|
|
76
|
+
if value is not None and not value.strip():
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"step {self.name!r}: {key!r} must be a non-empty string"
|
|
79
|
+
)
|
|
80
|
+
if sum(k is not None for k in (self.agent, self.action, self.command)) != 1:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"step {self.name!r}: exactly one of 'agent', 'action', or "
|
|
83
|
+
"'command' is required"
|
|
84
|
+
)
|
|
85
|
+
if self.command is not None:
|
|
86
|
+
for field, empty in (("artifact", None), ("verdicts", None),
|
|
87
|
+
("produces", []), ("params", {}),
|
|
88
|
+
("max_iterations", None)):
|
|
89
|
+
if getattr(self, field) != empty:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"step {self.name!r}: 'command' steps cannot set {field!r}"
|
|
92
|
+
)
|
|
93
|
+
if (self.artifact is None) != (self.verdicts is None):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"step {self.name!r}: 'artifact' and 'verdicts' must appear together"
|
|
96
|
+
)
|
|
97
|
+
if self.artifact is not None and self.produces:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"step {self.name!r}: 'produces' and 'artifact' are mutually exclusive"
|
|
100
|
+
)
|
|
101
|
+
if self.timeout is not None and self.command is None:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"step {self.name!r}: 'timeout' requires 'command'"
|
|
104
|
+
)
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LlmConfig(BaseModel):
|
|
109
|
+
model_config = ConfigDict(extra="forbid")
|
|
110
|
+
|
|
111
|
+
model: str
|
|
112
|
+
base_url: str | None = None
|
|
113
|
+
api_key_env: str
|
|
114
|
+
timeout: float | None = None # omitted → opinionated no-timeout default
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PipelineConfig(BaseModel):
|
|
118
|
+
model_config = ConfigDict(extra="forbid")
|
|
119
|
+
|
|
120
|
+
name: str
|
|
121
|
+
extensions: list[str] = []
|
|
122
|
+
additional_files: list[str] = []
|
|
123
|
+
env_allowlist: list[str] = []
|
|
124
|
+
llm: LlmConfig
|
|
125
|
+
state_dir: str = ".pr"
|
|
126
|
+
max_attempts: int = 3
|
|
127
|
+
agents_dir: str
|
|
128
|
+
steps: list[StepConfig] = Field(min_length=1)
|
|
129
|
+
|
|
130
|
+
@model_validator(mode="after")
|
|
131
|
+
def _check_graph(self) -> "PipelineConfig":
|
|
132
|
+
names = [s.name for s in self.steps]
|
|
133
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
134
|
+
if dupes:
|
|
135
|
+
raise ValueError(f"duplicate step names: {dupes}")
|
|
136
|
+
for n in names:
|
|
137
|
+
if n in TERMINALS:
|
|
138
|
+
raise ValueError(f"step name {n!r} collides with a reserved terminal")
|
|
139
|
+
valid = set(names) | TERMINALS
|
|
140
|
+
for step in self.steps:
|
|
141
|
+
for route in (step.on_pass, step.on_fail):
|
|
142
|
+
target = route_target(route)
|
|
143
|
+
if target is not None and target not in valid:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"step {step.name!r}: unknown route target {target!r}"
|
|
146
|
+
)
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_config(path: Path) -> PipelineConfig:
|
|
151
|
+
data = yaml.safe_load(Path(path).read_text())
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
|
|
155
|
+
)
|
|
156
|
+
return PipelineConfig.model_validate(data)
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""AI-DLC document-loader extension. Loading IS choosing: name
|
|
2
|
+
`agentic_dev_loop.extensions.aidlc:AidlcExtension` in the pipeline's
|
|
3
|
+
`extensions:` list and the pipeline gains `--ai-dlc-unit` (any
|
|
4
|
+
unambiguous unit spelling: U4 / 4 / 004 / U004 / full slug) plus bare-run
|
|
5
|
+
autodetect of the current unit from `aidlc-docs/aidlc-state.md`.
|
|
6
|
+
|
|
7
|
+
Resolvers are verbatim ports of yosk's `resolve_unit_docs` /
|
|
8
|
+
`get_current_unit`, with `docs_dir` parameterised (was the module-level
|
|
9
|
+
AIDLC_DOCS_DIR) and the coding-standards merge dropped (the pipeline's
|
|
10
|
+
`additional_files:` now owns shared files).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_DOC_SUFFIXES = (".md", ".yaml", ".yml", ".txt")
|
|
18
|
+
_UNIT_DIR_NUMBER = re.compile(r"unit[-_](\d+)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _leading_number(text: str) -> int | None:
|
|
22
|
+
"""Leading digits of a unit id (``"004-preset-management"`` -> 4), or
|
|
23
|
+
None for ids that do not start with a number."""
|
|
24
|
+
match = re.match(r"(\d+)", text)
|
|
25
|
+
return int(match.group(1)) if match else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dir_unit_number(dir_name: str) -> int | None:
|
|
29
|
+
"""Numeric prefix of a unit dir name (``"unit-004-preset-management"``
|
|
30
|
+
-> 4, ``"unit-14"`` -> 14)."""
|
|
31
|
+
match = _UNIT_DIR_NUMBER.match(dir_name.lower())
|
|
32
|
+
return int(match.group(1)) if match else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_unit_docs(docs_dir: Path | str, unit_id: str) -> dict[str, str]:
|
|
36
|
+
docs: dict[str, str] = {}
|
|
37
|
+
docs_dir = Path(docs_dir)
|
|
38
|
+
unit_id = unit_id.strip()
|
|
39
|
+
if unit_id[:1] in ("U", "u"):
|
|
40
|
+
unit_id = unit_id[1:]
|
|
41
|
+
|
|
42
|
+
unit_patterns = [f"unit-{unit_id}", f"unit_{unit_id}"]
|
|
43
|
+
number = _leading_number(unit_id)
|
|
44
|
+
construction_dir = docs_dir / "construction"
|
|
45
|
+
candidates: list[Path] = []
|
|
46
|
+
if construction_dir.is_dir():
|
|
47
|
+
for entry in sorted(construction_dir.iterdir()):
|
|
48
|
+
if not entry.is_dir():
|
|
49
|
+
continue
|
|
50
|
+
name = entry.name.lower()
|
|
51
|
+
if name == unit_patterns[0].lower() or name == unit_patterns[1].lower():
|
|
52
|
+
candidates.append(entry)
|
|
53
|
+
elif number is not None and _dir_unit_number(entry.name) == number:
|
|
54
|
+
candidates.append(entry)
|
|
55
|
+
|
|
56
|
+
candidates = sorted(set(candidates), key=lambda p: p.name.lower())
|
|
57
|
+
if len(candidates) > 1:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
f"Ambiguous unit id '{unit_id}': multiple unit directories match "
|
|
60
|
+
f"({', '.join(p.name for p in candidates)}). Pass the full unit "
|
|
61
|
+
"slug to disambiguate.")
|
|
62
|
+
|
|
63
|
+
unit_dir = candidates[0] if candidates else None
|
|
64
|
+
if unit_dir is None:
|
|
65
|
+
look_for = ", ".join(unit_patterns)
|
|
66
|
+
if number is not None:
|
|
67
|
+
look_for += f", or any unit dir numbered {number}"
|
|
68
|
+
raise FileNotFoundError(
|
|
69
|
+
f"No docs directory found for unit {unit_id} under {construction_dir} "
|
|
70
|
+
f"(looked for {look_for})")
|
|
71
|
+
|
|
72
|
+
for file in sorted(unit_dir.rglob("*")):
|
|
73
|
+
if file.is_file() and file.suffix in _DOC_SUFFIXES:
|
|
74
|
+
key = str(file.relative_to(unit_dir))
|
|
75
|
+
docs[key] = file.read_text()
|
|
76
|
+
|
|
77
|
+
# At this point docs holds only unit-dir files — an empty load means the
|
|
78
|
+
# pipeline would build context from inception docs alone (the 2026-07-19
|
|
79
|
+
# U3 silent-degradation bug). Fail fast instead.
|
|
80
|
+
if not any(not k.startswith("inception/") for k in docs):
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"Unit dir {unit_dir} matched but yielded no docs — "
|
|
83
|
+
"refusing to build context from inception docs alone")
|
|
84
|
+
|
|
85
|
+
# Namespaced keys so an inception doc can never silently overwrite a
|
|
86
|
+
# unit doc with the same filename.
|
|
87
|
+
inception = docs_dir / "inception" / "application-design"
|
|
88
|
+
if inception.is_dir():
|
|
89
|
+
for file in sorted(inception.iterdir()):
|
|
90
|
+
if file.is_file() and file.suffix in (".md", ".txt"):
|
|
91
|
+
docs[f"inception/{file.name}"] = file.read_text()
|
|
92
|
+
|
|
93
|
+
# The unit's code-generation plans are the build spec — absent is fine,
|
|
94
|
+
# present is how the spec reaches agents.
|
|
95
|
+
plans_dir = docs_dir / "construction" / "plans"
|
|
96
|
+
if plans_dir.is_dir():
|
|
97
|
+
for file in sorted(plans_dir.glob(f"unit-{unit_id}-*-code-generation-plan.md")):
|
|
98
|
+
docs[f"plans/{file.name}"] = file.read_text()
|
|
99
|
+
|
|
100
|
+
return docs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_current_unit(docs_dir: Path | str) -> str:
|
|
104
|
+
"""Read the current unit from the aidlc-state.md Current Stage line."""
|
|
105
|
+
state_file = Path(docs_dir) / "aidlc-state.md"
|
|
106
|
+
if not state_file.is_file():
|
|
107
|
+
raise FileNotFoundError(f"aidlc-state file not found: {state_file}")
|
|
108
|
+
# Anchor to the Current Stage line — the file accumulates history, so an
|
|
109
|
+
# unanchored search could silently pick up an earlier "Unit N" mention.
|
|
110
|
+
match = re.search(r"Current Stage[^\n]*Unit (\d+)", state_file.read_text())
|
|
111
|
+
if not match:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"Could not determine current unit from {state_file} "
|
|
114
|
+
"(no 'Unit N' in the Current Stage line)")
|
|
115
|
+
return f"U{match.group(1)}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _normalize_unit(unit: str) -> str:
|
|
119
|
+
unit = unit.strip()
|
|
120
|
+
if unit[:1] in ("U", "u"):
|
|
121
|
+
unit = unit[1:]
|
|
122
|
+
return f"U{unit}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AidlcDocumentLoader:
|
|
126
|
+
"""`--ai-dlc-unit` (any unambiguous spelling), or bare-run
|
|
127
|
+
autodetect from aidlc-state.md."""
|
|
128
|
+
|
|
129
|
+
name = "ai-dlc"
|
|
130
|
+
defaultable = True
|
|
131
|
+
|
|
132
|
+
def __init__(self, docs_dir: Path | str = "aidlc-docs"):
|
|
133
|
+
self.docs_dir = Path(docs_dir)
|
|
134
|
+
|
|
135
|
+
def add_cli_args(self, parser: argparse.ArgumentParser) -> None:
|
|
136
|
+
parser.add_argument("--ai-dlc-unit", metavar="U<N>", default=None,
|
|
137
|
+
help="AI-DLC unit ID: any unambiguous spelling "
|
|
138
|
+
"(U4, 4, 004, U004) or the full unit slug "
|
|
139
|
+
"(U004-preset-management); defaults to the "
|
|
140
|
+
"current unit in aidlc-docs/aidlc-state.md")
|
|
141
|
+
|
|
142
|
+
def resolve(self, args: argparse.Namespace) -> tuple[dict[str, str], str]:
|
|
143
|
+
unit = (_normalize_unit(args.ai_dlc_unit) if args.ai_dlc_unit
|
|
144
|
+
else get_current_unit(self.docs_dir))
|
|
145
|
+
return resolve_unit_docs(self.docs_dir, unit), f"unit {unit}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AidlcExtension:
|
|
149
|
+
name = "ai-dlc"
|
|
150
|
+
|
|
151
|
+
def register(self, registry) -> None:
|
|
152
|
+
registry.add_document_loader(AidlcDocumentLoader())
|