mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
tools: run batch continuations where their parent ran
This commit is contained in:
@@ -100,7 +100,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (process only: an answer a host keeps without persisting — YOLO/`--yes` never fills it, an out-of-workspace grant is asked either way). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume a truncated `read_file` slice or `@path` mention via its `truncate_token=...`), `read_document` / `search_document` (read a capped tool result by its `document_id`: a line window with optional line numbers, or a per-line regex), `document_info` / `list_documents` (describe or list the session's documents).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (a node is `argv` plus `then` / `else` / `pipe` continuations — one node or a list each — with `cwd` / `env` / `stdin` / `mix_stderr` / `stop_on_error` / `timeout_seconds`, max 20 nodes and 4 levels, and a pipe step that takes no `then` / `else`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (a node is `argv` plus `then` / `else` / `pipe` continuations — one node or a list each — with `cwd` / `env` / `stdin` / `mix_stderr` / `stop_on_error` / `timeout_seconds`; a continuation inherits its parent's `cwd` / `env`, a `pipe` chain runs to its last command and only that exit code decides the branch, max 20 nodes and 4 levels, and a pipe step takes no `then` / `else`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`<c-c>`, `<esc>`, `<c-up>`, `<c-s-f5>`, `<up*3>`, `<m-x>`, `\xHH` — xterm modifier sequences where they exist; an unknown `<…>` is an error, never typed); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged and a directory-access request is asked either way; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt. Every allow says so in the result itself: the tool text leads with `[approval] user (once)`, `user (round)` (batch), `grant (session)` (a TRUSTABLE grant, so nobody was asked this time) or `yolo (auto)`, and a handler that prompts on its own records it with `tools.context.record_approval` — `workspace.check_command_allowed` (`user`/`grant (process)`), `tools/net/grants.py` (`user`/`grant (process)`) and the skill write path (`user (once|session)`, `grant (session)`, `config (policy)`). One line per call (`Approval` + `most_specific_approval`: the answer closest to a human wins), none for a call that needed no confirmation or ended in `error:`, and it leads so a wire-capped result and `read_document` keep it. A refusal names which no it was (`Denial` / `DenialKind`): `user declined` (with the comment), `no answer within 30s — denied by default` (the prompt's deadline), `prompt cancelled` (Ctrl+C / EOF), or `no confirm hook or non-interactive (denied)` — a timeout is never reported as a decision, and every timed prompt (`workspace.check_command_allowed`, `tools/net/grants.py`, `tools/skills.py`, `tools/access.py`) carries the reason it got into the tool message.
|
||||
|
||||
@@ -254,12 +254,14 @@ class _Runner:
|
||||
depth: int,
|
||||
branch: str | None = None,
|
||||
parent: int | None = None,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
via = (branch, parent) if branch is not None and parent is not None else None
|
||||
for node in nodes:
|
||||
if self.stopped:
|
||||
return
|
||||
_ = await self.run_node(node, depth=depth, via=via)
|
||||
_ = await self.run_node(node, depth=depth, via=via, cwd=cwd, env=env)
|
||||
|
||||
async def run_node(
|
||||
self,
|
||||
@@ -270,11 +272,17 @@ class _Runner:
|
||||
stdin: str | None = None,
|
||||
also_feeds: bool = False,
|
||||
stage_only: bool = False,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> _Stage:
|
||||
# A continuation runs where its parent ran, unless it names its own
|
||||
# directory or environment; the batch's cwd/env are the outermost default.
|
||||
node_cwd = node["cwd"] if node["cwd"] is not None else (self.cwd if cwd is None else cwd)
|
||||
node_env = _merge_env(self.env if env is None else env, node["env"])
|
||||
result = await execute_argv(
|
||||
node["argv"],
|
||||
cwd=node["cwd"] if node["cwd"] is not None else self.cwd,
|
||||
env=_merge_env(self.env, node["env"]),
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
stdin=stdin if stdin is not None else node["stdin"],
|
||||
timeout_seconds=node["timeout_seconds"],
|
||||
mix_stderr=node["mix_stderr"],
|
||||
@@ -296,6 +304,8 @@ class _Runner:
|
||||
stdin=piped,
|
||||
also_feeds=position + 1 < len(chain),
|
||||
stage_only=True,
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
)
|
||||
piped = stage.captured
|
||||
|
||||
@@ -308,12 +318,26 @@ class _Runner:
|
||||
return stage
|
||||
if stage.failed:
|
||||
if node["else"]:
|
||||
await self.run_list(node["else"], depth=depth + 1, branch="else", parent=index)
|
||||
await self.run_list(
|
||||
node["else"],
|
||||
depth=depth + 1,
|
||||
branch="else",
|
||||
parent=index,
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
)
|
||||
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)
|
||||
await self.run_list(
|
||||
node["then"],
|
||||
depth=depth + 1,
|
||||
branch="then",
|
||||
parent=index,
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
)
|
||||
return stage
|
||||
|
||||
def _effective_stop(self, node: dict[str, Any]) -> bool:
|
||||
@@ -349,13 +373,22 @@ async def run_argv_batch(
|
||||
}
|
||||
|
||||
``steps`` is a list of nodes, and ``then`` / ``else`` / ``pipe`` each take one
|
||||
node or a list of them, so the tree nests. A failure with no ``else`` stops
|
||||
the batch, unless that node sets ``stop_on_error: false``: it then skips its
|
||||
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.
|
||||
node or a list of them, so the tree nests. A continuation runs where its
|
||||
parent ran: it inherits the parent's ``cwd`` and ``env`` (its own win, and
|
||||
the batch's are the outermost default). A ``pipe`` chain runs to its last
|
||||
command whatever the earlier ones did, and only that last exit code decides
|
||||
the branch; a pipe step takes no ``then`` / ``else`` — branch on the node
|
||||
that owns the pipe. A failure with no ``else`` stops the batch, unless that
|
||||
node sets ``stop_on_error: false``: it then skips its own continuations and
|
||||
the next sibling runs. A failure inside a continuation is nobody's to
|
||||
answer, so it stops the batch the same way.
|
||||
|
||||
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), in
|
||||
a ``--- step N ---`` block that also says how it got there: ``depth=`` when
|
||||
nested, ``via=then of step N`` (or ``else`` / ``pipe``) and ``pipe=true``
|
||||
when its stdout fed another command.
|
||||
"""
|
||||
try:
|
||||
nodes, count = _read_batch(steps)
|
||||
|
||||
@@ -306,6 +306,52 @@ async def test_run_argv_batch_names_the_node_grammar(workspace: object) -> None:
|
||||
assert "then, else, pipe" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_a_continuation_runs_where_its_parent_ran(workspace: Path) -> None:
|
||||
"""``then`` inherits the parent's ``cwd`` and ``env`` unless it names its own."""
|
||||
(workspace / "sub").mkdir()
|
||||
(workspace / "own").mkdir()
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import os; print('parent', os.path.basename(os.getcwd()), os.environ['FOO'])"),
|
||||
"cwd": "sub",
|
||||
"env": {"FOO": "inherited"},
|
||||
"then": [
|
||||
{"argv": _py("import os; print('then', os.path.basename(os.getcwd()), os.environ['FOO'])")},
|
||||
{
|
||||
"argv": _py("import os; print('own', os.path.basename(os.getcwd()), os.environ['FOO'])"),
|
||||
"cwd": "own",
|
||||
"env": {"FOO": "own"},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "parent sub inherited" in out
|
||||
assert "then sub inherited" in out
|
||||
assert "own own own" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_a_pipe_runs_to_its_end(workspace: object) -> None:
|
||||
"""A failing pipe stage does not skip the rest; the last command decides."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import sys; sys.exit(3)"),
|
||||
"pipe": {"argv": _py("import sys; print('second-stage-ran'); sys.exit(0)")},
|
||||
"else": {"argv": _py("print('branch-on-pipe-end')")},
|
||||
},
|
||||
{"argv": _py("print('batch-continues')")},
|
||||
],
|
||||
)
|
||||
assert "second-stage-ran" in out
|
||||
assert "branch-on-pipe-end" not in out # the chain's last exit code was 0
|
||||
assert "batch-continues" in out
|
||||
|
||||
|
||||
def _asking_registry(*definitions: ToolDefinition) -> tuple[ToolRegistry, list[str]]:
|
||||
"""A registry whose confirm hook records the calls it was asked about."""
|
||||
asked: list[str] = []
|
||||
|
||||
Reference in New Issue
Block a user