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,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
@@ -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", []))
@@ -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