tools: model batch steps as nested executions

This commit is contained in:
2026-11-02 14:21:26 +08:00
parent b897bac52b
commit 5c13d10989
8 changed files with 474 additions and 110 deletions
+41 -18
View File
@@ -36,7 +36,8 @@ _TOOL_ARGS_PREVIEW = 80
_ASK_SUBJECT_PREVIEW = 60 _ASK_SUBJECT_PREVIEW = 60
_PTY_PAYLOAD_PREVIEW = 40 _PTY_PAYLOAD_PREVIEW = 40
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one # ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
# line per command, a ``pipe_out`` chain joined into one — elided past this width. # line per command, a pipe chain joined into one, a ``then`` / ``else`` body
# indented under the node it follows — elided past this width.
_RUN_BATCH_CMD_LIMIT = 80 _RUN_BATCH_CMD_LIMIT = 80
_MCP_TOOL_PREFIX = "mcp__" _MCP_TOOL_PREFIX = "mcp__"
_SKILL_MAIN_FILE = "SKILL.md" _SKILL_MAIN_FILE = "SKILL.md"
@@ -417,32 +418,54 @@ def _batch_steps(args_json: str) -> list[dict[str, object]]:
value = json.loads(value) value = json.loads(value)
except ValueError: except ValueError:
return [] return []
return _batch_nodes(value)
def _batch_nodes(value: object) -> list[dict[str, object]]:
"""Nodes of one continuation: a single node, or a list of them."""
if isinstance(value, dict):
return [cast("dict[str, object]", value)]
if not isinstance(value, list): if not isinstance(value, list):
return [] return []
raw_steps = cast("list[object]", value) return [cast("dict[str, object]", item) for item in cast("list[object]", value) if isinstance(item, dict)]
return [cast("dict[str, object]", step) for step in raw_steps if isinstance(step, dict)]
def _batch_command_lines(args_json: str) -> list[str]: def _batch_chain(node: dict[str, object]) -> str:
"""One command per line, a ``pipe_out`` chain joined into one line with ``|``.""" """``a | b | c`` for a node and the commands its pipe feeds."""
lines: list[str] = [] argv = _str_list(node.get("argv"))
piped_from_previous = False parts = [shlex.join(argv) if argv else "?"]
for step in _batch_steps(args_json): parts.extend(_batch_chain(child) for child in _batch_nodes(node.get("pipe")))
argv = _str_list(step.get("argv")) return " | ".join(parts)
command = shlex.join(argv) if argv else "?"
if piped_from_previous and lines:
lines[-1] = f"{lines[-1]} | {command}" def _batch_node_lines(
else: nodes: list[dict[str, object]],
lines.append(command) depth: int,
# Same flag spellings the tool itself accepts (bool, or "true"/"1"/…). prefix: str = "",
piped_from_previous = str(step.get("pipe_out") or "").strip().lower() in {"1", "true", "yes", "on"} ) -> list[tuple[str, bool]]:
"""One line per command, and whether it is a command (``$``) or a branch."""
lines: list[tuple[str, bool]] = []
for node in nodes:
lines.append((f"{' ' * depth}{prefix}{_batch_chain(node)}", not prefix))
for branch in ("then", "else"):
children = _batch_nodes(node.get(branch))
if children:
lines.extend(_batch_node_lines(children, depth + 1, f"{branch} "))
return lines return lines
def _batch_command_lines(args_json: str) -> list[tuple[str, bool]]:
"""One line per command, a pipe chain joined into one line with ``|``."""
return _batch_node_lines(_batch_steps(args_json), 0)
def _run_argv_batch_prefix(args_json: str) -> str: def _run_argv_batch_prefix(args_json: str) -> str:
"""``* Run batch `` with the commands it runs listed above it.""" """``* Run batch `` with the commands it runs listed above it."""
commands = _batch_command_lines(args_json) lines = [
body = "\n".join(_pretty_prefix(f"$ {_preview(command, _RUN_BATCH_CMD_LIMIT)}") for command in commands) _pretty_prefix(f"$ {_preview(text, _RUN_BATCH_CMD_LIMIT)}" if is_command else f"{text}")
for text, is_command in _batch_command_lines(args_json)
]
body = "\n".join(lines)
header = _pretty_prefix("* Run batch ") header = _pretty_prefix("* Run batch ")
return f"{body}\n{header}" if body else header return f"{body}\n{header}" if body else header
+1 -1
View File
@@ -76,7 +76,7 @@ confirm blocks the turn, so it costs time. Invoke the target command directly (a
of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \ of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \
(`read_file` / `edit_*` / `vcs_*` / `fetch` / `fetch_local` / …) when one already does the job. (`read_file` / `edit_*` / `vcs_*` / `fetch` / `fetch_local` / …) when one already does the job.
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \ - Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
for ordered pipelines (`pipe_out` / `mix_stderr` as needed). for ordered commands, with `then` / `else` / `pipe` continuations as needed.
- Prefer `vcs_*` for status/diff/log/branch when enough. - Prefer `vcs_*` for status/diff/log/branch when enough.
### Network ### Network
+32 -7
View File
@@ -10,7 +10,7 @@ from plyngent.tools.trash import under_system_temp
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_trash_enabled, resolve_path from plyngent.tools.workspace import AccessMode, WorkspaceError, get_trash_enabled, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Iterator, Mapping, Sequence
def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] | None: def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] | None:
@@ -188,6 +188,32 @@ def _batch_step_argv(item: object) -> list[str] | None:
return parts or None return parts or None
def _batch_nodes(value: object) -> list[dict[str, object]]:
"""Nodes of one continuation: a single node, or a list of them."""
if isinstance(value, dict):
return [cast("dict[str, object]", value)]
if not isinstance(value, list):
return []
return [cast("dict[str, object]", item) for item in cast("list[object]", value) if isinstance(item, dict)]
def _walk_batch_argv(value: object, where: str) -> Iterator[tuple[str, list[str]]]:
"""Every command of a batch tree, in the order it can run, with its path.
``then`` / ``else`` / ``pipe`` hold the continuations, so a command hiding in
one is reviewed exactly like a top-level step.
"""
for index, node in enumerate(_batch_nodes(value)):
label = f"{where}[{index}]"
argv = _batch_step_argv(node)
if argv is not None:
yield label, argv
for branch in ("then", "else", "pipe"):
child = node.get(branch)
if child is not None:
yield from _walk_batch_argv(child, f"{label}.{branch}")
def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None: def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
"""One confirm for the whole batch if any step runs code or detaches. """One confirm for the whole batch if any step runs code or detaches.
@@ -203,12 +229,11 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
raw = loaded raw = loaded
if not isinstance(raw, list): if not isinstance(raw, list):
return None return None
risky = [ risky: list[DangerReason] = []
reason for label, argv in _walk_batch_argv(cast("list[object]", raw), "run_argv_batch"):
for index, item in enumerate(cast("list[object]", raw)) reason = _command_reason(argv, via=label)
if (argv := _batch_step_argv(item)) is not None if reason is not None:
and (reason := _command_reason(argv, via=f"run_argv_batch[{index}]")) is not None risky.append(reason)
]
if not risky: if not risky:
return None return None
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)" header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)"
+3 -1
View File
@@ -19,6 +19,8 @@ from plyngent.tools.workspace import (
DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_MAX_BATCH_STEPS = 20 DEFAULT_MAX_BATCH_STEPS = 20
# A batch node's continuations nest, so the tree needs its own ceiling.
MAX_BATCH_DEPTH = 4
def format_command_result( def format_command_result(
@@ -65,7 +67,7 @@ class CommandStepResult:
stdout: str stdout: str
stderr: str stderr: str
mix_stderr: bool mix_stderr: bool
captured: str # piped to the next step when provider sets pipe_out captured: str # stdout, fed to the next command of a pipe
async def execute_argv( async def execute_argv(
+198 -63
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import shlex import shlex
from dataclasses import dataclass, field
from typing import Any, cast from typing import Any, cast
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
@@ -10,6 +11,7 @@ from plyngent.tools.workspace import WorkspaceError
from .command_exec import ( from .command_exec import (
DEFAULT_MAX_BATCH_STEPS, DEFAULT_MAX_BATCH_STEPS,
DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS,
MAX_BATCH_DEPTH,
CommandStepResult, CommandStepResult,
execute_argv, execute_argv,
) )
@@ -19,6 +21,14 @@ from .command_exec import (
BATCH_OUTPUT_BUDGET_CHARS = 32_000 BATCH_OUTPUT_BUDGET_CHARS = 32_000
BATCH_OUTPUT_SOFT_CAP_FACTOR = 4 BATCH_OUTPUT_SOFT_CAP_FACTOR = 4
# A node is one command plus up to three continuations: ``then`` runs when it
# succeeds, ``else`` when it fails, ``pipe`` takes its stdout as stdin. Only a
# pipe hands data to its child, so a pipe stage may not branch: the stage's
# result belongs to the node that owns the pipe.
_CONTINUATIONS = ("then", "else", "pipe")
_COMMAND_KEYS = frozenset({"argv", "cwd", "env", "stdin", "mix_stderr", "stop_on_error", "timeout_seconds"})
_ACCEPTED_KEYS = "argv, cwd, env, stdin, mix_stderr, stop_on_error, timeout_seconds, then, else, pipe"
def _as_bool(value: object, *, default: bool = False) -> bool: def _as_bool(value: object, *, default: bool = False) -> bool:
if value is None: if value is None:
@@ -49,33 +59,86 @@ def _as_str_dict(value: object) -> dict[str, str] | None:
return {str(key): str(val) for key, val in cast("dict[object, object]", value).items()} return {str(key): str(val) for key, val in cast("dict[object, object]", value).items()}
def _parse_step(raw: object, index: int) -> dict[str, Any]: @dataclass
class _Budget:
"""Parsed node count: every node costs one, however deeply it nests."""
nodes: int = 0
def take(self, where: str) -> None:
self.nodes += 1
if self.nodes > DEFAULT_MAX_BATCH_STEPS:
msg = f"more than {DEFAULT_MAX_BATCH_STEPS} nodes in one batch (at {where})"
raise WorkspaceError(msg)
def _parse_nodes(
value: object,
*,
where: str,
depth: int,
budget: _Budget,
fed: bool,
) -> list[dict[str, Any]]:
"""A continuation: one node or a list of them, in the order they run."""
if value is None:
return []
items = cast("list[object]", value) if isinstance(value, list) else [value]
parsed: list[dict[str, Any]] = []
for index, item in enumerate(items):
at = f"{where}[{index}]" if isinstance(value, list) else where
parsed.append(_parse_node(item, where=at, depth=depth, budget=budget, fed=fed))
return parsed
def _parse_node(raw: object, *, where: str, depth: int, budget: _Budget, fed: bool) -> dict[str, Any]:
if not isinstance(raw, dict): if not isinstance(raw, dict):
msg = f"steps[{index}] must be an object" msg = f"{where} must be an object"
raise WorkspaceError(msg)
if depth > MAX_BATCH_DEPTH:
msg = f"{where}: nodes nest at most {MAX_BATCH_DEPTH} levels deep"
raise WorkspaceError(msg) raise WorkspaceError(msg)
data = cast("dict[str, object]", raw) data = cast("dict[str, object]", raw)
unknown = sorted(set(data) - _COMMAND_KEYS - set(_CONTINUATIONS))
if unknown:
hint = '; write a pipeline as {"pipe": [...]}, not "pipe_out"' if "pipe_out" in unknown else ""
msg = f"{where} has unknown key(s) {', '.join(unknown)}; accepted keys are {_ACCEPTED_KEYS}{hint}"
raise WorkspaceError(msg)
budget.take(where)
argv = data.get("argv") argv = data.get("argv")
if not isinstance(argv, list) or not argv: if not isinstance(argv, list) or not argv:
msg = f"steps[{index}].argv must be a non-empty argv list" msg = f"{where}.argv must be a non-empty argv list"
raise WorkspaceError(msg) raise WorkspaceError(msg)
argv_parts = cast("list[object]", argv) argv_parts = cast("list[object]", argv)
argv_str = [part for part in argv_parts if isinstance(part, str)] argv_str = [part for part in argv_parts if isinstance(part, str)]
if len(argv_str) != len(argv_parts): if len(argv_str) != len(argv_parts):
msg = f"steps[{index}].argv must be a list of strings" msg = f"{where}.argv must be a list of strings"
raise WorkspaceError(msg)
stdin = data.get("stdin")
if fed and stdin is not None:
msg = f"{where}: stdin comes from the pipe; drop stdin or split the command"
raise WorkspaceError(msg) raise WorkspaceError(msg)
cwd = data.get("cwd") cwd = data.get("cwd")
stdin = data.get("stdin")
stop = data.get("stop_on_error", None) stop = data.get("stop_on_error", None)
return { node: dict[str, Any] = {
"argv": argv_str, "argv": argv_str,
"cwd": str(cwd) if isinstance(cwd, str) and cwd else None, "cwd": str(cwd) if isinstance(cwd, str) and cwd else None,
"env": _as_str_dict(data.get("env")), "env": _as_str_dict(data.get("env")),
"stdin": None if stdin is None else str(stdin), "stdin": None if stdin is None else str(stdin),
"pipe_out": _as_bool(data.get("pipe_out"), default=False),
"mix_stderr": _as_bool(data.get("mix_stderr"), default=False), "mix_stderr": _as_bool(data.get("mix_stderr"), default=False),
"stop_on_error": None if stop is None else _as_bool(stop, default=True), "stop_on_error": None if stop is None else _as_bool(stop, default=True),
"timeout_seconds": _as_float(data.get("timeout_seconds"), default=DEFAULT_TIMEOUT_SECONDS), "timeout_seconds": _as_float(data.get("timeout_seconds"), default=DEFAULT_TIMEOUT_SECONDS),
} }
node["then"] = _parse_nodes(data.get("then"), where=f"{where}.then", depth=depth + 1, budget=budget, fed=False)
node["else"] = _parse_nodes(data.get("else"), where=f"{where}.else", depth=depth + 1, budget=budget, fed=False)
node["pipe"] = _parse_nodes(data.get("pipe"), where=f"{where}.pipe", depth=depth + 1, budget=budget, fed=True)
if data.get("pipe") is not None and not node["pipe"]:
msg = f"{where}.pipe must not be empty"
raise WorkspaceError(msg)
if fed and (node["then"] or node["else"]):
msg = f"{where}: a pipe step takes no then/else; branch on the node that owns the pipe"
raise WorkspaceError(msg)
return node
def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]: def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
@@ -92,15 +155,39 @@ def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
return cast("list[object]", steps) return cast("list[object]", steps)
def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> str: @dataclass(frozen=True)
return "\n".join( class _Step:
"""One executed command, and whether its stdout fed another one."""
result: CommandStepResult
depth: int
via: tuple[str, int] | None
feeds: bool
@dataclass(frozen=True)
class _Stage:
"""Exit code of a node's command stage — its pipe chain, for a piped node."""
code: int | None
failed: bool
captured: str
def _format_step(index: int, step: _Step) -> str:
result = step.result
lines = [f"--- step {index} ---"]
if step.depth > 0:
lines.append(f"depth={step.depth}")
if step.via is not None:
lines.append(f"via={step.via[0]} of step {step.via[1]}")
lines.extend(
[ [
f"--- step {index} ---",
f"exit_code={result.exit_code if result.exit_code is not None else ''}", f"exit_code={result.exit_code if result.exit_code is not None else ''}",
f"timed_out={'true' if result.timed_out else 'false'}", f"timed_out={'true' if result.timed_out else 'false'}",
f"cwd={result.cwd_display}", f"cwd={result.cwd_display}",
f"cmd={shlex.join(result.command)}", f"cmd={shlex.join(result.command)}",
f"pipe_out={'true' if pipe_out else 'false'}", f"pipe={'true' if step.feeds else 'false'}",
f"mix_stderr={'true' if result.mix_stderr else 'false'}", f"mix_stderr={'true' if result.mix_stderr else 'false'}",
"--- stdout ---", "--- stdout ---",
result.stdout, result.stdout,
@@ -108,6 +195,7 @@ def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> st
result.stderr, result.stderr,
] ]
) )
return "\n".join(lines)
def _merge_env(base_env: dict[str, str] | None, step_env: dict[str, str] | None) -> dict[str, str] | None: def _merge_env(base_env: dict[str, str] | None, step_env: dict[str, str] | None) -> dict[str, str] | None:
@@ -116,43 +204,88 @@ def _merge_env(base_env: dict[str, str] | None, step_env: dict[str, str] | None)
return {**(base_env or {}), **(step_env or {})} return {**(base_env or {}), **(step_env or {})}
async def _run_batch_steps( @dataclass
steps: list[dict[str, Any]], class _Runner:
"""Runs the node tree, remembering each command and when to stop."""
cwd: str
env: dict[str, str] | None
stop_on_error: bool
steps: list[_Step] = field(default_factory=list)
chars: int = 0
stopped: bool = False
async def run_list(
self,
nodes: list[dict[str, Any]],
*, *,
cwd: str, depth: int,
env: dict[str, str] | None, branch: str | None = None,
stop_on_error: bool, parent: int | None = None,
) -> tuple[list[tuple[dict[str, Any], CommandStepResult]], bool]: ) -> None:
results: list[tuple[dict[str, Any], CommandStepResult]] = [] via = (branch, parent) if branch is not None and parent is not None else None
prev_capture: str | None = None for node in nodes:
prev_piped = False if self.stopped:
stopped_early = False return
total_chars = 0 _ = await self.run_node(node, depth=depth, via=via)
for index, step in enumerate(steps): async def run_node(
stdin_text = prev_capture if prev_piped else step["stdin"] self,
node: dict[str, Any],
*,
depth: int,
via: tuple[str, int] | None,
stdin: str | None = None,
also_feeds: bool = False,
stage_only: bool = False,
) -> _Stage:
result = await execute_argv( result = await execute_argv(
step["argv"], node["argv"],
cwd=step["cwd"] if step["cwd"] is not None else cwd, cwd=node["cwd"] if node["cwd"] is not None else self.cwd,
env=_merge_env(env, step["env"]), env=_merge_env(self.env, node["env"]),
stdin=stdin_text, stdin=stdin if stdin is not None else node["stdin"],
timeout_seconds=step["timeout_seconds"], timeout_seconds=node["timeout_seconds"],
mix_stderr=step["mix_stderr"], mix_stderr=node["mix_stderr"],
) )
results.append((step, result)) index = len(self.steps)
prev_capture = result.captured self.steps.append(_Step(result, depth, via, feeds=bool(node["pipe"]) or also_feeds))
prev_piped = bool(step["pipe_out"]) self.chars += len(result.stdout) + len(result.stderr)
total_chars += len(result.stdout) + len(result.stderr) stage = _Stage(result.exit_code, bool(result.timed_out) or result.exit_code != 0, result.captured)
failed = bool(result.timed_out) or result.exit_code != 0 chain: list[dict[str, Any]] = node["pipe"]
effective_stop = stop_on_error if step["stop_on_error"] is None else bool(step["stop_on_error"]) piped = result.captured
if failed and effective_stop: for position, child in enumerate(chain):
return results, True if self.stopped:
if total_chars > BATCH_OUTPUT_BUDGET_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR: break
return results, True stage = await self.run_node(
del index # used for clarity in loop only child,
depth=depth + 1,
via=("pipe", index),
stdin=piped,
also_feeds=position + 1 < len(chain),
stage_only=True,
)
piped = stage.captured
return results, stopped_early if self.chars > BATCH_OUTPUT_BUDGET_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
self.stopped = True
return stage
# A pipe stage's failure is the pipe owner's to answer: the chain still
# ran to its last command, so only that result decides the branch.
if stage_only:
return stage
if stage.failed:
if node["else"]:
await self.run_list(node["else"], depth=depth + 1, branch="else", parent=index)
elif self._effective_stop(node):
self.stopped = True
return stage
if node["then"]:
await self.run_list(node["then"], depth=depth + 1, branch="then", parent=index)
return stage
def _effective_stop(self, node: dict[str, Any]) -> bool:
return self.stop_on_error if node["stop_on_error"] is None else bool(node["stop_on_error"])
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@@ -163,47 +296,49 @@ async def run_argv_batch(
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
stop_on_error: bool = True, stop_on_error: bool = True,
) -> str: ) -> str:
"""Run a serial batch of argv commands (no shell). """Run a batch of argv commands (no shell), each with optional continuations.
Each element of ``steps`` is an object:: A node is an object::
{ {
"argv": ["git", "status"], # required argv "argv": ["pytest", "-q"], # required argv
"then": {"argv": [...]}, # runs when argv succeeds (node or list)
"else": {"argv": [...]}, # runs when argv fails (node or list)
"pipe": {"argv": [...]}, # takes argv's stdout as its stdin
"cwd": ".", # optional, else batch cwd "cwd": ".", # optional, else batch cwd
"env": {"FOO": "1"}, # optional overlay "env": {"FOO": "1"}, # optional overlay
"stdin": "...", # optional; unused if previous pipe_out "stdin": "...", # optional; a pipe step takes it from the pipe
"pipe_out": false, # if true, feed capture into *next* stdin
"mix_stderr": false, # OS-level merge stderr into capture "mix_stderr": false, # OS-level merge stderr into capture
"stop_on_error": null, # override batch stop_on_error "stop_on_error": null, # override batch stop_on_error
"timeout_seconds": 30 "timeout_seconds": 30
} }
``pipe_out`` is set on the **provider** step. Last-step ``pipe_out`` is an error. ``steps`` is a list of nodes, and ``then`` / ``else`` / ``pipe`` each take one
Default ``stop_on_error`` is true. Max 20 steps; the batch stops early once node or a list of them, so the tree nests. A failure with no ``else`` stops
accumulated output passes the run budget (128k chars). Each step's output is the batch, unless that node sets ``stop_on_error: false``: it then skips its
kept whole for the model; a long result carries a ``document_id`` to read on. own continuations and the next sibling runs. A ``pipe`` step takes no
``then`` / ``else`` — branch on the node that owns the pipe. Max 20 nodes and
4 nesting levels; the batch stops early once accumulated output passes the
run budget (128k chars). Each command's output is kept whole for the model; a
long result carries a ``document_id`` to read on.
""" """
try: try:
raw_steps = _normalize_steps(steps) raw_steps = _normalize_steps(steps)
if not raw_steps: if not raw_steps:
return "error: steps must not be empty" return "error: steps must not be empty"
if len(raw_steps) > DEFAULT_MAX_BATCH_STEPS:
return f"error: at most {DEFAULT_MAX_BATCH_STEPS} steps per batch"
steps = [_parse_step(item, i) for i, item in enumerate(raw_steps)] budget = _Budget()
if steps[-1]["pipe_out"]: nodes = _parse_nodes(raw_steps, where="steps", depth=0, budget=budget, fed=False)
return "error: last step has pipe_out=true but there is no next step" runner = _Runner(cwd=cwd, env=env, stop_on_error=stop_on_error)
await runner.run_list(nodes, depth=0)
results, stopped_early = await _run_batch_steps(steps, cwd=cwd, env=env, stop_on_error=stop_on_error) body = "\n".join(_format_step(i, step) for i, step in enumerate(runner.steps))
body = "\n".join( last_exit = runner.steps[-1].result.exit_code if runner.steps else ""
_format_step(i, result, pipe_out=bool(step["pipe_out"])) for i, (step, result) in enumerate(results)
)
last_exit = results[-1][1].exit_code if results else ""
return "\n".join( return "\n".join(
[ [
f"steps={len(steps)} ran={len(results)} " f"steps={budget.nodes} ran={len(runner.steps)} "
f"stop_on_error={'true' if stop_on_error else 'false'} " f"stop_on_error={'true' if stop_on_error else 'false'} "
f"stopped_early={'true' if stopped_early else 'false'}", f"stopped_early={'true' if runner.stopped else 'false'}",
body, body,
"--- summary ---", "--- summary ---",
f"last_exit={last_exit if last_exit is not None else ''}", f"last_exit={last_exit if last_exit is not None else ''}",
+16 -5
View File
@@ -456,9 +456,9 @@ async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str])
result = ( result = (
"steps=3 ran=2 stop_on_error=true stopped_early=true\n" "steps=3 ran=2 stop_on_error=true stopped_early=true\n"
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n" "--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n" "pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n" "--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n" "pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=1\n" "--- summary ---\nlast_exit=1\n"
) )
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)])) await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
@@ -495,16 +495,27 @@ async def test_pretty_run_argv_batch_lists_commands(capsys: pytest.CaptureFixtur
async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None: async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None:
"""``pipe_out`` steps share one line; a step they do not feed keeps its own.""" """A pipe chain shares one line; a node it does not feed keeps its own."""
args = ( args = (
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe_out": true},' '{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe": {"argv": ["wc", "-l"]}},'
' {"argv": ["wc", "-l"]}, {"argv": ["git", "status"]}]}' ' {"argv": ["git", "status"]}]}'
) )
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)])) await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_indents_branches(capsys: pytest.CaptureFixture[str]) -> None:
"""A ``then`` / ``else`` body sits below the node it follows."""
args = (
'{"steps": [{"argv": ["pytest", "-q"], "then": {"argv": ["ruff", "format", "."]},'
' "else": [{"argv": ["git", "status"]}]}]}'
)
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
assert "$ pytest -q\n then ruff format .\n else git status\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None: async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None:
long_arg = "x" * 200 long_arg = "x" * 200
args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}' args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}'
+28
View File
@@ -108,6 +108,34 @@ def test_classify_run_argv_batch_risky() -> None:
) )
def test_classify_run_argv_batch_sees_nested_steps() -> None:
"""A command hiding in a continuation is reviewed like a top-level step."""
reason = _danger(
"run_argv_batch",
{
"steps": [
{
"argv": ["echo", "ok"],
"then": {"argv": ["bash", "-c", "echo risky"]},
"else": [{"argv": ["sh", "-c", "echo risky"]}],
}
]
},
)
assert "run_argv_batch[0].then[0]" in reason.text
assert "run_argv_batch[0].else[0]" in reason.text
# A plain interpreter run is YOLO mode's to drop, wherever it sits.
assert reason.yolo_skip is True
def test_a_nested_step_that_escalates_keeps_the_batch_prompt() -> None:
reason = _danger(
"run_argv_batch",
{"steps": [{"argv": ["echo", "ok"], "then": {"argv": ["sudo", "python", "-c", "print(1)"]}}]},
)
assert reason.yolo_skip is False
def test_classify_shell_and_dash_c() -> None: def test_classify_shell_and_dash_c() -> None:
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]}) r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
assert r is not None assert r is not None
+150 -10
View File
@@ -112,33 +112,31 @@ async def test_run_argv_batch_stop_on_error(workspace: object) -> None:
assert "should-not-run" not in out assert "should-not-run" not in out
async def test_run_argv_batch_pipe_out(workspace: object) -> None: async def test_run_argv_batch_pipe(workspace: object) -> None:
del workspace del workspace
out = await call_async( out = await call_async(
run_argv_batch, run_argv_batch,
[ [
{ {
"argv": _py("print('piped-payload', end='')"), "argv": _py("print('piped-payload', end='')"),
"pipe_out": True, "pipe": {"argv": _py("import sys; print(sys.stdin.read())")},
},
{
"argv": _py("import sys; print(sys.stdin.read())"),
}, },
], ],
) )
assert "ran=2" in out assert "ran=2" in out
assert "piped-payload" in out assert "piped-payload" in out
assert "pipe_out=true" in out assert "pipe=true" in out
async def test_run_argv_batch_last_pipe_out_error(workspace: object) -> None: async def test_run_argv_batch_pipe_out_is_gone(workspace: object) -> None:
"""The old provider-side flag is refused, pointing at the pipe node."""
del workspace del workspace
out = await call_async( out = await call_async(
run_argv_batch, run_argv_batch,
[{"argv": _py("print(1)"), "pipe_out": True}], [{"argv": _py("print(1)"), "pipe_out": True}],
) )
assert "error:" in out assert "error:" in out
assert "pipe_out" in out assert '{"pipe": [...]}' in out
async def test_run_argv_batch_mix_stderr(workspace: object) -> None: async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
@@ -149,9 +147,8 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
{ {
"argv": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"), "argv": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
"mix_stderr": True, "mix_stderr": True,
"pipe_out": True, "pipe": {"argv": _py("import sys; print(repr(sys.stdin.read()))")},
}, },
{"argv": _py("import sys; print(repr(sys.stdin.read()))")},
], ],
) )
assert "ran=2" in out assert "ran=2" in out
@@ -159,6 +156,149 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
assert "out" in out and "err" in out assert "out" in out and "err" in out
async def test_run_argv_batch_then_runs_on_success(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print('first')"), "then": {"argv": _py("print('then-body')")}}],
)
assert "then-body" in out
assert "ran=2" in out
assert "depth=1" in out
assert "via=then of step 0" in out
async def test_run_argv_batch_else_handles_the_failure(workspace: object) -> None:
"""A failure with an ``else`` is answered, so the batch keeps going."""
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("import sys; sys.exit(2)"),
"else": {"argv": _py("print('else-body')")},
},
{"argv": _py("print('sibling-ran')")},
],
)
assert "else-body" in out
assert "sibling-ran" in out
assert "via=else of step 0" in out
assert "stopped_early=false" in out
async def test_run_argv_batch_runs_a_then_list_in_order(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("print('first')"),
"then": [{"argv": _py("print('second')")}, {"argv": _py("print('third')")}],
}
],
)
assert "ran=3" in out
assert out.index("first") < out.index("second") < out.index("third")
async def test_run_argv_batch_a_failing_branch_stops_the_batch(workspace: object) -> None:
"""Nobody answered a failure inside a continuation, so the batch stops."""
del workspace
out = await call_async(
run_argv_batch,
[
{"argv": _py("print('first')"), "then": {"argv": _py("import sys; sys.exit(4)")}},
{"argv": _py("print('sibling-ran')")},
],
)
assert "stopped_early=true" in out
assert "sibling-ran" not in out
async def test_run_argv_batch_stop_on_error_false_skips_only_its_continuations(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("import sys; sys.exit(2)"),
"stop_on_error": False,
"then": {"argv": _py("print('then-ran')")},
},
{"argv": _py("print('sibling-ran')")},
],
)
assert "then-ran" not in out
assert "sibling-ran" in out
assert "stopped_early=false" in out
async def test_run_argv_batch_branches_on_the_pipe_result(workspace: object) -> None:
"""A piped node branches on the last command of its chain."""
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("print('payload', end='')"),
"pipe": {"argv": _py("import sys; sys.stdin.read(); sys.exit(3)")},
"then": {"argv": _py("print('pipe-ok')")},
"else": {"argv": _py("print('pipe-failed')")},
}
],
)
assert "pipe-failed" in out
assert "pipe-ok" not in out
assert "ran=3" in out
async def test_run_argv_batch_pipe_step_takes_no_branch(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "then": {"argv": _py("print(3)")}}}],
)
assert "error:" in out
assert "pipe step takes no then/else" in out
async def test_run_argv_batch_pipe_step_takes_no_stdin(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "stdin": "own"}}],
)
assert "error:" in out
assert "stdin comes from the pipe" in out
async def test_run_argv_batch_rejects_a_deep_tree(workspace: object) -> None:
del workspace
node: dict[str, object] = {"argv": _py("print(1)")}
for _ in range(6):
node = {"argv": _py("print(1)"), "then": node}
out = await call_async(run_argv_batch, [node])
assert "error:" in out
assert "nest at most 4 levels deep" in out
async def test_run_argv_batch_counts_nested_nodes(workspace: object) -> None:
"""Every node of the tree costs one, so a nested tree hits the same cap."""
del workspace
out = await call_async(run_argv_batch, [{"argv": _py("print(1)")} for _ in range(21)])
assert "error:" in out
assert "more than 20 nodes" in out
async def test_run_argv_batch_names_the_node_grammar(workspace: object) -> None:
del workspace
out = await call_async(run_argv_batch, [{"argv": _py("print(1)"), "when": "always"}])
assert "error:" in out
assert "unknown key(s) when" in out
assert "then, else, pipe" in out
async def test_run_argv_denied(workspace: object) -> None: async def test_run_argv_denied(workspace: object) -> None:
del workspace del workspace
out = await call_async(run_argv, ["rm", "-rf", "."]) out = await call_async(run_argv, ["rm", "-rf", "."])