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,275 @@
|
|
|
1
|
+
"""Config-driven LangGraph builder + the run_pipeline public API.
|
|
2
|
+
|
|
3
|
+
The pipeline is a cyclic state graph: one node per configured step, routers
|
|
4
|
+
steer the retry cycle, and a SQLite checkpointer makes runs crash-resumable
|
|
5
|
+
(--resume <thread-id> re-enters at the first stage that didn't complete).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import operator
|
|
11
|
+
import uuid
|
|
12
|
+
import webbrowser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated, TypedDict
|
|
15
|
+
|
|
16
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
17
|
+
from langgraph.graph import END, START, StateGraph
|
|
18
|
+
|
|
19
|
+
from . import viz
|
|
20
|
+
from .actions import git_worktree_clean, kill_listeners_on_port
|
|
21
|
+
from .config import load_config
|
|
22
|
+
from .llm import build_llm
|
|
23
|
+
from .loaders import select_loader
|
|
24
|
+
from .registry import Registry, default_registry, load_extensions
|
|
25
|
+
from .stages import (Runtime, build_context, clear_attempt_artifacts,
|
|
26
|
+
make_action_node, make_agent_node, make_bump_node,
|
|
27
|
+
make_command_node, make_router, success_node)
|
|
28
|
+
|
|
29
|
+
CHECKPOINT_DB = "loop-checkpoints.sqlite" # under state_dir, one DB per workspace
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoopState(TypedDict):
|
|
33
|
+
"""State threaded through the graph. `notes` accumulates across attempts
|
|
34
|
+
(append reducer); every other key is last-write-wins."""
|
|
35
|
+
subject: str
|
|
36
|
+
docs: dict[str, str]
|
|
37
|
+
context: str # built at attempt start, shared by all stages
|
|
38
|
+
attempt: int
|
|
39
|
+
max_attempts: int
|
|
40
|
+
notes: Annotated[list[str], operator.add]
|
|
41
|
+
failed: bool # hard stage failure — ends the run
|
|
42
|
+
failed_step: str | None # the step that hard-failed (resume target)
|
|
43
|
+
resume_target: str | None # where a resumed run re-enters (START edge)
|
|
44
|
+
outcome: str | None # "success" once routed to the success terminal
|
|
45
|
+
commit_allowed: bool # worktree was clean at run start
|
|
46
|
+
step_verdicts: dict[str, str | None] # per-step pass/fail/None(unclear)
|
|
47
|
+
retry_target: str | None # where the bump node loops back to
|
|
48
|
+
thread_id: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_graph(runtime: Runtime, checkpointer=None):
|
|
52
|
+
"""Compile the pipeline's state graph: one node per step (agent, command,
|
|
53
|
+
or action), plus the bump retry node and the success terminal. Unknown
|
|
54
|
+
action names fail here, at build time, not mid-run."""
|
|
55
|
+
for step in runtime.cfg.steps:
|
|
56
|
+
if step.action is not None and step.action not in runtime.registry.actions:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"step {step.name!r}: unknown action {step.action!r} "
|
|
59
|
+
f"(registered: {sorted(runtime.registry.actions)})")
|
|
60
|
+
graph = StateGraph(LoopState)
|
|
61
|
+
for step in runtime.cfg.steps:
|
|
62
|
+
if step.kind == "agent":
|
|
63
|
+
node = make_agent_node(runtime, step)
|
|
64
|
+
elif step.kind == "command":
|
|
65
|
+
node = make_command_node(runtime, step)
|
|
66
|
+
else:
|
|
67
|
+
node = make_action_node(runtime, step)
|
|
68
|
+
graph.add_node(step.name, node)
|
|
69
|
+
graph.add_conditional_edges(step.name, make_router(runtime, step))
|
|
70
|
+
graph.add_node("bump", make_bump_node(runtime))
|
|
71
|
+
graph.add_conditional_edges("bump", lambda state: state["retry_target"])
|
|
72
|
+
graph.add_node("success", success_node)
|
|
73
|
+
graph.add_conditional_edges(
|
|
74
|
+
START,
|
|
75
|
+
lambda state: state.get("resume_target") or runtime.cfg.steps[0].name)
|
|
76
|
+
graph.add_edge("success", END)
|
|
77
|
+
return graph.compile(checkpointer=checkpointer)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_docs(cfg, base: Path, registry: Registry,
|
|
81
|
+
args) -> tuple[dict[str, str], str]:
|
|
82
|
+
"""Select and invoke the document loader, then append the pipeline's
|
|
83
|
+
additional_files (resolved relative to the pipeline file's dir;
|
|
84
|
+
listed-but-missing fails fast)."""
|
|
85
|
+
loader = select_loader(registry, args)
|
|
86
|
+
if loader is not None:
|
|
87
|
+
docs, subject = loader.resolve(args)
|
|
88
|
+
if not docs:
|
|
89
|
+
# U3 policy: an empty load silently degraded to a context the
|
|
90
|
+
# agents couldn't act on. Fail fast at the call site instead.
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Document loader {loader.name!r} returned no docs — "
|
|
93
|
+
"refusing to build context from nothing")
|
|
94
|
+
else:
|
|
95
|
+
docs, subject = {}, cfg.name
|
|
96
|
+
for rel in cfg.additional_files:
|
|
97
|
+
path = base / rel
|
|
98
|
+
if not path.is_file():
|
|
99
|
+
raise FileNotFoundError(f"additional_files entry not found: {path}")
|
|
100
|
+
docs[path.name] = path.read_text()
|
|
101
|
+
return docs, subject
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
|
|
105
|
+
"""Load a pipeline YAML, resolve docs via the selected document loader,
|
|
106
|
+
and run the graph to completion. Returns (success, attempts) — the
|
|
107
|
+
public API, yosk's `construct`/`resume_loop` equivalent.
|
|
108
|
+
|
|
109
|
+
`args` carries the parsed CLI namespace: loader flags, --resume,
|
|
110
|
+
--max-attempts (which overrides the YAML value when set), and --no-viz.
|
|
111
|
+
|
|
112
|
+
The live viz is on by default for CLI runs (suppressed with ``--no-viz``).
|
|
113
|
+
With ``viz_bus=None`` and viz suppressed, the graph runs via
|
|
114
|
+
``graph.invoke`` exactly as before. A passed-in ``viz_bus`` (programmatic)
|
|
115
|
+
or a default-on CLI run switches to ``graph.stream`` and publishes live
|
|
116
|
+
events; the CLI path additionally serves the HTTP page and opens the
|
|
117
|
+
browser, and the process exits as soon as the loop completes.
|
|
118
|
+
Programmatic Namespaces without a ``no_viz`` attribute get no server —
|
|
119
|
+
default-on is a CLI-layer concern."""
|
|
120
|
+
cfg_path = Path(cfg_path)
|
|
121
|
+
cfg = load_config(cfg_path)
|
|
122
|
+
base = cfg_path.resolve().parent
|
|
123
|
+
max_attempts = getattr(args, "max_attempts", None) or cfg.max_attempts
|
|
124
|
+
|
|
125
|
+
registry = default_registry()
|
|
126
|
+
load_extensions(cfg.extensions, registry)
|
|
127
|
+
|
|
128
|
+
state_dir = base / cfg.state_dir
|
|
129
|
+
runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
|
|
130
|
+
agents_dir=base / cfg.agents_dir, state_dir=state_dir,
|
|
131
|
+
workspace=Path.cwd())
|
|
132
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
resume = getattr(args, "resume", None)
|
|
135
|
+
thread_id = resume or uuid.uuid4().hex
|
|
136
|
+
config = {"configurable": {"thread_id": thread_id}}
|
|
137
|
+
db = str(state_dir / CHECKPOINT_DB)
|
|
138
|
+
if resume:
|
|
139
|
+
initial = None # state (docs/subject/context) comes from the checkpoint
|
|
140
|
+
else:
|
|
141
|
+
docs, subject = _resolve_docs(cfg, base, registry, args)
|
|
142
|
+
# A fresh run owns state_dir: drop cross-run leftovers (a previous
|
|
143
|
+
# failure's last-error.md, stale artifacts) so nothing mistakes
|
|
144
|
+
# them for this run's output. --resume keeps them.
|
|
145
|
+
clear_attempt_artifacts(runtime)
|
|
146
|
+
initial: LoopState = {
|
|
147
|
+
"subject": subject,
|
|
148
|
+
"docs": docs,
|
|
149
|
+
"context": build_context(runtime, docs, subject, []),
|
|
150
|
+
"attempt": 1,
|
|
151
|
+
"max_attempts": max_attempts,
|
|
152
|
+
"notes": [],
|
|
153
|
+
"failed": False,
|
|
154
|
+
"failed_step": None,
|
|
155
|
+
"resume_target": None,
|
|
156
|
+
"outcome": None,
|
|
157
|
+
# Only a clean-at-start worktree gets an auto-commit — otherwise
|
|
158
|
+
# the commit could swallow pre-existing changes. Computed BEFORE
|
|
159
|
+
# the checkpointer creates its db under state_dir, which would
|
|
160
|
+
# itself dirty the worktree.
|
|
161
|
+
"commit_allowed": git_worktree_clean(Path.cwd()),
|
|
162
|
+
"step_verdicts": {},
|
|
163
|
+
"retry_target": None,
|
|
164
|
+
"thread_id": thread_id,
|
|
165
|
+
}
|
|
166
|
+
bus = viz_bus
|
|
167
|
+
with SqliteSaver.from_conn_string(db) as saver:
|
|
168
|
+
if resume and saver.get_tuple(config) is None:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"No checkpoint found for thread {thread_id!r} in {db} — "
|
|
171
|
+
f"nothing to resume.")
|
|
172
|
+
graph = build_graph(runtime, saver)
|
|
173
|
+
|
|
174
|
+
if resume and getattr(args, "max_attempts", None):
|
|
175
|
+
# A resumed run takes its state from the checkpoint, so the CLI
|
|
176
|
+
# override computed above would never reach it. Re-apply it to the
|
|
177
|
+
# checkpoint state before the run re-enters.
|
|
178
|
+
graph.update_state(config, {"max_attempts": max_attempts})
|
|
179
|
+
|
|
180
|
+
if resume and initial is None:
|
|
181
|
+
values = graph.get_state(config).values
|
|
182
|
+
if values.get("failed"):
|
|
183
|
+
# A hard-failed run's checkpoint has no pending work — a
|
|
184
|
+
# plain resume would return it untouched and exit silently.
|
|
185
|
+
# Re-enter at the step that failed so the run can complete.
|
|
186
|
+
failed_step = values.get("failed_step")
|
|
187
|
+
target = (failed_step or values.get("retry_target")
|
|
188
|
+
or cfg.steps[0].name)
|
|
189
|
+
print(
|
|
190
|
+
f"[{values.get('subject', '?')}] Resuming hard-failed run "
|
|
191
|
+
f"(thread {thread_id}) — re-entering at step {target!r}. "
|
|
192
|
+
f"Failure details: {state_dir}/last-error.md",
|
|
193
|
+
flush=True)
|
|
194
|
+
initial = {**values, "failed": False,
|
|
195
|
+
"resume_target": target, "failed_step": None}
|
|
196
|
+
|
|
197
|
+
if bus is None and not getattr(args, "no_viz", True):
|
|
198
|
+
start_state = initial if initial is not None \
|
|
199
|
+
else graph.get_state(config).values
|
|
200
|
+
topology, nodes = viz.topology_mermaid(cfg)
|
|
201
|
+
bus = viz.VizBus()
|
|
202
|
+
if args.viz_port:
|
|
203
|
+
kill_listeners_on_port(args.viz_port, match="agentic-dev-loop",
|
|
204
|
+
cwd=runtime.workspace)
|
|
205
|
+
httpd, _thread = viz.serve_viz(
|
|
206
|
+
bus, topology, start_state["subject"], args.viz_port, nodes)
|
|
207
|
+
bound = httpd.server_address[1]
|
|
208
|
+
url = f"http://127.0.0.1:{bound}"
|
|
209
|
+
if args.viz_port and bound != args.viz_port:
|
|
210
|
+
print(f"viz port {args.viz_port} busy — serving on {url}",
|
|
211
|
+
flush=True)
|
|
212
|
+
print(f"Live graph viz: {url}", flush=True)
|
|
213
|
+
webbrowser.open(url)
|
|
214
|
+
|
|
215
|
+
if bus is not None:
|
|
216
|
+
start_state = initial if initial is not None \
|
|
217
|
+
else graph.get_state(config).values
|
|
218
|
+
bus.publish({
|
|
219
|
+
"type": "run_started",
|
|
220
|
+
"subject": start_state["subject"],
|
|
221
|
+
"max_attempts": start_state["max_attempts"],
|
|
222
|
+
"thread_id": thread_id,
|
|
223
|
+
"attempt": start_state["attempt"],
|
|
224
|
+
})
|
|
225
|
+
watch = viz.NodeWatch()
|
|
226
|
+
capture = viz.ConsoleCapture(bus, watch)
|
|
227
|
+
capture.attach()
|
|
228
|
+
heartbeat = viz.HeartbeatThread(watch, bus)
|
|
229
|
+
heartbeat.start()
|
|
230
|
+
final = None
|
|
231
|
+
try:
|
|
232
|
+
for part in graph.stream(initial, config,
|
|
233
|
+
stream_mode=["tasks", "updates"],
|
|
234
|
+
version="v2"):
|
|
235
|
+
for event in viz.translate(part):
|
|
236
|
+
if event["type"] == "node_started":
|
|
237
|
+
watch.start(event["node"])
|
|
238
|
+
elif event["type"] == "node_finished":
|
|
239
|
+
watch.finish(event["node"])
|
|
240
|
+
bus.publish(event)
|
|
241
|
+
final = graph.get_state(config).values
|
|
242
|
+
finally:
|
|
243
|
+
heartbeat.stop()
|
|
244
|
+
heartbeat.join()
|
|
245
|
+
if final is None:
|
|
246
|
+
try:
|
|
247
|
+
final = graph.get_state(config).values
|
|
248
|
+
except Exception:
|
|
249
|
+
final = None
|
|
250
|
+
if final is not None:
|
|
251
|
+
success = final.get("outcome") == "success"
|
|
252
|
+
attempts = final.get("attempt", 0)
|
|
253
|
+
else:
|
|
254
|
+
success = False
|
|
255
|
+
attempts = 0
|
|
256
|
+
bus.publish({"type": "run_finished",
|
|
257
|
+
"success": success, "attempts": attempts})
|
|
258
|
+
capture.detach()
|
|
259
|
+
else:
|
|
260
|
+
final = graph.invoke(initial, config)
|
|
261
|
+
success = final["outcome"] == "success"
|
|
262
|
+
attempts = final["attempt"]
|
|
263
|
+
|
|
264
|
+
if not success and final is not None:
|
|
265
|
+
if final.get("failed"):
|
|
266
|
+
print(
|
|
267
|
+
f"[{final['subject']}] FAILED after {final['attempt']} "
|
|
268
|
+
f"attempt(s) — hard failure at step "
|
|
269
|
+
f"{final.get('failed_step', 'unknown')!r}; see "
|
|
270
|
+
f"{state_dir}/last-error.md",
|
|
271
|
+
flush=True)
|
|
272
|
+
else:
|
|
273
|
+
print(f"[{final['subject']}] FAILED after {final['attempt']} "
|
|
274
|
+
f"attempt(s).", flush=True)
|
|
275
|
+
return success, attempts
|
agentic_dev_loop/llm.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""LLM construction from pipeline config. Fail fast on a missing key —
|
|
2
|
+
never fall back to another env var (no silent fallbacks)."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from openhands.sdk import LLM
|
|
7
|
+
from openhands.sdk.llm.utils import model_features as _model_features
|
|
8
|
+
|
|
9
|
+
from .config import LlmConfig
|
|
10
|
+
|
|
11
|
+
# Compat shim: OpenHands SDK 1.17 gates reasoning_content passback on a closed
|
|
12
|
+
# model-name list (SEND_REASONING_CONTENT_MODELS) that does not know
|
|
13
|
+
# deepseek-v4-pro. DeepSeek's thinking mode 400s on any multi-turn request
|
|
14
|
+
# whose history omits prior reasoning_content. Unlike force_string_serializer,
|
|
15
|
+
# the SDK exposes no instance-level override, so register the model here.
|
|
16
|
+
# Remove once the SDK lists the model or adds an override.
|
|
17
|
+
_DEEPSEEK_MODEL = "deepseek-v4-pro"
|
|
18
|
+
if _DEEPSEEK_MODEL not in _model_features.SEND_REASONING_CONTENT_MODELS:
|
|
19
|
+
_model_features.SEND_REASONING_CONTENT_MODELS.append(_DEEPSEEK_MODEL)
|
|
20
|
+
|
|
21
|
+
# Compat shim 2: the SDK splits a multi-tool-call turn into one assistant
|
|
22
|
+
# message per call (Agent.step) and carries reasoning_content only on the
|
|
23
|
+
# first; Message.to_chat_dict also drops empty-string reasoning. DeepSeek's
|
|
24
|
+
# thinking mode 400s any request whose history contains reasoning_content
|
|
25
|
+
# but omits it from an assistant tool-call message ("The reasoning_content
|
|
26
|
+
# in the thinking mode must be passed back to the API"). Repair the
|
|
27
|
+
# serialized history: an assistant tool-call message without its own
|
|
28
|
+
# reasoning borrows the turn's last reasoning (reset on user turns), and a
|
|
29
|
+
# present-but-empty reasoning is still written (field presence matters).
|
|
30
|
+
# Remove once the SDK distributes reasoning_content to every message.
|
|
31
|
+
_ORIGINAL_FORMAT_MESSAGES = LLM.format_messages_for_llm
|
|
32
|
+
if not getattr(_ORIGINAL_FORMAT_MESSAGES, "_agentic_dev_loop_reasoning_repair", False):
|
|
33
|
+
def _repair_reasoning_passback(self, messages: list) -> list[dict]:
|
|
34
|
+
out = _ORIGINAL_FORMAT_MESSAGES(self, messages)
|
|
35
|
+
last = None
|
|
36
|
+
for msg, d in zip(messages, out):
|
|
37
|
+
if msg.role == "user":
|
|
38
|
+
last = None
|
|
39
|
+
if msg.role == "assistant" and msg.tool_calls:
|
|
40
|
+
rc = getattr(msg, "reasoning_content", None)
|
|
41
|
+
if rc is not None:
|
|
42
|
+
last = rc
|
|
43
|
+
if last is not None:
|
|
44
|
+
d["reasoning_content"] = last
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
_repair_reasoning_passback._agentic_dev_loop_reasoning_repair = True
|
|
48
|
+
LLM.format_messages_for_llm = _repair_reasoning_passback
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_llm(cfg: LlmConfig) -> LLM:
|
|
52
|
+
api_key = os.environ.get(cfg.api_key_env)
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise RuntimeError(f"{cfg.api_key_env} environment variable is required")
|
|
55
|
+
return LLM(
|
|
56
|
+
model=cfg.model,
|
|
57
|
+
api_key=api_key,
|
|
58
|
+
base_url=cfg.base_url,
|
|
59
|
+
timeout=cfg.timeout, # policy: no LLM timeout unless explicitly set
|
|
60
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Built-in `plan` document loader + the loader-selection flow."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .registry import DocumentLoader, Registry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_plan_docs(plan_path: Path | str) -> dict[str, str]:
|
|
10
|
+
"""Load a single plan doc as the pipeline's full context — plan mode is
|
|
11
|
+
self-contained (no unit/inception docs; shared files like coding
|
|
12
|
+
standards come from the pipeline's `additional_files:`, appended by the
|
|
13
|
+
runner)."""
|
|
14
|
+
path = Path(plan_path)
|
|
15
|
+
if not path.is_file():
|
|
16
|
+
raise FileNotFoundError(f"Plan doc not found: {path}")
|
|
17
|
+
return {path.name: path.read_text()}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PlanDocumentLoader:
|
|
21
|
+
name = "plan"
|
|
22
|
+
defaultable = False # needs a path — never autodetects
|
|
23
|
+
|
|
24
|
+
def add_cli_args(self, parser: argparse.ArgumentParser) -> None:
|
|
25
|
+
parser.add_argument("--plan", type=Path, default=None,
|
|
26
|
+
help="Path to a single plan doc")
|
|
27
|
+
|
|
28
|
+
def resolve(self, args: argparse.Namespace) -> tuple[dict[str, str], str]:
|
|
29
|
+
docs = resolve_plan_docs(args.plan)
|
|
30
|
+
return docs, f"plan {args.plan.name}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def select_loader(registry: Registry, args: argparse.Namespace) -> DocumentLoader | None:
|
|
34
|
+
"""The document-loader coexistence model — load many, invoke one:
|
|
35
|
+
|
|
36
|
+
1. A loader flag present in argv (parsed value not None) selects its
|
|
37
|
+
loader; other loaders are bypassed. Flags from two different loaders
|
|
38
|
+
in one invocation → error naming both (genuine ambiguity).
|
|
39
|
+
2. No loader flag → the sole `defaultable` loader runs (e.g. aidlc
|
|
40
|
+
autodetects the current unit). Multiple defaultables → error.
|
|
41
|
+
3. None defaultable → None: run with no injected docs; the subject is
|
|
42
|
+
the pipeline's `name:`.
|
|
43
|
+
|
|
44
|
+
Convention: loader flags must default to None so "present" is decidable.
|
|
45
|
+
"""
|
|
46
|
+
invoked = {loader_name for dest, loader_name in registry.cli_dests.items()
|
|
47
|
+
if getattr(args, dest, None) is not None}
|
|
48
|
+
if len(invoked) > 1:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Flags from multiple document loaders in one invocation: "
|
|
51
|
+
f"{sorted(invoked)}")
|
|
52
|
+
if invoked:
|
|
53
|
+
name = invoked.pop()
|
|
54
|
+
return next(l for l in registry.document_loaders if l.name == name)
|
|
55
|
+
defaultable = [l for l in registry.document_loaders if l.defaultable]
|
|
56
|
+
if len(defaultable) > 1:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Multiple defaultable document loaders: "
|
|
59
|
+
f"{[l.name for l in defaultable]}")
|
|
60
|
+
return defaultable[0] if defaultable else None
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Captured agent output: value objects that own the response→artifact and
|
|
2
|
+
response→verdict channels (unit-1).
|
|
3
|
+
|
|
4
|
+
The pipeline — not the agent — writes the step's artifact from the agent's
|
|
5
|
+
final SDK response. `Verdict.from_text` parses that same text for routing.
|
|
6
|
+
Expected conditions are typed `Result` errors, never exceptions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from openhands.sdk.conversation.response_utils import get_agent_final_response
|
|
16
|
+
|
|
17
|
+
from .results import Result
|
|
18
|
+
|
|
19
|
+
# Leading markdown/list/quote decoration tolerated on a verdict line.
|
|
20
|
+
_LEAD_DECOR = "#*_`>- "
|
|
21
|
+
# Decoration tolerated between VERDICT and ':' and after the keyword.
|
|
22
|
+
_DECOR = "*_`> "
|
|
23
|
+
# Caveat delimiters: after one of these, arbitrary prose may follow the
|
|
24
|
+
# keyword without the line losing its decision status. '.' doubles as the
|
|
25
|
+
# bare sentence terminator.
|
|
26
|
+
_CAVEAT = "—–,;:."
|
|
27
|
+
# Negators recognized immediately before the keyword.
|
|
28
|
+
_NEGATORS = ("NOT", "DOES NOT", "DID NOT")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CaptureError(enum.Enum):
|
|
32
|
+
EMPTY_RESPONSE = "empty_response"
|
|
33
|
+
IO_ERROR = "io_error"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VerdictError(enum.Enum):
|
|
37
|
+
NO_VERDICT = "no_verdict"
|
|
38
|
+
AMBIGUOUS = "ambiguous"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Verdict:
|
|
43
|
+
"""A parsed decision: `value` is the canonical outcome (e.g. 'pass').
|
|
44
|
+
|
|
45
|
+
Parsing policy (BR-2): the last decoration-tolerant `VERDICT: X` line
|
|
46
|
+
that carries a *decision* wins; a keyword followed directly by prose is
|
|
47
|
+
discussion, not a decision. Conflicting decisions are never silently
|
|
48
|
+
resolved — they surface as AMBIGUOUS and retry.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
value: str
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def from_text(text: str, verdicts: dict[str, str]) -> "Result[Verdict, VerdictError]":
|
|
55
|
+
decisions: set[str] = set()
|
|
56
|
+
saw_verdict_line = False
|
|
57
|
+
for line in text.splitlines():
|
|
58
|
+
is_line, decision = _parse_line(line, verdicts)
|
|
59
|
+
if is_line:
|
|
60
|
+
saw_verdict_line = True
|
|
61
|
+
if decision is not None:
|
|
62
|
+
decisions.add(decision)
|
|
63
|
+
if len(decisions) == 1:
|
|
64
|
+
return Result.ok(Verdict(value=next(iter(decisions))))
|
|
65
|
+
if len(decisions) > 1:
|
|
66
|
+
return Result.err(VerdictError.AMBIGUOUS)
|
|
67
|
+
if saw_verdict_line:
|
|
68
|
+
return Result.err(VerdictError.AMBIGUOUS)
|
|
69
|
+
return Result.err(VerdictError.NO_VERDICT)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_line(line: str, verdicts: dict[str, str]) -> tuple[bool, str | None]:
|
|
73
|
+
"""Parse one line -> (is_verdict_line, decision_or_None).
|
|
74
|
+
|
|
75
|
+
Ported verbatim from the pre-unit-1 `verdicts.py` parser; the original
|
|
76
|
+
returned None for both non-verdict lines and verdict lines without a
|
|
77
|
+
decision — the flag now separates those cases for BR-2.5/BR-2.6.
|
|
78
|
+
"""
|
|
79
|
+
stripped = line.strip().lstrip(_LEAD_DECOR)
|
|
80
|
+
if not stripped.upper().startswith("VERDICT"):
|
|
81
|
+
return False, None
|
|
82
|
+
rest = stripped[len("VERDICT"):].lstrip(_DECOR)
|
|
83
|
+
if not rest.startswith(":"):
|
|
84
|
+
return True, None
|
|
85
|
+
body = rest[1:].strip()
|
|
86
|
+
negated = False
|
|
87
|
+
for negator in _NEGATORS:
|
|
88
|
+
if body.upper().startswith(negator + " "):
|
|
89
|
+
negated = True
|
|
90
|
+
body = body[len(negator):].strip()
|
|
91
|
+
break
|
|
92
|
+
body = body.lstrip(_DECOR)
|
|
93
|
+
for keyword in sorted(verdicts, key=len, reverse=True):
|
|
94
|
+
if not body.upper().startswith(keyword.upper()):
|
|
95
|
+
continue
|
|
96
|
+
after = body[len(keyword):].strip().lstrip(_DECOR).strip()
|
|
97
|
+
if after.startswith("?"):
|
|
98
|
+
return True, None
|
|
99
|
+
if after.startswith("!"):
|
|
100
|
+
if after[1:].strip():
|
|
101
|
+
return True, None # bare '!' is emphasis; '!' plus prose is not
|
|
102
|
+
elif after and after[0] not in _CAVEAT and not after.startswith("-"):
|
|
103
|
+
return True, None # bare prose — discussion, not a decision
|
|
104
|
+
if negated:
|
|
105
|
+
if len(verdicts) == 2:
|
|
106
|
+
other = next(k for k in verdicts if k != keyword)
|
|
107
|
+
return True, verdicts[other]
|
|
108
|
+
return True, None # negation on a larger set is ambiguous
|
|
109
|
+
return True, verdicts[keyword]
|
|
110
|
+
return True, None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class CapturedOutput:
|
|
115
|
+
"""The agent's final response, captured by the pipeline (FR-1.2)."""
|
|
116
|
+
|
|
117
|
+
raw_text: str
|
|
118
|
+
artifact_path: Path
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def from_agent_result(
|
|
122
|
+
conv, artifact: str, state_dir: Path
|
|
123
|
+
) -> "Result[CapturedOutput, CaptureError]":
|
|
124
|
+
text = _final_agent_text(conv)
|
|
125
|
+
if not text.strip():
|
|
126
|
+
return Result.err(CaptureError.EMPTY_RESPONSE)
|
|
127
|
+
return Result.ok(CapturedOutput(raw_text=text, artifact_path=state_dir / artifact))
|
|
128
|
+
|
|
129
|
+
def write(self) -> Result[None, CaptureError]:
|
|
130
|
+
"""Write the captured response to the step's artifact path (BR-4/BR-5)."""
|
|
131
|
+
try:
|
|
132
|
+
self.artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
self.artifact_path.write_text(self.raw_text)
|
|
134
|
+
return Result.ok(None)
|
|
135
|
+
except OSError:
|
|
136
|
+
return Result.err(CaptureError.IO_ERROR)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _final_agent_text(conv) -> str:
|
|
140
|
+
"""Extract the agent's final response from an SDK conversation.
|
|
141
|
+
|
|
142
|
+
Delegates to the SDK's own `get_agent_final_response`, which handles both
|
|
143
|
+
ways an agent can end a run — a `FinishTool` call or a plain-text message.
|
|
144
|
+
The events live on ``conv.state.events`` (NOT ``conv.events``), and must be
|
|
145
|
+
read before ``conv.close()`` releases the event stream.
|
|
146
|
+
"""
|
|
147
|
+
return get_agent_final_response(getattr(conv.state, "events", []))
|
agentic_dev_loop/proc.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Pure subprocess helpers shared by command and action steps.
|
|
2
|
+
|
|
3
|
+
Command steps classify their command string, split simple commands into argv
|
|
4
|
+
lists, and scrub the child environment; action steps reuse the pid-token gate
|
|
5
|
+
and the same env scrubbing. All four functions are stateless (PBT targets).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
_SHELL_METACHARS = frozenset("&|;><$`~*?[]")
|
|
15
|
+
_FIXED_ENV = ("PATH", "HOME", "LANG", "LC_ALL", "TERM")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def needs_shell(command: str) -> bool:
|
|
19
|
+
"""True when `command` carries shell metacharacters (``&&``, ``|``,
|
|
20
|
+
``;``, ``>``, ``<``, ``$``, backticks, ``~``, or glob chars). Simple
|
|
21
|
+
commands are shlex-split and run shell-free; anything else keeps
|
|
22
|
+
``shell=True`` (BR-4.1 / BR-4.2)."""
|
|
23
|
+
return any(char in _SHELL_METACHARS for char in command)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def split_command(command: str) -> list[str]:
|
|
27
|
+
"""Split a simple command into argv via ``shlex.split``. Callers invoke
|
|
28
|
+
this only when ``needs_shell`` is false — ``shlex`` never sees a shell
|
|
29
|
+
command (BR-4.1)."""
|
|
30
|
+
return shlex.split(command)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_pid_token(token: str) -> bool:
|
|
34
|
+
"""True only for a plain decimal integer — the only tokens from ``lsof``
|
|
35
|
+
output safe to hand to ``kill``/``ps`` (BR-5.2)."""
|
|
36
|
+
return token.isascii() and token.isdigit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def child_env(allowlist: list[str] | None = None,
|
|
40
|
+
api_key_env: str | None = None,
|
|
41
|
+
parent: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
42
|
+
"""Build a scrubbed child environment: the fixed known-safe whitelist
|
|
43
|
+
(``PATH``, ``HOME``, ``LANG``, ``LC_ALL``, ``TERM``), plus the env var
|
|
44
|
+
named by ``api_key_env`` (always included by default), plus any
|
|
45
|
+
additional names in ``allowlist`` (the author-extensible
|
|
46
|
+
``env_allowlist``). Everything else is excluded
|
|
47
|
+
(BR-4.3, BR-5.4, NFR-2.1)."""
|
|
48
|
+
source = os.environ if parent is None else parent
|
|
49
|
+
names = set(_FIXED_ENV)
|
|
50
|
+
if api_key_env:
|
|
51
|
+
names.add(api_key_env)
|
|
52
|
+
if allowlist:
|
|
53
|
+
names.update(allowlist)
|
|
54
|
+
return {name: source[name] for name in names if name in source}
|
|
File without changes
|