From 8de89301bddacd8e8ce0cd5bdbee7085f8239441 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 7 Sep 2026 01:04:33 +0800 Subject: [PATCH 1/2] fix(subagents): preserve Pi tools and provider lifecycle in child sessions --- README.md | 24 +- SETUP.md | 4 +- docs/research/README.md | 2 + .../WORKFLOW_CHILD_FAILURES_2026-09-07.md | 59 ++ extensions/ai-providers/README.md | 18 +- .../ai-providers/cursor/input-images.ts | 5 +- extensions/ai-providers/cursor/proto.ts | 229 +++++++- extensions/ai-providers/cursor/protobuf.ts | 14 +- extensions/ai-providers/cursor/provider.ts | 296 +++++++++- extensions/ai-providers/cursor/tool-bridge.ts | 68 +++ extensions/ai-providers/index.ts | 6 +- extensions/shared/child-session.ts | 14 + extensions/subagents/index.ts | 7 +- extensions/subagents/src/agent-types.ts | 22 +- extensions/subagents/src/manager.ts | 73 +-- extensions/subagents/src/prompt.ts | 4 +- extensions/workflows/index.ts | 71 ++- extensions/workflows/runner.ts | 167 +----- extensions/workflows/sandbox.ts | 4 + skills/subagents/REFERENCE.md | 13 +- skills/subagents/SKILL.md | 2 +- skills/workflows/REFERENCE.md | 4 +- tests/extensions/ai-providers/cursor.test.ts | 527 ++++++++++++++++++ tests/extensions/shared/child-session.test.ts | 21 + .../extensions/subagents/agent-types.test.ts | 60 +- tests/extensions/subagents/manager.test.ts | 125 +---- .../subagents/pi-backend-lifecycle.test.ts | 128 ++--- tests/extensions/subagents/prompt.test.ts | 6 + .../extensions/workflows/execute.e2e.test.ts | 120 +++- tests/extensions/workflows/runner.test.ts | 240 +++----- tests/extensions/workflows/sandbox.test.ts | 14 + tests/support/subagents-stub.ts | 2 +- 32 files changed, 1586 insertions(+), 763 deletions(-) create mode 100644 docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md create mode 100644 extensions/ai-providers/cursor/tool-bridge.ts diff --git a/README.md b/README.md index 16c6b9e6..9ed7732b 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ OpenPI 把成熟 Coding Agent 的工作习惯做成 Pi-native 能力,但不复 | 快捷工作流 | `/btw` 旁路提问(TUI)、`/lg` 浏览 Diff(TUI)、`/pr` 查 PR、`/copy-all`、`fd`、`rg`、只读 Git 工具 | | 人类决策 | `ask_user` 草稿与最终复核、parent-only `human_handoff`、Plan Ready 实施门禁 | | 统一配置 | `/openpi-setup` 管理 OpenPI 自有模型、并发、Footer、输出密度与 Post-edit 偏好 | -| 模型授权 | `/login google-antigravity`;实验性的 `/login cursor`(仅聊天,不执行 Cursor 原生工具) | +| 模型授权 | `/login google-antigravity`;实验性的 `/login cursor`(支持 Pi 工具,不执行 Cursor 原生工具) | OpenPI 采用 [MIT License](LICENSE);第三方来源与保留声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 @@ -196,23 +196,23 @@ subagent_spawn({ 每个 Subagent 都是新的进程内 Pi SDK Session: - 默认继承父会话的 Provider 与模型;用户可明确指定 Thinking Level,否则模型根据角色建议、任务难度与目标模型实际支持的档位选择; -- 继承普通 child-safe 工具、Skills、项目说明与 Trust 决策; +- 继承父会话当前启用且允许委派的工具、Skills 和项目说明;目标目录的项目扩展按其自身 Trust 决策加载; - 最多 4 个模型发起的 Subagent 并发运行,结束后自动回传; - 可 `check`、`wait`、`cancel`,也可用 `subagent_send` 继续同一子会话; - 输入框下方显示实时摘要,空输入时按 `↓` 聚焦,`Enter` 或 `→` 打开管理界面。 -内置角色由 Harness 强制工具边界,不靠 Prompt 自律: +内置角色提供任务分工建议,普通模式下继承父会话当前启用且允许委派的工具,包括 Bash 和已启用的联网工具。角色名称本身不是只读权限边界;Plan Mode 与自定义角色的显式工具限制仍由 Harness 执行。 -| `agent_type` | 适合 | 相对 effort 建议 | 强制能力 | -| ------------- | ---------------- | ------------------- | ----------------------------- | -| `explorer` | 代码追踪与探索 | 中等,难题可提高 | 只读发现工具 | -| `implementer` | 聚焦实现 | 中高,按范围与风险调整 | read / bash / edit / write 等 | -| `reviewer` | 正确性与回归审查 | 较高 | 只读发现工具 | -| `advisor` | 深度技术建议 | 较高 | 只读发现工具 | +| `agent_type` | 适合 | 相对 effort 建议 | +| ------------- | ---------------- | ---------------- | +| `explorer` | 代码追踪与探索 | 中等,难题可提高 | +| `implementer` | 聚焦实现 | 中高,按范围与风险调整 | +| `reviewer` | 正确性与回归审查 | 较高 | +| `advisor` | 深度技术建议 | 较高 | 上述只是模型的相对选择提示,不会为内置角色写死具体档位。用户明确指定的 `reasoning_effort` 始终优先;否则模型结合任务难度,从目标模型实际支持的档位中选择。 -角色可由全局 `~/.pi/agent/agents/*.md` 或受信任项目 `.pi/agents/*.md` 覆盖。模型优先级是:显式调用 > Agent Type 文件 > `/openpi-setup` 角色模型 > 父模型继承。更高优先级定义损坏时会阻断 fallback,而不是悄悄退回更宽松的能力。 +角色可由全局 `~/.pi/agent/agents/*.md` 或受信任项目 `.pi/agents/*.md` 覆盖。模型优先级是:显式调用 > Agent Type 文件 > `/openpi-setup` 角色模型 > 父模型继承。更高优先级定义损坏时会阻断 fallback,而不是悄悄退回更宽松的能力。自定义角色省略 `tools` 时继承当前父工具;显式列表只能收窄,未在父会话启用的工具不会由委派自动激活。已有角色文件不会被升级覆盖;旧版 `explorer.md` 的只读列表仍然有效。工具可用性不等于文件系统沙箱;普通模式可使用绝对路径访问其他目录,目标仓库的执行 cwd 应通过 `working_dir` 指定。
并行写文件时如何隔离 Worktree? @@ -275,7 +275,7 @@ return agent("Synthesize the verified findings", { | `pipeline()` | 每个 item 完成上阶段后立即进入下一阶段;多阶段 fan-out 的默认选择 | | `parallel()` | 并发 barrier;只在下一阶段确实需要全部结果时使用 | -Workflow 默认并发 8 个 Agent,单次最多 128 次调用;可配置到 64 和 1024。前台运行可实时查看,后台运行完成后自动回传;`/workflows` 展示阶段、Agent、Transcript、Graph、用量与产物。每个 Child Provider turn 必须在 45 秒内产生模型可见的 thinking、text、tool call 或完成事件,并在持续输出时按进展续期;空 stream start 与 transport heartbeat 不算进展。用户显式配置了更宽的 Pi `httpIdleTimeoutMs` 时沿用该上限。超时会 abort 当前 Child、保留已有 Transcript/usage/evidence,并让 sibling 与后续阶段继续结算。 +Workflow 默认并发 8 个 Agent,单次最多 128 次调用;可配置到 64 和 1024。前台运行可实时查看,后台运行完成后自动回传;`/workflows` 展示阶段、Agent、Transcript、Graph、用量与产物。普通子代理和 Workflow 都使用 Pi 原生传输超时与重试,不再用额外的 45 秒无可见输出计时器打断思考、排队或重试。显式取消和 Session 清理仍有界,原生 Provider 错误保留在 Child outcome 中。并发上限不代表账号的服务端速率额度;429 仍按 Pi 原生重试策略处理。 --- @@ -285,6 +285,8 @@ OpenPI 把一次调用拆成可以审计的生命周期,而不是把“进程 ### Result Handoff 与派生 Graph +`agent(prompt, { working_dir: "/path/to/repository" })` 显式选择子代理工作目录;相对路径以父会话 cwd 解析,并在模型调用前验证。目标目录影响 Git、资源加载、Trust、Worktree 和 Replay 身份;只在 Prompt 中写路径不会切换 cwd。普通继承工具的内置角色不使用只读 Replay;明确配置只读工具的自定义角色仍保留原有 Replay 仓库边界。 + 成功调用返回同一 Run 内有效的 opaque `ref`。后续调用通过 `inputs: [previous.ref]` 显式接收上游结论;每个结论最多 16 KiB,合计最多 48 KiB,并标记为不可信数据。Artifacts 从这些引用派生只读 Graph,用来观察 lineage,不参与调度。 ### Invocation Ledger diff --git a/SETUP.md b/SETUP.md index 9fe26edb..c470aee5 100644 --- a/SETUP.md +++ b/SETUP.md @@ -16,7 +16,7 @@ Pi installs the package dependencies automatically. Restart Pi or run `/reload` ## fd, rg, and read-only git tools -The `file-search` extension registers `fd` and `rg` as model tools, and `git-read` registers `git_show`, `git_diff`, and `git_log` (read-only git inspection). They stay outside an ordinary parent turn until the user explicitly asks to use `fd`/`rg`/git history, or structured file search, or the model loads the `search` group through `openpi_load_tools`. Entering or restoring Plan Mode is a runtime-safety exception: it loads `search` for that Session so diff investigation can use the structured Git boundary. The gateway is shown after an explicit OpenPI-capability request, or remains visible when the user opts into adaptive discovery; child sessions may still receive these tools through the reviewed child-safe allowlist (the read-only git tools let reviewer/advisor subagents inspect diffs, which a bash-free tool boundary otherwise excludes). No setup is normally needed: at startup `fd`/`rg` silently use a system-installed binary (`fd`/`fdfind` and `rg`) when available, or an existing binary in the agent's private managed bin directory (`~/.pi/agent/bin`). Only when neither exists does it download an official release binary (macOS/Linux, arm64/x64, over HTTPS) into that directory — a persistent cache that survives package updates — and show a one-time notification. If your platform is unsupported, install `fd` and `rg` with your package manager and restart Pi. The git tools require a system `git`. +The `file-search` extension registers `fd` and `rg` as model tools, and `git-read` registers `git_show`, `git_diff`, and `git_log` (read-only git inspection). They stay outside an ordinary parent turn until the user explicitly asks to use `fd`/`rg`/git history, or structured file search, or the model loads the `search` group through `openpi_load_tools`. Entering or restoring Plan Mode is a runtime-safety exception: it loads `search` for that Session so diff investigation can use the structured Git boundary. The gateway is shown after an explicit OpenPI-capability request, or remains visible when the user opts into adaptive discovery; children receive these tools only when active in the parent and permitted by their role allowlist. No setup is normally needed: at startup `fd`/`rg` silently use a system-installed binary (`fd`/`fdfind` and `rg`) when available, or an existing binary in the agent's private managed bin directory (`~/.pi/agent/bin`). Only when neither exists does it download an official release binary (macOS/Linux, arm64/x64, over HTTPS) into that directory — a persistent cache that survives package updates — and show a one-time notification. If your platform is unsupported, install `fd` and `rg` with your package manager and restart Pi. The git tools require a system `git`. While Plan Mode is active, raw Bash `git diff`, `git show`, and `git whatchanged` are refused, as are diff-generating `git log` options such as `-p`, `--stat`, `--name-only`, and `-L`. Use `git_log` to find commits and `git_diff` / `git_show` to inspect changes; the latter commands always pass `--no-ext-diff --no-textconv --no-color`, so repository-configured `diff.external` and textconv drivers are not executed. This guarantee is scoped to the Git diff-driver boundary rather than every possible hostile Git configuration; the remaining allowlisted Git investigation commands still run inside Pi's existing project Trust boundary. @@ -60,7 +60,7 @@ Use the single canonical package-owned command. `/my-pi-setup` remains a compati /openpi-setup 清除 explorer 的模型,让它继承父模型 ``` -Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI tool surface until the user asks for a capability. The case-insensitive English words `subagent` and `workflow` are reserved authorization words: entering either word is sufficient to load its capability group, and the interactive editor shows it in Claude Code-style lavender before submission, with a darker purple fallback for light themes. This makes discussion that contains either English word an intentional opt-in tradeoff; conditional and negated clauses remain inert, while Chinese capability names still require a recognized action request. `adaptive` is an explicit opt-in that keeps only `openpi_load_tools` visible and allows the model to load a useful group on its own; because this can start Subagents, Workflows, or background processes, normal permission and configured concurrency/call limits still apply. Changing the setting updates the current Session immediately, while already loaded groups remain stable for that Session. Accepted Suggestion text is classified only after it becomes real editor input. The visual feedback and runtime activation share one fail-closed intent classifier. Next-action suggestions default to off. Run `/openpi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line plain layout (`model context |flex| git pr cwd`; `thinking`, `cache`, `cost`, and `throughput` remain opt-in metrics). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font affects powerline separator glyphs (``) and adds Codex-style outline icons to compact read, terminal, edit, search, and directory activity rows; all accompanying text remains readable without it. Footer metrics use one Codicon outline family (`` model, `` context, and `` directory) plus `⎇` for the branch. A Nerd Font containing Codicons renders them as designed; without one, the text labels remain readable even if an icon falls back to an empty box. Footer changes apply immediately in the active TUI session. Subagent results default to a compact status summary; full mode remains available as a per-user setting, and `app.tools.expand` (`Ctrl+O` by default) reveals the available child report. Ordinary `read`, `grep`, `find`, and `ls` operations render as one-line semantic activity summaries. Bash and Write/Edit default to the same activity-row projection, showing the target, running/success/failure state, and useful line or diff counts without replaying stdout or previews. Select full independently for Bash or Write/Edit to keep Pi's native rendering expanded. `app.tools.expand` temporarily restores the native arguments, output, errors, diff, timing, images, truncation notices, and full-output metadata; it never changes Session history or model context. An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model by default. `/openpi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/.md` overrides global `~/.pi/agent/agents/.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`. +Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI tool surface until the user asks for a capability. The case-insensitive English words `subagent` and `workflow` are reserved authorization words: entering either word is sufficient to load its capability group, and the interactive editor shows it in Claude Code-style lavender before submission, with a darker purple fallback for light themes. This makes discussion that contains either English word an intentional opt-in tradeoff; conditional and negated clauses remain inert, while Chinese capability names still require a recognized action request. `adaptive` is an explicit opt-in that keeps only `openpi_load_tools` visible and allows the model to load a useful group on its own; because this can start Subagents, Workflows, or background processes, normal permission and configured concurrency/call limits still apply. Changing the setting updates the current Session immediately, while already loaded groups remain stable for that Session. Accepted Suggestion text is classified only after it becomes real editor input. The visual feedback and runtime activation share one fail-closed intent classifier. Next-action suggestions default to off. Run `/openpi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line plain layout (`model context |flex| git pr cwd`; `thinking`, `cache`, `cost`, and `throughput` remain opt-in metrics). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font affects powerline separator glyphs (``) and adds Codex-style outline icons to compact read, terminal, edit, search, and directory activity rows; all accompanying text remains readable without it. Footer metrics use one Codicon outline family (`` model, `` context, and `` directory) plus `⎇` for the branch. A Nerd Font containing Codicons renders them as designed; without one, the text labels remain readable even if an icon falls back to an empty box. Footer changes apply immediately in the active TUI session. Subagent results default to a compact status summary; full mode remains available as a per-user setting, and `app.tools.expand` (`Ctrl+O` by default) reveals the available child report. Ordinary `read`, `grep`, `find`, and `ls` operations render as one-line semantic activity summaries. Bash and Write/Edit default to the same activity-row projection, showing the target, running/success/failure state, and useful line or diff counts without replaying stdout or previews. Select full independently for Bash or Write/Edit to keep Pi's native rendering expanded. `app.tools.expand` temporarily restores the native arguments, output, errors, diff, timing, images, truncation notices, and full-output metadata; it never changes Session history or model context. An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model and currently active child-eligible tools by default. Explicit custom role `tools` lists narrow that surface; omitted lists inherit it. Built-in investigator roles suggest read-only work but do not impose a read-only tool boundary outside Plan Mode. Existing role files are preserved, so an old global `explorer.md` allowlist continues to exclude Bash/network tools until deliberately edited. `/openpi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/.md` overrides global `~/.pi/agent/agents/.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`. OpenPI Web theme defaults to `system`; `light` and `dark` are explicit canonical setup choices, and the browser consumes them from each authoritative snapshot without writing a competing local preference. diff --git a/docs/research/README.md b/docs/research/README.md index 07707793..6840d3d6 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -4,6 +4,8 @@ Research records preserve sourced investigation and distinguish observations, in ## Validated investigations +- [`WORKFLOW_CHILD_FAILURES_2026-09-07.md`](WORKFLOW_CHILD_FAILURES_2026-09-07.md) — child tool transport, cwd and timeout failure mechanisms, intended capability inheritance, and acceptance limits ([#424](https://github.com/openpi-dev/openpi/issues/424)). + - [`WORKFLOW_DASHBOARD_REFRESH_2026-09-07.md`](WORKFLOW_DASHBOARD_REFRESH_2026-09-07.md) — repeated synchronous history loading on dashboard animation ticks, its regression boundary, and measurement limits ([#420](https://github.com/openpi-dev/openpi/issues/420)). ## Legacy records diff --git a/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md b/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md new file mode 100644 index 00000000..bbc92e29 --- /dev/null +++ b/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md @@ -0,0 +1,59 @@ +# Workflow child failures and capability inheritance + +- Status: validated for private run inspection and local regression reproduction; live provider acceptance tracked separately below +- Created / verified: 2026-09-07 +- Observed runtime: `c8f2c13d49f2e6cd3b389dfff72ccc2eaca970c1`, the unique OpenPI source returned by `pi list` +- Repair base: `a7455cd378ef7befa9c7cf099c1fa1e4ee5dc3e3` +- Issue: [#424](https://github.com/openpi-dev/openpi/issues/424) +- Related performance repair: [#420](https://github.com/openpi-dev/openpi/issues/420), [PR #422](https://github.com/openpi-dev/openpi/pull/422) +- Supersedes: none + +## Observed failures + +Three private Workflow runs used eight audit shards. Their parent Session cwd was the home directory, while the prompts named a different Git checkout. + +| Provider / model | Observed evidence | +| --- | --- | +| Cursor / cursor-grok-4.6-high-fast | Eight children rejected `partialToolCall` in chat-only mode; zero tool results; 18.6 seconds total | +| openai-codex / gpt-5.6-luna | Eight children ended with OpenPI's model-progress timeout; 141 tool results, 53 marked errors; 625.9 seconds total | +| seal / gpt-5.6-sol | Three HTTP 429 account-rate-limit errors, five aborted children; 140 tool results, 31 marked errors; 41 seconds total | + +The abort initiator for the last five children is unknown. The first two scripts returned application-level `ok:false`; runtime `completed` means the scripts returned, not that the audit succeeded. The Codex children ran tools before timing out, so this was not solely a startup failure. No evidence establishes that eight concurrent children always fail or identifies the account's exact rate ceiling. + +Private Session transcripts, credentials, role files, and raw Workflow records remain outside Git. This is a diagnostic record, not a public Benchmark or an archive of those private records. + +## Reproduced mechanisms + +1. The original Cursor adapter deliberately disabled tool transport and rejected tool-call events. Changing role assignments to a listed Cursor model could pass model resolution but still fail every tool-using child. +2. The user's custom `explorer` role listed only filesystem discovery and structured read-only Git tools. It had no Bash or network tool even though the task required live GitHub inspection. A model fallback does not change an explicit role tool list. +3. Git tools use execution cwd. The actual `runGit` implementation failed from home with “not a git repository” and succeeded from the repository. A path in natural-language instructions did not change Workflow cwd. +4. Empty Pi settings resolved native HTTP idle timeout to 300000 ms, but OpenPI imposed a separate 45000 ms model-visible-progress timeout. The two clocks measure different events: transport activity and retries can be silent in model output. A deterministic runner test advancing 46000 ms failed before the repair because abort was invoked, then completed successfully after removal of the additional clock. +5. Dashboard animation previously synchronously hydrated retained history every 120 ms. That independent event-loop problem was repaired in PR #422. Its contribution to each historical child timeout was not measured. + +## Repair contract + +Pi remains the provider/tool/lifecycle owner. Cursor advertises only Pi's supplied tool definitions through namespaced MCP descriptors. Matching Cursor calls become ordinary Pi tool calls; Pi executes them with its normal hooks and returns results through the next provider request. Cursor-native shell, edit, web, task, and unknown tool identities are not a second execution path. Approval probes do not execute tools. + +Built-in roles inherit the parent's currently active child-eligible tool surface. An explicit custom role allowlist intersects it, and parent-only exclusions remain enforced. Active-tool visibility is not a filesystem sandbox or permission ledger. The read-only wording of an investigator role is task guidance in normal mode; Plan Mode remains a runtime restriction. Existing custom role files are preserved, so old explicit explorer lists remain restricted until the user deliberately edits them. Merely updating OpenPI will not remove a user's custom restriction. + +Workflow `working_dir` resolves relative to the parent cwd and is validated before model invocation. Target-project resources use the target's trust decision; access to another directory does not imply trust in its extensions. Cwd and tool projection participate in operator and replay identity, and worktree operations use the selected repository. Inherited built-in calls run for real on resume; explicit bounded read-only custom calls retain the existing replay filesystem boundary. + +Ordinary and Workflow children rely on Pi's native transport timeout/retry behavior. Quiet thinking, tool work, and retry backoff are not terminated by an extra 45-second output clock. Explicit cancellation and session disposal deadlines remain bounded. A 429 may still fail after native retries; concurrency configuration is a ceiling, not a provider account capacity claim. + +## Validation and remaining limits + +The local targeted timeout/lifecycle suite covers quiet turns beyond the former deadline, native provider error preservation, explicit cancellation, late completion, and bounded disposal. Capability tests cover parent intersection, missing eligible tools, selected cwd, target trust, invalid cwd before model invocation, replay behavior, and alternate-repository worktree cleanup. + +Cursor transport fixtures use local HTTP/2 and exercise Pi tool execution and result resumption. They cannot alone prove the live Cursor service accepts the protocol. `bun run check` passed. The complete repository-discovered suite passed with Node file concurrency 2 (1435 passed, one platform skip) and Vitest (30/30). A default-concurrency run hit the existing Setup subprocess 8-second startup deadline under host load; that unrelated test was not modified. Two independent diff reviews reported no actionable findings. Live smoke receipts follow below. Merged source, an installed release, and the user's currently running Pi Session remain separate acceptance boundaries. + +### Live Cursor smoke receipt + +On 2026-09-07, an isolated Pi agent directory reported this repair worktree as its single OpenPI source. The user's configured runtime remained `c8f2c13`; no role file or user package setting was changed. The smoke used the existing authenticated Cursor account, with credentials kept private, and `cursor-grok-4.6-high-fast`. + +| Scope | Result | Elapsed | +| --- | --- | --- | +| Native Pi Agent, one in-memory nonce tool | One tool execution, two provider requests, exact returned nonce | 9.4 s | +| Real Workflow `runAgent`, `read` plus `bash` | Read an absolute file outside cwd; Bash returned selected cwd and fetched Example Domain over HTTPS | 27.3 s | +| Eight concurrent real Workflow `runAgent` sessions | 8/8 successful; each executed one read, completed two model turns, and returned the correct external-file nonce | 12.0 s total | + +These are bounded connectivity/lifecycle acceptance runs, not model-quality or throughput Benchmarks. The eight-child smoke invoked the production runner concurrently; selected-cwd DSL propagation is separately covered by execute-level tests. It does not prove long 44-PR audits fit provider rate limits, automatic choice of the right tool, every Cursor model, or the user's older custom role configuration. Cursor does not report complete usage, so zero provider counters are unknown accounting rather than a claim of free execution. diff --git a/extensions/ai-providers/README.md b/extensions/ai-providers/README.md index 3a479818..3a8487ec 100644 --- a/extensions/ai-providers/README.md +++ b/extensions/ai-providers/README.md @@ -4,10 +4,12 @@ This extension registers two opt-in model providers backed by account OAuth: - `google-antigravity` uses Google Cloud Code Assist and supports ordinary Pi tool calls. -- `cursor` uses Cursor AgentService and is experimental, chat-only support. - It does not advertise or execute Cursor-native coding tools. If the server - requests one, the request fails explicitly instead of bypassing Pi's tool and - permission lifecycle. +- `cursor` uses Cursor AgentService with an experimental Pi tool bridge. + The active Pi tools are advertised as MCP tools. A complete invocation returns + to Pi for permission checks and execution; the next provider turn replays the + paired result. Cursor-native shell, editing, web, task, and interaction tools + remain unsupported and fail explicitly. The provider never executes tools + inside its transport or widens a child session's effective tool set. After installing OpenPI, restart Pi or run `/reload`, then authenticate and select a model: @@ -43,8 +45,12 @@ When Cursor is selected, a supported PNG/JPEG/GIF/WebP path at the start of an interactive prompt is converted into an actual image attachment (up to 10 MiB) before the request is sent. The absolute path is not exposed to the model. -The provider also adds an explicit chat-only rule so the normal Pi coding -system prompt cannot cause Cursor to attempt unavailable read or shell tools. +When Pi supplies tools, the provider directs Cursor to the advertised Pi MCP +catalog. Without tools it uses an explicit chat-only rule. Partial tool previews +and approval-only probes never execute a tool; unknown or malformed invocations +fail explicitly. Local HTTP/2 tests exercise a normal Pi tool lifecycle and +result replay, but account/model-specific compatibility still requires a live +smoke test. Cursor's native execution protocol is intentionally not enabled. Cursor's token delta describes generated output only, so the provider does not publish it as complete context usage. Pi 0.84.3+ can estimate an all-Cursor diff --git a/extensions/ai-providers/cursor/input-images.ts b/extensions/ai-providers/cursor/input-images.ts index d67d7dbc..1faaa159 100644 --- a/extensions/ai-providers/cursor/input-images.ts +++ b/extensions/ai-providers/cursor/input-images.ts @@ -1,11 +1,11 @@ import { readFile, stat } from "node:fs/promises"; import { basename, isAbsolute } from "node:path"; +import type { ImageContent } from "@earendil-works/pi-ai/compat"; import type { ExtensionContext, InputEvent, InputEventResult, } from "@earendil-works/pi-coding-agent"; -import type { ImageContent } from "@earendil-works/pi-ai/compat"; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; @@ -58,8 +58,7 @@ function detectImageMimeType( /** * Pi's TUI represents a clipboard image as a leading local path. Cursor's - * chat-only provider cannot ask a native read-file tool to resolve that path, - * so convert an explicit leading image path into the same ImageContent shape + * provider does not execute Cursor-native read-file tools, so convert an explicit leading image path into the same ImageContent shape * used by CLI/RPC attachments before the agent turn starts. */ export async function transformCursorImageInput( diff --git a/extensions/ai-providers/cursor/proto.ts b/extensions/ai-providers/cursor/proto.ts index a4e71b74..c1188889 100644 --- a/extensions/ai-providers/cursor/proto.ts +++ b/extensions/ai-providers/cursor/proto.ts @@ -3,8 +3,8 @@ * * The field numbers and message names are vendored from * oh-my-pi@eab72e88e4, packages/catalog/src/discovery/cursor-proto.ts - * (MIT). Only the chat, image, model-discovery, and exec-rejection messages - * used by this chat-only provider are retained. Unknown fields are skipped by + * (MIT). Only the chat, image, model-discovery, and Pi MCP bridge messages + * used by this provider are retained. Unknown fields are skipped by * the local protobuf codec so newer Cursor messages remain forward-compatible. */ @@ -297,7 +297,8 @@ export interface ConversationStep extends ProtoMessage { message: | { case: undefined; value?: undefined } | { case: "assistantMessage"; value: AssistantMessage } - | { case: "thinkingMessage"; value: ThinkingMessage }; + | { case: "thinkingMessage"; value: ThinkingMessage } + | { case: "toolCall"; value: CursorToolCall }; } export const ConversationStepSchema: MessageCodec = @@ -306,6 +307,12 @@ export const ConversationStepSchema: MessageCodec = kind: "oneof", name: "message", variants: [ + { + no: 2, + name: "toolCall", + kind: "message", + T: () => CursorToolCallSchema, + }, { no: 1, name: "assistantMessage", @@ -556,11 +563,77 @@ export const CursorRuleSchema: MessageCodec = pb( ], ); -/** Empty definitions deliberately make the request-context tool list empty. */ -export interface McpToolDefinition extends ProtoMessage {} +export interface McpToolDefinition extends ProtoMessage { + name: string; + providerIdentifier: string; + toolName: string; + description: string; + inputSchema: Uint8Array; + inputSchemaJson?: string; +} export const McpToolDefinitionSchema: MessageCodec = - pb("agent.v1.McpToolDefinition", []); + pb("agent.v1.McpToolDefinition", [ + { no: 1, name: "name", kind: "string" }, + { no: 4, name: "providerIdentifier", kind: "string" }, + { no: 5, name: "toolName", kind: "string" }, + { no: 2, name: "description", kind: "string" }, + { no: 3, name: "inputSchema", kind: "bytes" }, + { no: 6, name: "inputSchemaJson", kind: "string", optional: true }, + ]); + +export interface McpArgs extends ProtoMessage { + name: string; + args: Record; + toolCallId: string; + providerIdentifier: string; + toolName: string; + smartModeApprovalOnly: boolean; + skipApproval: boolean; + serverIdentifier: string; +} + +export const McpArgsSchema: MessageCodec = pb( + "agent.v1.McpArgs", + [ + { no: 1, name: "name", kind: "string" }, + { no: 2, name: "args", kind: "map", K: "string", V: "bytes" }, + { no: 3, name: "toolCallId", kind: "string" }, + { no: 4, name: "providerIdentifier", kind: "string" }, + { no: 5, name: "toolName", kind: "string" }, + { no: 7, name: "smartModeApprovalOnly", kind: "bool" }, + { no: 8, name: "skipApproval", kind: "bool" }, + { no: 9, name: "serverIdentifier", kind: "string" }, + ], +); + +export interface McpRejected extends ProtoMessage { + reason: string; + isReadonly: boolean; +} + +export const McpRejectedSchema: MessageCodec = pb( + "agent.v1.McpRejected", + [ + { no: 1, name: "reason", kind: "string" }, + { no: 2, name: "isReadonly", kind: "bool" }, + ], +); + +export interface McpToolCall extends ProtoMessage { + result?: McpToolResult; + args?: McpArgs; + description?: string; +} + +export const McpToolCallSchema: MessageCodec = pb( + "agent.v1.McpToolCall", + [ + { no: 2, name: "result", kind: "message", T: () => McpToolResultSchema }, + { no: 1, name: "args", kind: "message", T: () => McpArgsSchema }, + { no: 3, name: "description", kind: "string", optional: true }, + ], +); export interface RequestContext extends ProtoMessage { rules: CursorRule[]; @@ -677,7 +750,8 @@ export interface ExecClientMessage extends ProtoMessage { execId: string; message: | { case: undefined; value?: undefined } - | { case: "requestContextResult"; value: RequestContextResult }; + | { case: "requestContextResult"; value: RequestContextResult } + | { case: "mcpResult"; value: McpResult }; } export const ExecClientMessageSchema: MessageCodec = @@ -688,6 +762,12 @@ export const ExecClientMessageSchema: MessageCodec = kind: "oneof", name: "message", variants: [ + { + no: 11, + name: "mcpResult", + kind: "message", + T: () => McpResultSchema, + }, { no: 10, name: "requestContextResult", @@ -756,7 +836,8 @@ export interface ExecServerMessage extends ProtoMessage { execId: string; message: | { case: undefined; value?: undefined } - | { case: "requestContextArgs"; value: RequestContextArgs }; + | { case: "requestContextArgs"; value: RequestContextArgs } + | { case: "mcpArgs"; value: McpArgs }; } export const ExecServerMessageSchema: MessageCodec = @@ -767,6 +848,7 @@ export const ExecServerMessageSchema: MessageCodec = kind: "oneof", name: "message", variants: [ + { no: 11, name: "mcpArgs", kind: "message", T: () => McpArgsSchema }, { no: 10, name: "requestContextArgs", @@ -940,7 +1022,7 @@ export const InteractionUpdateSchema: MessageCodec = no: 15, name: "toolCallDelta", kind: "message", - T: () => ToolInteractionUpdateSchema, + T: () => InteractionQueryPayloadSchema, }, { no: 2, @@ -988,10 +1070,57 @@ export const InteractionUpdateSchema: MessageCodec = }, ]); -export interface ToolInteractionUpdate extends ProtoMessage {} +export interface ToolInteractionUpdate extends ProtoMessage { + callId?: string; + toolCall?: CursorToolCall; + argsTextDelta?: string; +} export const ToolInteractionUpdateSchema: MessageCodec = - pb("agent.v1.ToolInteractionUpdate", []); + pb("agent.v1.ToolInteractionUpdate", [ + { no: 1, name: "callId", kind: "string" }, + { no: 2, name: "toolCall", kind: "message", T: () => CursorToolCallSchema }, + { no: 3, name: "argsTextDelta", kind: "string" }, + ]); + +// Only the MCP branch is supported. Unknown native tool variants remain unknown +// and are rejected by the provider rather than executed outside Pi. +export interface CursorToolCall extends ProtoMessage { + toolCallId?: string; + tool: + | { case: undefined; value?: undefined } + | { case: "mcpToolCall"; value: McpToolCall }; +} +export const CursorToolCallSchema = pb("agent.v1.ToolCall", [ + { no: 57, name: "toolCallId", kind: "string", optional: true }, + { + kind: "oneof", + name: "tool", + variants: [ + { + no: 15, + name: "mcpToolCall", + kind: "message", + T: () => McpToolCallSchema, + }, + ], + }, +]); + +export interface McpResult extends ProtoMessage { + result: + | { case: undefined; value?: undefined } + | { case: "rejected"; value: McpRejected }; +} +export const McpResultSchema = pb("agent.v1.McpResult", [ + { + kind: "oneof", + name: "result", + variants: [ + { no: 3, name: "rejected", kind: "message", T: () => McpRejectedSchema }, + ], + }, +]); export interface TextDeltaUpdate extends ProtoMessage { text: string; @@ -1062,3 +1191,81 @@ export const GetUsableModelsResponseSchema: MessageCodec = pb( + "agent.v1.McpSuccess", + [ + { + no: 1, + name: "content", + kind: "message", + T: () => McpToolResultContentItemSchema, + repeat: true, + }, + { no: 2, name: "isError", kind: "bool" }, + ], +); + +export interface McpTextContent extends ProtoMessage { + text: string; +} + +export const McpTextContentSchema: MessageCodec = + pb("agent.v1.McpTextContent", [ + { no: 1, name: "text", kind: "string" }, + ]); + +export interface McpImageContent extends ProtoMessage { + data: Uint8Array; + mimeType: string; +} + +export const McpImageContentSchema: MessageCodec = + pb("agent.v1.McpImageContent", [ + { no: 1, name: "data", kind: "bytes" }, + { no: 2, name: "mimeType", kind: "string" }, + ]); + +export interface McpToolResultContentItem extends ProtoMessage { + content: + | { case: undefined; value?: undefined } + | { case: "text"; value: McpTextContent } + | { case: "image"; value: McpImageContent }; +} + +export const McpToolResultContentItemSchema: MessageCodec = + pb("agent.v1.McpToolResultContentItem", [ + { + kind: "oneof", + name: "content", + variants: [ + { no: 1, name: "text", kind: "message", T: () => McpTextContentSchema }, + { + no: 2, + name: "image", + kind: "message", + T: () => McpImageContentSchema, + }, + ], + }, + ]); + +export interface McpToolResult extends ProtoMessage { + result: + | { case: undefined; value?: undefined } + | { case: "success"; value: McpSuccess }; +} +export const McpToolResultSchema = pb("agent.v1.McpToolResult", [ + { + kind: "oneof", + name: "result", + variants: [ + { no: 1, name: "success", kind: "message", T: () => McpSuccessSchema }, + ], + }, +]); diff --git a/extensions/ai-providers/cursor/protobuf.ts b/extensions/ai-providers/cursor/protobuf.ts index 49b8330c..7c04ae79 100644 --- a/extensions/ai-providers/cursor/protobuf.ts +++ b/extensions/ai-providers/cursor/protobuf.ts @@ -472,7 +472,12 @@ function compileMapField(desc: MapFieldDesc): CompiledField { } } - target[entryKey] = entryValue; + Object.defineProperty(target, entryKey, { + value: entryValue, + enumerable: true, + configurable: true, + writable: true, + }); }, toJson(message, output) { const input = Reflect.get(message, name); @@ -1143,7 +1148,12 @@ function readJsonStruct(reader: Reader): { [key: string]: JsonValue } { entryReader.skip(entryWire); } } - output[entryKey] = entryVal; + Object.defineProperty(output, entryKey, { + value: entryVal, + enumerable: true, + configurable: true, + writable: true, + }); } else { reader.skip(wireType); } diff --git a/extensions/ai-providers/cursor/provider.ts b/extensions/ai-providers/cursor/provider.ts index 69163ae6..d77fc214 100644 --- a/extensions/ai-providers/cursor/provider.ts +++ b/extensions/ai-providers/cursor/provider.ts @@ -10,6 +10,7 @@ import type { Model, SimpleStreamOptions, TextContent, + ToolCall, } from "@earendil-works/pi-ai/compat"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat"; import { emptyUsage } from "../usage.ts"; @@ -35,6 +36,7 @@ import { CursorRuleSchema, CursorRuleTypeGlobalSchema, CursorRuleTypeSchema, + CursorToolCallSchema, ExecClientControlMessageSchema, ExecClientMessageSchema, ExecClientStreamCloseSchema, @@ -44,6 +46,15 @@ import { KvClientMessageSchema, type KvServerMessage, KvServerMessageSchema, + McpArgsSchema, + McpImageContentSchema, + McpRejectedSchema, + McpResultSchema, + McpSuccessSchema, + McpTextContentSchema, + McpToolCallSchema, + McpToolResultContentItemSchema, + McpToolResultSchema, type ModelDetails, ModelDetailsSchema, RequestContextResultSchema, @@ -59,8 +70,14 @@ import { UserMessageActionSchema, UserMessageSchema, } from "./proto.ts"; -import { create, fromBinary, toBinary } from "./protobuf.ts"; +import { create, encodeJsonValue, fromBinary, toBinary } from "./protobuf.ts"; import { connectCursorHttp2 } from "./proxy.ts"; +import { + buildCursorTools, + CURSOR_PI_PROVIDER, + CURSOR_PI_TOOLS_SYSTEM_PROMPT, + decodeCursorTool, +} from "./tool-bridge.ts"; const CONNECT_END_STREAM_FLAG = 0b00000010; const CONNECT_COMPRESSED_FLAG = 0b00000001; @@ -199,22 +216,51 @@ function rootPromptContent( function assistantRootContent( message: Extract, + results: Map>, ) { const content: Array> = []; for (const item of message.content) { if (item.type === "text" && item.text) { content.push({ type: "text", text: item.text }); + } else if ( + item.type === "toolCall" && + results.get(item.id)?.toolName === item.name + ) { + content.push({ + type: "tool-call", + toolCallId: item.id, + toolName: item.name, + args: item.arguments, + }); } } return content; } +function pairedToolResults(messages: Message[], end: number) { + const calls = new Map(); + const results = new Map>(); + for (const message of messages.slice(0, end < 0 ? undefined : end)) { + if (message.role === "assistant") { + for (const part of message.content) + if (part.type === "toolCall") calls.set(part.id, part.name); + } else if ( + message.role === "toolResult" && + calls.get(message.toolCallId) === message.toolName + ) { + results.set(message.toolCallId, message); + } + } + return results; +} + function buildHistoryRootPrompt( messages: Message[], store: CursorBlobStore, activeUserIndex: number, ): Uint8Array[] { const entries: Uint8Array[] = []; + const results = pairedToolResults(messages, activeUserIndex); for (let index = 0; index < messages.length; index++) { if (index === activeUserIndex) break; const message = messages[index]; @@ -224,13 +270,29 @@ function buildHistoryRootPrompt( if (content.length === 0) continue; value = { role: "user", content }; } else if (message.role === "assistant") { - const content = assistantRootContent(message); + const content = assistantRootContent(message, results); if (content.length === 0) continue; value = { role: "assistant", content }; } else { - // Chat-only mode never replays assistant tool calls. Replaying only the - // matching tool result would create an invalid orphan in Cursor history. - continue; + if (results.get(message.toolCallId) !== message) continue; + value = { + role: "tool", + id: message.toolCallId, + content: [ + { + type: "tool-result", + toolCallId: message.toolCallId, + toolName: message.toolName, + result: message.content.some((part) => part.type === "image") + ? rootPromptContent(message.content) + : message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + ...(message.isError ? { isError: true } : {}), + }, + ], + }; } entries.push( storeBlob(store, new TextEncoder().encode(JSON.stringify(value))), @@ -242,13 +304,17 @@ function buildHistoryRootPrompt( function buildSystemPrompt( systemPrompt: Context["systemPrompt"], store: CursorBlobStore, + hasTools = false, ): Uint8Array[] { const prompts = systemPrompt ? Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt] : ["You are a helpful assistant."]; - return [...prompts, CURSOR_CHAT_ONLY_SYSTEM_PROMPT].map((prompt) => + return [ + ...prompts, + hasTools ? CURSOR_PI_TOOLS_SYSTEM_PROMPT : CURSOR_CHAT_ONLY_SYSTEM_PROMPT, + ].map((prompt) => storeBlob( store, new TextEncoder().encode( @@ -260,11 +326,12 @@ function buildSystemPrompt( /** * Cursor asks for these rules over the exec channel before generating text. - * They are global rules only; the chat-only provider intentionally returns an - * empty MCP tool list and never forwards `context.tools`. + * These rules keep Cursor-native tools disabled. The request-context response + * advertises only the active Pi tools through the MCP protocol bridge. */ export function buildCursorRequestContextRules( systemPrompt: Context["systemPrompt"], + hasTools = false, ): CursorRule[] { const rules: CursorRule[] = systemPrompt?.trim() ? [ @@ -283,8 +350,10 @@ export function buildCursorRequestContextRules( : []; rules.push( create(CursorRuleSchema, { - fullPath: "/pi/cursor-chat-only.mdc", - content: CURSOR_CHAT_ONLY_SYSTEM_PROMPT, + fullPath: hasTools ? "/pi/cursor-tools.mdc" : "/pi/cursor-chat-only.mdc", + content: hasTools + ? CURSOR_PI_TOOLS_SYSTEM_PROMPT + : CURSOR_CHAT_ONLY_SYSTEM_PROMPT, source: 2, type: create(CursorRuleTypeSchema, { type: { case: "global", value: create(CursorRuleTypeGlobalSchema, {}) }, @@ -300,6 +369,7 @@ function buildHistoryTurns( activeUserIndex: number, ): Uint8Array[] { const turns: Uint8Array[] = []; + const results = pairedToolResults(messages, activeUserIndex); const end = activeUserIndex >= 0 ? activeUserIndex : messages.length; let index = 0; while (index < end) { @@ -335,6 +405,68 @@ function buildHistoryTurns( ), ), ); + } else if (item.type === "toolCall") { + const result = results.get(item.id); + if (!result || result.toolName !== item.name) continue; + const args = Object.fromEntries( + Object.entries(item.arguments).map(([key, value]) => [ + key, + encodeJsonValue(JSON.parse(JSON.stringify(value))), + ]), + ); + const tool = create(CursorToolCallSchema, { + toolCallId: item.id, + tool: { + case: "mcpToolCall", + value: create(McpToolCallSchema, { + args: create(McpArgsSchema, { + name: item.name, + toolName: item.name, + providerIdentifier: CURSOR_PI_PROVIDER, + toolCallId: item.id, + args, + }), + result: create(McpToolResultSchema, { + result: { + case: "success", + value: create(McpSuccessSchema, { + isError: result.isError, + content: result.content.map((part) => + create(McpToolResultContentItemSchema, { + content: + part.type === "text" + ? { + case: "text", + value: create(McpTextContentSchema, { + text: part.text, + }), + } + : { + case: "image", + value: create(McpImageContentSchema, { + data: Buffer.from(part.data, "base64"), + mimeType: part.mimeType, + }), + }, + }), + ), + }), + }, + }), + }), + }, + }); + steps.push( + storeBlob( + store, + toBinary( + ConversationStepSchema, + create(ConversationStepSchema, { + message: { case: "toolCall", value: tool }, + }), + ), + ), + ); } } } @@ -414,11 +546,14 @@ export async function buildCursorRequest( options?: SimpleStreamOptions, ): Promise { const store: CursorBlobStore = new Map(); - const activeIndex = lastUserIndex(context.messages); + const activeIndex = + context.messages.at(-1)?.role === "user" + ? lastUserIndex(context.messages) + : -1; const active = activeIndex >= 0 ? context.messages[activeIndex] : undefined; const activeContent = active?.role === "user" ? active.content : undefined; const rootPromptMessagesJson = [ - ...buildSystemPrompt(context.systemPrompt, store), + ...buildSystemPrompt(context.systemPrompt, store, !!context.tools?.length), ...buildHistoryRootPrompt(context.messages, store, activeIndex), ]; const state = create(ConversationStateStructureSchema, { @@ -554,7 +689,7 @@ function isAbortError( ); } -/** Cursor AgentService/Run, deliberately chat-only (no context.tools advertisement or Pi tool calls). */ +/** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */ export function streamCursor( model: Model, context: Context, @@ -586,6 +721,8 @@ export function streamCursor( let turnEnded = false; let terminalError: Error | undefined; let finished = false; + const pendingCalls = new Map(); + let handingOffTools = false; const closeBlocks = () => { if (currentText) { @@ -698,6 +835,7 @@ export function streamCursor( }; let frameBuffer: Buffer = Buffer.alloc(0); const processFrame = (flags: number, bytes: Uint8Array) => { + if (handingOffTools) return; if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) { throw new Error("Compressed Cursor Connect frames are unsupported"); } @@ -715,8 +853,11 @@ export function streamCursor( case: "success", value: create(RequestContextSuccessSchema, { requestContext: create(RequestContextSchema, { - rules: buildCursorRequestContextRules(context.systemPrompt), - tools: [], + rules: buildCursorRequestContextRules( + context.systemPrompt, + !!context.tools?.length, + ), + tools: buildCursorTools(context.tools), }), }), }, @@ -734,6 +875,60 @@ export function streamCursor( ); return; } + if (exec.message.case === "mcpArgs") { + const args = exec.message.value; + if (args.smartModeApprovalOnly) { + // A probe must not become a tool call or preauthorize Pi execution. + const reply = create(AgentClientMessageSchema, { + message: { + case: "execClientMessage", + value: create(ExecClientMessageSchema, { + id: exec.id, + execId: exec.execId, + message: { + case: "mcpResult", + value: create(McpResultSchema, { + result: { + case: "rejected", + value: create(McpRejectedSchema, { + reason: + "Pi must evaluate permissions when executing the tool; approval-only probes cannot authorize execution.", + }), + }, + }), + }, + }), + }, + }); + h2Request?.write( + frameConnectMessage(toBinary(AgentClientMessageSchema, reply)), + ); + return; + } + const call = decodeCursorTool(args, context.tools); + if ( + context.messages.some((message) => + message.role === "toolResult" + ? message.toolCallId === call.id + : message.role === "assistant" && + message.content.some( + (part) => part.type === "toolCall" && part.id === call.id, + ), + ) + ) { + throw new Error( + "Cursor attempted to replay a tool call identity already present in Pi history", + ); + } + const previous = pendingCalls.get(call.id); + if (previous && JSON.stringify(previous) !== JSON.stringify(call)) { + throw new Error( + "Cursor repeated a tool call identity with different arguments", + ); + } + pendingCalls.set(call.id, call); + return; + } const throwReply = create(AgentClientMessageSchema, { message: { case: "execClientControlMessage", @@ -742,8 +937,9 @@ export function streamCursor( case: "throw", value: create(ExecClientThrowSchema, { id: exec.id, - error: - "Cursor tools are not available in this chat-only provider", + error: context.tools?.length + ? "Cursor-native execution is unavailable; use advertised Pi MCP tools" + : "Cursor tools are not available in this chat-only provider", errorCode: "UNIMPLEMENTED", }), }, @@ -762,7 +958,9 @@ export function streamCursor( }, }); const error = new Error( - "Cursor requested a tool that is unavailable in chat-only mode", + context.tools?.length + ? "Cursor requested unsupported native execution outside Pi" + : "Cursor requested a tool that is unavailable in chat-only mode", ); terminalError = error; if (!h2Request) { @@ -784,10 +982,32 @@ export function streamCursor( } if (message.message.case === "interactionQuery") { throw new Error( - `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable in chat-only mode`, + `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable ${context.tools?.length ? "outside Pi's interaction lifecycle" : "in chat-only mode"}`, ); } if (message.message.case !== "interactionUpdate") return; + const update = message.message.value; + if ( + context.tools?.length && + (update.message.case === "partialToolCall" || + update.message.case === "toolCallStarted" || + update.message.case === "toolCallCompleted") + ) { + const preview = update.message.value.toolCall; + if (preview && preview.tool.case !== "mcpToolCall") { + throw new Error( + "Cursor-native tools are unavailable; use the advertised Pi MCP tools", + ); + } + // Only exec mcpArgs is an invocation. UI previews may be partial, + // duplicated, or emitted for approval probes, and never execute. + return; + } + if (context.tools?.length && update.message.case === "toolCallDelta") { + throw new Error( + "Cursor-native tool deltas are unavailable; use the advertised Pi MCP tools", + ); + } processInteraction( message.message.value, output, @@ -830,6 +1050,37 @@ export function streamCursor( frameBuffer = frameBuffer.subarray(size + 5); processFrame(flags, data); } + if (pendingCalls.size > 0 && !handingOffTools) { + if (terminalError) throw terminalError; + if (options?.signal?.aborted) + throw new Error("Cursor request aborted"); + closeBlocks(); + for (const call of pendingCalls.values()) { + const contentIndex = output.content.length; + output.content.push(call); + stream.push({ + type: "toolcall_start", + contentIndex, + partial: output, + }); + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: JSON.stringify(call.arguments), + partial: output, + }); + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: call, + partial: output, + }); + } + output.stopReason = "toolUse"; + handingOffTools = true; + turnEnded = true; + settle(); + } }; h2Client = await connectCursorHttp2(baseUrl, { @@ -967,7 +1218,12 @@ export function streamCursor( output.usage.totalTokens = output.usage.input + output.usage.output; stream.push({ type: "done", - reason: output.stopReason === "length" ? "length" : "stop", + reason: + output.stopReason === "toolUse" + ? "toolUse" + : output.stopReason === "length" + ? "length" + : "stop", message: output, }); stream.end(); diff --git a/extensions/ai-providers/cursor/tool-bridge.ts b/extensions/ai-providers/cursor/tool-bridge.ts new file mode 100644 index 00000000..c7e5154a --- /dev/null +++ b/extensions/ai-providers/cursor/tool-bridge.ts @@ -0,0 +1,68 @@ +/** Translate Cursor MCP requests into Pi calls; execution stays in Pi's loop. */ +import type { Context, ToolCall } from "@earendil-works/pi-ai/compat"; +import { type McpArgs, McpToolDefinitionSchema } from "./proto.ts"; +import { + create, + decodeJsonValue, + encodeJsonValue, + type JsonValue, +} from "./protobuf.ts"; + +export const CURSOR_PI_PROVIDER = "openpi"; +export const CURSOR_PI_TOOLS_SYSTEM_PROMPT = + "Use only the provided openpi MCP tools. These are the active Pi tools and Pi owns their execution and permissions. Do not use Cursor-native filesystem, shell, editing, web, task, or interaction tools. When tool results appear in conversation history, continue from those results. Do not repeat a completed tool call."; + +export function buildCursorTools(tools: Context["tools"]) { + return (tools ?? []).map((tool) => { + const schema: JsonValue = JSON.parse(JSON.stringify(tool.parameters)); + return create(McpToolDefinitionSchema, { + name: tool.name, + providerIdentifier: CURSOR_PI_PROVIDER, + toolName: tool.name, + description: tool.description, + inputSchema: encodeJsonValue(schema), + inputSchemaJson: JSON.stringify(schema), + }); + }); +} + +export function decodeCursorTool( + args: McpArgs, + tools: Context["tools"], +): ToolCall { + const name = args.toolName || args.name; + if ( + args.providerIdentifier !== CURSOR_PI_PROVIDER || + (args.serverIdentifier && args.serverIdentifier !== CURSOR_PI_PROVIDER) || + !name || + (args.name && args.name !== name) || + !tools?.some((tool) => tool.name === name) + ) { + throw new Error("Cursor requested an unadvertised Pi tool identity"); + } + if (!args.toolCallId.trim()) + throw new Error("Cursor MCP tool call has no identity"); + const values: Record = {}; + for (const [key, value] of Object.entries(args.args)) { + // google.protobuf.Value, not JSON text. Define own properties so keys such + // as __proto__ cannot alter the decoded argument object's prototype. + if (!value.length || ![8, 17, 26, 32, 42, 50].includes(value[0]!)) { + throw new Error("Cursor MCP argument is not a protobuf JSON value"); + } + const decoded = decodeJsonValue(value); + const validateJson = (item: JsonValue): void => { + if (typeof item === "number" && !Number.isFinite(item)) + throw new Error("Cursor MCP argument contains a non-finite number"); + if (item && typeof item === "object") + for (const child of Object.values(item)) validateJson(child); + }; + validateJson(decoded); + Object.defineProperty(values, key, { + value: decoded, + enumerable: true, + configurable: true, + writable: true, + }); + } + return { type: "toolCall", id: args.toolCallId, name, arguments: values }; +} diff --git a/extensions/ai-providers/index.ts b/extensions/ai-providers/index.ts index f96438e6..a8c19721 100644 --- a/extensions/ai-providers/index.ts +++ b/extensions/ai-providers/index.ts @@ -3,8 +3,8 @@ * * Adds OAuth-backed Google Antigravity and Cursor model providers. Both are * inert until the user logs in and selects one of their models. Cursor uses - * AgentService/Run in deliberately chat-only mode: Cursor-native coding tools - * are not exposed or executed by this extension. + * AgentService/Run with an experimental bridge to normal Pi tool calls. + * Cursor-native coding tools are not exposed or executed by this extension. * * Wire protocol: Cloud Code Assist `v1internal:streamGenerateContent` over * SSE (see antigravity/provider.ts). Reference implementation: oh-my-pi's @@ -13,7 +13,6 @@ import { createProvider, type ProviderStreams } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { createOAuthAuth } from "./oauth-adapter.ts"; import { encodeApiKey } from "./antigravity/credentials.ts"; import { fetchAntigravityModels } from "./antigravity/discovery.ts"; import { @@ -31,6 +30,7 @@ import { transformCursorImageInput } from "./cursor/input-images.ts"; import { CURSOR_MODELS } from "./cursor/models.ts"; import { loginCursor, refreshCursorToken } from "./cursor/oauth.ts"; import { streamCursor } from "./cursor/provider.ts"; +import { createOAuthAuth } from "./oauth-adapter.ts"; function providerStreams( streamSimple: ProviderStreams["streamSimple"], diff --git a/extensions/shared/child-session.ts b/extensions/shared/child-session.ts index 9e0f8033..61a9d3ce 100644 --- a/extensions/shared/child-session.ts +++ b/extensions/shared/child-session.ts @@ -550,6 +550,20 @@ export function effectiveChildToolAllowlist(tools?: readonly string[]) { ); } +/** Project the parent's active surface into a child; a role can only narrow it. + * Active tools are a visibility choice, not a filesystem/network sandbox. + * Inactive tools are not implicitly activated by delegation. + */ +export function inheritedChildToolAllowlist( + parentTools: readonly string[], + roleTools?: readonly string[], +) { + const allowed = roleTools === undefined ? undefined : new Set(roleTools); + return effectiveChildToolAllowlist([...new Set(parentTools)])!.filter( + (name) => allowed === undefined || allowed.has(name), + ); +} + export function childToolPolicy(tools?: readonly string[]) { const effectiveTools = effectiveChildToolAllowlist(tools); return { diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 7cf19f44..06819fef 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -57,6 +57,7 @@ import { } from "../shared/below-editor-navigation.ts"; import { effectiveChildToolAllowlist, + inheritedChildToolAllowlist, resolveStandaloneChildProjectTrust, } from "../shared/child-session.ts"; import { formatContextUtilization } from "../shared/context-utilization.ts"; @@ -886,6 +887,7 @@ export default function ( if ( planning && agentType && + !agentType.planningCompatible && !planModeAllowsDeclaredTools(declaredChildTools) ) { throw new Error( @@ -934,7 +936,10 @@ export default function ( const requestedChildTools = planning ? planModeChildTools(declaredChildTools) : declaredChildTools; - const childTools = effectiveChildToolAllowlist(requestedChildTools); + const childTools = inheritedChildToolAllowlist( + pi.getActiveTools(), + requestedChildTools, + ); // Read at spawn time so `/openpi-setup` changes affect the next child // without reloading this extension. Undefined preserves parent-model // inheritance in the backend. diff --git a/extensions/subagents/src/agent-types.ts b/extensions/subagents/src/agent-types.ts index dd2d4e84..77e215d0 100644 --- a/extensions/subagents/src/agent-types.ts +++ b/extensions/subagents/src/agent-types.ts @@ -92,6 +92,8 @@ export interface AgentType { readonly description: string; /** Omitted = the child keeps the normal tool set. Present = allowlist. */ readonly tools?: readonly string[]; + /** Only built-in investigator definitions carry this planning compatibility. */ + readonly planningCompatible?: boolean; /** "provider/model-id" or a bare id; resolved by the pi backend. */ readonly model?: string; readonly reasoningEffort?: ReasoningEffort; @@ -121,9 +123,9 @@ export const READ_ONLY_AGENT_TOOLS = [ export const BUILT_IN_AGENT_TYPES: readonly AgentType[] = [ { name: "explorer", + planningCompatible: true, description: "Read-only codebase exploration. Usually use moderate reasoning, increasing it for harder tasks.", - tools: READ_ONLY_AGENT_TOOLS, body: "Explore the codebase read-only. Trace the real flow, inspect related callers, and report concise evidence with file paths and line references.", source: "built-in:explorer", }, @@ -131,36 +133,22 @@ export const BUILT_IN_AGENT_TYPES: readonly AgentType[] = [ name: "implementer", description: "Focused implementation with repository checks. Usually use medium-high reasoning, adjusted for scope, risk, and task difficulty.", - tools: [ - "read", - "bash", - "edit", - "write", - "grep", - "find", - "ls", - "fd", - "rg", - "git_show", - "git_diff", - "git_log", - ], body: "Implement the requested change carefully. Trace the affected flow first, make the smallest correct edit, and run relevant checks before reporting results.", source: "built-in:implementer", }, { name: "reviewer", + planningCompatible: true, description: "Read-only review for correctness, safety, and regressions. Usually use high reasoning, adjusted for task difficulty.", - tools: READ_ONLY_AGENT_TOOLS, body: "Review the requested code or change read-only. Identify concrete correctness, security, and regression risks with evidence; do not modify files.", source: "built-in:reviewer", }, { name: "advisor", + planningCompatible: true, description: "Deep read-only analysis and technical advice. Usually use high reasoning, adjusted for task difficulty.", - tools: READ_ONLY_AGENT_TOOLS, body: "Analyze the problem deeply without modifying files. Explain the relevant tradeoffs, risks, and recommended next step using repository evidence.", source: "built-in:advisor", }, diff --git a/extensions/subagents/src/manager.ts b/extensions/subagents/src/manager.ts index 4df2098a..2ea33dc7 100644 --- a/extensions/subagents/src/manager.ts +++ b/extensions/subagents/src/manager.ts @@ -10,10 +10,8 @@ * imperative TUI components (which render synchronously) can read snapshots * and issue fire-and-forget commands without touching the Effect runtime. * - * Every run is guarded by a first-response watchdog: a provider that accepts - * the request but never emits its first assistant event is settled as a - * failure (releasing its concurrency slot) instead of hanging forever, - * mirroring the workflow runner's watchdog. + * Pi owns provider transport timeouts and retries. This manager owns explicit + * cancellation, settlement, and bounded cleanup, not model-output deadlines. */ import { @@ -60,13 +58,6 @@ export const MAX_TRACKED = 64; const STOP_TIMEOUT_MS = 5_000; /** Session abort/shutdown (5s) plus bounded direct-worktree cleanup (4s). */ const ENTRY_CLOSE_TIMEOUT_MS = 10_000; -/** - * First-response watchdog: a run whose provider accepts the request but - * never emits an assistant event is settled as a failure so it cannot - * occupy a concurrency slot forever. Matches the workflow runner's - * MODEL_PROGRESS_TIMEOUT_MS (extensions/workflows/runner.ts). - */ -export const FIRST_RESPONSE_TIMEOUT_MS = 45_000; const ERROR_TEXT_MAX_LENGTH = 4_096; const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024; const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024; @@ -77,10 +68,6 @@ function bounded(text: string) { return text.slice(0, ERROR_TEXT_MAX_LENGTH); } -function formatWatchdogTimeout(ms: number) { - return ms % 1_000 === 0 ? `${ms / 1_000} seconds` : `${ms} ms`; -} - function boundedTranscriptText(text: string) { return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH); } @@ -133,8 +120,6 @@ interface Entry { scope: Scope.Closeable; pump?: Fiber.Fiber; liveToolMap: Map; - /** First-response watchdog timer for the active (or just-armed) run. */ - watchdogTimer?: ReturnType; /** Idle restart dispatched but RunStarted not folded yet; counts as running * so concurrent restarts cannot race past the cap. */ restarting?: boolean; @@ -214,8 +199,6 @@ export class SubagentManager extends Context.Service< const makeManager = (config: SubagentManagerConfig = {}) => Effect.gen(function* () { - const firstResponseTimeoutMs = - config.firstResponseTimeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS; const registry = yield* BackendRegistry; // Detached forker for sync contexts (read-model commands, pruning) that // preserves the manager's services instead of using the global runtime. @@ -344,7 +327,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => }; const settle = (entry: Entry, outcome: RunOutcome) => { - clearWatchdog(entry); const s = entry.snapshot; const wasRestarting = entry.restarting === true; entry.restarting = false; @@ -403,44 +385,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => pruneSettled(); }; - /** Stop the first-response watchdog (first response arrived / run settled). */ - const clearWatchdog = (entry: Entry) => { - if (entry.watchdogTimer !== undefined) { - clearTimeout(entry.watchdogTimer); - entry.watchdogTimer = undefined; - } - }; - - /** Settle a run whose provider never emitted a first assistant response. */ - const watchdogExpired = (entry: Entry) => { - entry.watchdogTimer = undefined; - if (!isBusy(entry)) return; - const model = entry.snapshot.meta.modelLabel; - settle(entry, { - _tag: "Failed", - errorText: `Agent received no assistant response event${model ? ` for ${model}` : ""} within ${formatWatchdogTimeout(firstResponseTimeoutMs)}; the provider request may be stalled. Retry the subagent.`, - }); - // The stalled session cannot be trusted to abort cooperatively; dispose - // it like the abort-deadline path so it cannot revive into a zombie run. - const fiber = runDetached( - closeEntryScope(entry).pipe( - Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS), - Effect.ignore, - ), - ); - cleanups.add(fiber); - fiber.addObserver(() => cleanups.delete(fiber)); - }; - - /** Arm the first-response watchdog for the entry's current run. */ - const armWatchdog = (entry: Entry) => { - clearWatchdog(entry); - entry.watchdogTimer = setTimeout( - () => watchdogExpired(entry), - firstResponseTimeoutMs, - ); - }; - const foldEvent = (entry: Entry, event: SubagentEvent) => { const s = entry.snapshot; switch (event._tag) { @@ -451,7 +395,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => s.settledAt = undefined; s.errorText = undefined; s.structuredResult = undefined; - armWatchdog(entry); break; case "RunSettled": settle(entry, event.outcome); @@ -463,7 +406,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => }); break; case "AssistantDelta": { - clearWatchdog(entry); const live = s.liveAssistant ?? { text: "", thinking: "" }; s.liveAssistant = event.kind === "text" @@ -482,7 +424,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => break; } case "AssistantMessage": - clearWatchdog(entry); appendTranscript(s, { kind: "assistant", parts: event.parts.map((part) => @@ -628,9 +569,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => liveToolMap: new Map(), }; entries.set(id, entry); - // The run is live from the caller's perspective before RunStarted - // reaches the pump; guard that window too. - armWatchdog(entry); // Pump: fold the event stream into the snapshot. Tied to the entry // scope, so closing the scope stops it. If the stream ends while the @@ -781,10 +719,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => // both pass the check in that window. Cleared by RunStarted/settle, // or here when the backend rejects the send. entry.restarting = true; - // A backend that accepts the send but never starts the run would - // hold the slot forever; guard the restart window the same way the - // spawn path guards its pre-RunStarted window. - armWatchdog(entry); return entry.session.send(text).pipe( Effect.onError(() => Effect.sync(() => { @@ -800,7 +734,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => const disposeAll = Effect.gen(function* () { disposed = true; const all = [...entries.values()]; - for (const entry of all) clearWatchdog(entry); entries.clear(); yield* Effect.forEach( all, @@ -878,8 +811,6 @@ const makeManager = (config: SubagentManagerConfig = {}) => }); export interface SubagentManagerConfig { - /** Test-only override for the first-response watchdog timeout. */ - firstResponseTimeoutMs?: number; /** Session-branch high-water marks restored by the extension host. */ initialModelCounter?: number; initialBtwCounter?: number; diff --git a/extensions/subagents/src/prompt.ts b/extensions/subagents/src/prompt.ts index 9f4139c9..ab74567b 100644 --- a/extensions/subagents/src/prompt.ts +++ b/extensions/subagents/src/prompt.ts @@ -141,11 +141,11 @@ export const SUBAGENT_SPAWN_PROMPT_GUIDELINES = [ /** Model-facing schema descriptions for subagent_spawn task and execution options. */ export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = { prompt: - "Task prompt for the subagent. Must be self-contained: include all needed context, file paths, and what to report back.", + "Self-contained task: include needed context, file paths, and expected report.", name: "Short human-readable name shown in listings and the UI", harness: 'Optional; "pi" is the only harness and the default.', workingDir: - "Trusted child working directory; defaults to the current directory", + "Child cwd, absolute or relative to parent. Target project trust is checked separately.", isolation: 'Use "worktree" for concurrent writers and tell the child to commit. See the Subagents Skill for lifecycle details.', model: diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 0e4de4d1..c928dc98 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -53,7 +53,11 @@ import { formatActivityStatus, } from "../shared/activity-status.ts"; import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; -import { waitBounded } from "../shared/child-session.ts"; +import { + inheritedChildToolAllowlist, + resolveStandaloneChildProjectTrust, + waitBounded, +} from "../shared/child-session.ts"; import { contextPercent } from "../shared/context-utilization.ts"; import { completionOwnerFor } from "../shared/completion-inbox.ts"; import { @@ -544,6 +548,7 @@ interface ScriptAgentResult { } interface AgentCallOptions { + working_dir?: unknown; agent_type?: unknown; label?: unknown; phase?: unknown; @@ -1339,11 +1344,12 @@ export default function workflows( structured: boolean, cwd: string, agentTypePrompt?: string, + childProjectTrusted = projectTrusted, ) => createWorkflowResources( cwd, structured ? "structured" : "plain", - projectTrusted, + childProjectTrusted, agentTypePrompt, ); @@ -1638,6 +1644,36 @@ export default function workflows( ); } + const childTools = inheritedChildToolAllowlist( + pi.getActiveTools(), + agentType?.tools, + ); + if ( + opts.working_dir !== undefined && + (typeof opts.working_dir !== "string" || !opts.working_dir.trim()) + ) { + return fail( + `agent "${label}": working_dir must be a non-empty string`, + ); + } + const requestedCwd = path.resolve( + ctx.cwd, + typeof opts.working_dir === "string" ? opts.working_dir : ".", + ); + try { + if (!fs.statSync(requestedCwd).isDirectory()) + throw new Error("not a directory"); + } catch { + return fail( + `agent "${label}": working_dir is not a directory: ${requestedCwd}`, + ); + } + const childProjectTrusted = resolveStandaloneChildProjectTrust({ + parentCwd: ctx.cwd, + childCwd: requestedCwd, + parentTrusted: projectTrusted, + }); + const explicitModel = typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() @@ -1708,11 +1744,14 @@ export default function workflows( const operatorFingerprint = operatorKey ? agentCallKey("workflow-operator", { execution: { + cwd: requestedCwd, + projectTrusted: childProjectTrusted, + tools: childTools, agentType: agentType ? { name: agentType.name, body: agentType.body, - tools: agentType.tools, + tools: childTools, } : undefined, model: model ? `${model.provider}/${model.id}` : undefined, @@ -1732,7 +1771,7 @@ export default function workflows( const replaySafe = operatorKey === undefined && isReplaySafeAgentCall({ - tools: agentType?.tools, + tools: agentType?.tools === undefined ? undefined : childTools, isolation: opts.isolation, }); const replayLease = beginProcessReplayWorkspaceLease(replaySafe); @@ -1744,13 +1783,14 @@ export default function workflows( try { replayResources = await getResources( effectiveSchema !== undefined, - ctx.cwd, + requestedCwd, agentType?.body, + childProjectTrusted, ); replayIdentity = createReplayIdentity( - ctx.cwd, + requestedCwd, replayResources.loader, - projectTrusted, + childProjectTrusted, ); } catch { // Fingerprinting is an optimization boundary. If resources cannot @@ -1768,7 +1808,7 @@ export default function workflows( ? { name: agentType.name, body: agentType.body, - tools: agentType.tools, + tools: childTools, } : undefined, model: model ? `${model.provider}/${model.id}` : undefined, @@ -1925,7 +1965,7 @@ export default function workflows( ); } const created = await createWorktree({ - cwd: ctx.cwd, + cwd: requestedCwd, label, id: `${details.runId}-${record.index}`, }); @@ -1942,7 +1982,7 @@ export default function workflows( ? runSignal.reason : new Error("Workflow was aborted"); } - const agentCwd = worktree?.path ?? ctx.cwd; + const agentCwd = worktree?.path ?? requestedCwd; // Inside the try, not before it: building resources can throw // (bad settings, an unreadable skills dir), and a throw out here @@ -1968,6 +2008,7 @@ export default function workflows( effectiveSchema !== undefined, agentCwd, agentType?.body, + childProjectTrusted, ), resourceAbort, ]).finally(() => { @@ -1992,7 +2033,7 @@ export default function workflows( settingsManager: resources.settingsManager, ...(sessionManager ? { sessionManager } : {}), modelRegistry: ctx.modelRegistry, - ...(agentType?.tools ? { tools: agentType.tools } : {}), + tools: childTools, ...(testAgentSessionFactory ? { sessionFactory: testAgentSessionFactory } : {}), @@ -2108,9 +2149,9 @@ export default function workflows( // unfingerprintable calls always run for real. const completedIdentity = callKey ? createReplayIdentity( - ctx.cwd, + requestedCwd, resources.loader, - projectTrusted, + childProjectTrusted, ) : undefined; const completedKey = completedIdentity @@ -2157,7 +2198,7 @@ export default function workflows( runId: details.runId, agentIndex: record.index, agentLabel: record.label, - repoCwd: ctx.cwd, + repoCwd: requestedCwd, worktree, }); let cleanup: WorktreeCleanup; @@ -2174,7 +2215,7 @@ export default function workflows( const reclaimer = workflowLifecycleTestHooks?.reclaimWorktree ?? reclaimWorktree; - cleanup = await reclaimer(ctx.cwd, worktree).catch( + cleanup = await reclaimer(requestedCwd, worktree).catch( (error): WorktreeCleanup => ({ removed: false, branchDeleted: false, diff --git a/extensions/workflows/runner.ts b/extensions/workflows/runner.ts index 12c9686e..5f4c1cb2 100644 --- a/extensions/workflows/runner.ts +++ b/extensions/workflows/runner.ts @@ -50,7 +50,6 @@ import { truncateUtf8 } from "./serialization.ts"; import { bindWorkflowToolRenderer } from "./tool-renderer.ts"; const AGENT_OUTPUT_MAX_BYTES = 64 * 1024; -export const MODEL_PROGRESS_TIMEOUT_MS = 45_000; export type WorkflowModel = NonNullable; export type ThinkingLevel = ReturnType; @@ -111,8 +110,6 @@ export interface RunAgentOptions { replayFilesystemBoundary?: ReplayFilesystemBoundaryOptions; /** Test-only override for the per-tool execution timeout. */ toolCallTimeoutMs?: number; - /** Test-only override for the per-provider-turn model-progress timeout. */ - modelProgressTimeoutMs?: number; /** Test-only override for the end-to-end abort/shutdown deadline. */ shutdownTimeoutMs?: number; /** Test seam for lifecycle races; production always uses createAgentSession. */ @@ -245,119 +242,6 @@ function errorText(error: unknown): string { ); } -function formatTimeout(timeoutMs: number) { - return timeoutMs % 1_000 === 0 - ? `${timeoutMs / 1_000} seconds` - : `${timeoutMs} ms`; -} - -export function resolveModelProgressTimeoutMs( - settingsManager: SettingsManager, - override?: number, -) { - if (override !== undefined) return override; - const configured = - settingsManager.getProjectSettings().httpIdleTimeoutMs ?? - settingsManager.getGlobalSettings().httpIdleTimeoutMs; - return typeof configured === "number" && Number.isFinite(configured) - ? Math.max(MODEL_PROGRESS_TIMEOUT_MS, Math.floor(configured)) - : MODEL_PROGRESS_TIMEOUT_MS; -} - -/** Abort any provider turn that stops producing model-visible progress. */ -export function createModelProgressWatchdog( - onTimeout: (error: Error) => Promise, - options: { timeoutMs?: number; model?: string } = {}, -) { - const timeoutMs = options.timeoutMs ?? MODEL_PROGRESS_TIMEOUT_MS; - let timer: ReturnType | undefined; - let activeTurn = false; - let closed = false; - let rejectTimeout!: (error: Error) => void; - const timeout = new Promise((_resolve, reject) => { - rejectTimeout = reject; - }); - - const clear = () => { - if (timer) clearTimeout(timer); - timer = undefined; - }; - const schedule = () => { - clear(); - if (!activeTurn || closed) return; - // This timer owns the awaited watchdog outcome. Keep it referenced so a - // short-lived Node 22 process cannot exit with the promise still pending. - timer = setTimeout(() => { - timer = undefined; - activeTurn = false; - closed = true; - const model = options.model ? ` for ${options.model}` : ""; - const error = new Error( - `Agent provider turn${model} produced no model-visible progress for ${formatTimeout(timeoutMs)}; the provider request may be stalled. Retry the workflow.`, - ); - rejectTimeout(error); - try { - void onTimeout(error).catch(() => {}); - } catch { - // The timeout result remains authoritative even if abort throws before - // returning its promise; bounded shutdown below gets another chance. - } - }, timeoutMs); - }; - const armTurn = () => { - if (closed) return; - activeTurn = true; - schedule(); - }; - const markProgress = () => { - if (!activeTurn || closed) return; - schedule(); - }; - const completeTurn = () => { - activeTurn = false; - clear(); - }; - const cancel = () => { - closed = true; - activeTurn = false; - clear(); - }; - - return { - armTurn, - markProgress, - completeTurn, - cancel, - async waitFor(operation: Promise) { - try { - return await Promise.race([operation, timeout]); - } finally { - cancel(); - } - }, - }; -} - -function isModelVisibleProgress(event: AgentSessionEvent) { - if (event.type !== "message_update" || event.message.role !== "assistant") { - return false; - } - // Raw transport heartbeats never become AgentSession events. Empty stream, - // text, and thinking starts likewise cannot keep a provider turn alive. - const update = event.assistantMessageEvent; - if ( - update.type === "text_delta" || - update.type === "thinking_delta" || - update.type === "toolcall_delta" - ) { - return update.delta.length > 0; - } - if (update.type === "text_end" || update.type === "thinking_end") { - return update.content.length > 0; - } - return update.type === "toolcall_start" || update.type === "toolcall_end"; -} - export async function runAgent( options: RunAgentOptions, ): Promise { @@ -367,8 +251,6 @@ export async function runAgent( let session: AgentSession | undefined; let unsubscribeToolGuards: (() => void) | undefined; let aborted = false; - let terminalCause: "abort" | "model-progress-timeout" | undefined; - let modelProgressTimeoutMessage: string | undefined; let abortOperation: Promise | undefined; let rejectForAbort: ((error: Error) => void) | undefined; let rejectForProjectionFailure: ((error: Error) => void) | undefined; @@ -389,7 +271,6 @@ export async function runAgent( const onAbort = () => { if (aborted) return; aborted = true; - terminalCause ??= "abort"; if (session) { try { abortOperation ??= session.abort(); @@ -594,10 +475,6 @@ export async function runAgent( }); }; - let armModelProgress = () => {}; - let markModelProgress = () => {}; - let completeModelTurn = () => {}; - let cancelModelProgressWatchdog = () => {}; let compactionReconcileQueued = false; const queueCompactionReconcile = () => { if (compactionReconcileQueued) return; @@ -625,7 +502,6 @@ export async function runAgent( }; const unsubscribe = childSession.subscribe((event) => { if (settled) return; - if (event.type === "turn_start") armModelProgress(); if (event.type === "tool_execution_start") { toolRenderer.start( event.toolCallId, @@ -648,10 +524,6 @@ export async function runAgent( event.isError, ); } - if (isModelVisibleProgress(event)) markModelProgress(); - if (event.type === "message_end" && event.message.role === "assistant") { - completeModelTurn(); - } if (event.type === "message_end") { assistantSettlement = observeAssistantSettlement( assistantSettlement, @@ -686,32 +558,10 @@ export async function runAgent( captureToolRenderData(childSession.messages); snapshotProjection(); if (!aborted) { - const watchdog = createModelProgressWatchdog( - (error) => { - terminalCause ??= "model-progress-timeout"; - if (terminalCause === "model-progress-timeout") { - modelProgressTimeoutMessage ??= error.message; - } - abortOperation ??= childSession.abort(); - void abortOperation.catch(() => {}); - return abortOperation; - }, - { - timeoutMs: resolveModelProgressTimeoutMs( - options.settingsManager, - options.modelProgressTimeoutMs, - ), - model: modelId, - }, - ); - armModelProgress = watchdog.armTurn; - markModelProgress = watchdog.markProgress; - completeModelTurn = watchdog.completeTurn; - cancelModelProgressWatchdog = watchdog.cancel; + // Pi owns transport liveness and retries. Quiet model output is not + // evidence of a stalled request (thinking and retry backoff can be silent). await Promise.race([ - watchdog.waitFor( - childSession.prompt(buildWorkflowAgentPrompt(options.prompt)), - ), + childSession.prompt(buildWorkflowAgentPrompt(options.prompt)), abortRace, projectionFailureRace, ]); @@ -719,7 +569,6 @@ export async function runAgent( } catch (error) { promptErrorMessage ??= errorText(error); } finally { - cancelModelProgressWatchdog(); options.signal?.removeEventListener("abort", onAbort); settled = true; unsubscribe(); @@ -758,11 +607,7 @@ export async function runAgent( ? `Cleanup failed: ${cleanupErrors.join("; ")}` : undefined; - if ( - terminalCause === "abort" || - (terminalCause === undefined && - assistantSettlement?.stopReason === "aborted") - ) { + if (aborted || assistantSettlement?.stopReason === "aborted") { return { ok: false, output, @@ -779,9 +624,7 @@ export async function runAgent( } const failureMessage = - (terminalCause === "model-progress-timeout" - ? modelProgressTimeoutMessage - : agentFailureMessage(assistantSettlement, promptErrorMessage)) ?? + agentFailureMessage(assistantSettlement, promptErrorMessage) ?? cleanupError; if (failureMessage !== undefined) { return { diff --git a/extensions/workflows/sandbox.ts b/extensions/workflows/sandbox.ts index 95f2da27..81f242ee 100644 --- a/extensions/workflows/sandbox.ts +++ b/extensions/workflows/sandbox.ts @@ -35,6 +35,7 @@ export interface SandboxAgentOptions { provider?: unknown; effort?: unknown; isolation?: unknown; + working_dir?: unknown; operator?: unknown; inputs?: unknown; } @@ -110,6 +111,9 @@ function sanitizeAgentOptions(value: unknown): SandboxAgentOptions { ...(value.provider !== undefined ? { provider: value.provider } : {}), ...(value.effort !== undefined ? { effort: value.effort } : {}), ...(value.isolation !== undefined ? { isolation: value.isolation } : {}), + ...(value.working_dir !== undefined + ? { working_dir: value.working_dir } + : {}), ...(value.operator !== undefined ? { operator: value.operator } : {}), ...(value.inputs !== undefined ? { inputs: value.inputs } : {}), }; diff --git a/skills/subagents/REFERENCE.md b/skills/subagents/REFERENCE.md index 99ff4fc0..0cbafdb1 100644 --- a/skills/subagents/REFERENCE.md +++ b/skills/subagents/REFERENCE.md @@ -32,7 +32,7 @@ file:line references. You cannot modify files — do not attempt to. | ------------------ | -------- | ------------------------------------------------------------------------------------ | | `name` | yes | `[a-z0-9-]`, ≤64 chars, and must equal the filename stem. | | `description` | yes | Shown to the parent model when it picks a type. ≤1024 chars. | -| `tools` | no | Tool allowlist. **Omit to inherit the normal tool set.** | +| `tools` | no | Tool allowlist. **Omit to inherit active parent child-eligible tools.** | | `model` | no | `provider/model-id`, or a bare id resolved against the current provider; ≤256 chars. | | `reasoning_effort` | no | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. | | body | no | Appended to the child's system prompt. ≤16384 chars. | @@ -50,16 +50,15 @@ file with the same name. | Role | Tools | Relative effort guidance | Purpose | | ------------- | ------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------ | -| `explorer` | `read grep find ls fd rg git_show git_diff git_log` | Moderate | Read-only codebase tracing; increase for harder tasks. | -| `implementer` | `read bash edit write grep find ls fd rg git_show git_diff git_log` | Medium-high | Focused implementation; adjust for scope and risk. | -| `reviewer` | `read grep find ls fd rg git_show git_diff git_log` | High | Read-only correctness, safety, and regression review. | -| `advisor` | `read grep find ls fd rg git_show git_diff git_log` | High | Deep read-only analysis and technical advice. | +| `explorer` | Active parent child-eligible tools | Moderate | Read-only codebase tracing; increase for harder tasks. | +| `implementer` | Active parent child-eligible tools | Medium-high | Focused implementation; adjust for scope and risk. | +| `reviewer` | Active parent child-eligible tools | High | Read-only correctness, safety, and regression review. | +| `advisor` | Active parent child-eligible tools | High | Deep read-only analysis and technical advice. | These are relative selection hints, not fixed Pi thinking levels. Built-ins set no model or reasoning-effort default. An explicit user requirement takes priority; otherwise the parent model chooses from levels supported by the -resolved child model according to the role and task difficulty. Their tool -allowlists still intersect with plan mode and the child denylist. +resolved child model according to the role and task difficulty. Custom role allowlists intersect with active parent tools, Plan Mode, and the child denylist. Built-in read-only task guidance is a role instruction, not a filesystem sandbox. Existing custom role files are never widened automatically. ## Discovery diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index c454cb35..29571a4b 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -10,7 +10,7 @@ The tool definitions are canonical for parameters, limits, model syntax, isolati - Delegate substantial independent work, not a lookup or edit the parent can do directly. - Give the child a standalone prompt with paths, constraints, relevant context, and the expected report; it cannot see the parent conversation or ask the user. - Inherit the parent model by default. When choosing the child's reasoning effort, honor an explicit user requirement first; otherwise use the selected role's relative guidance and the task's difficulty, choosing from levels supported by the resolved child model. -- Prefer a matching agent type when one exists; its tool restriction is enforced. Model precedence is explicit spawn override, selected type-file model, configured built-in role model, then parent model. Reasoning precedence is explicit spawn override, selected type default, then parent effort. Types live in `~/.pi/agent/agents/*.md` and, for trusted projects, `.pi/agents/*.md`; see [Agent types](REFERENCE.md). +- Prefer a matching agent type when one exists; built-ins inherit active parent tools, while an explicit custom tool list is enforced as a narrowing restriction. Model precedence is explicit spawn override, selected type-file model, configured built-in role model, then parent model. Reasoning precedence is explicit spawn override, selected type default, then parent effort. Types live in `~/.pi/agent/agents/*.md` and, for trusted projects, `.pi/agents/*.md`; see [Agent types](REFERENCE.md). - Isolate concurrent writers in worktrees according to the `subagent_spawn` schema so they cannot overwrite one checkout or git index. While Plan Mode is active, use only read-only exploration types (or no type); worktree isolation and types narrowed by Plan Mode are rejected. - After spawning, continue useful parent work. In an interactive session, if none remains, tell the user the child is still running and end the turn; automatic result delivery will re-invoke the parent when it settles. Do not block merely because the next step depends on the result or because there is nothing else to do. Use `subagent_wait` only when the user explicitly asks to keep the current response open for the result, or when non-interactive automation must return it in the same invocation. - Use optional `output_schema` when downstream work needs a machine-validated result rather than prose. The child then receives one terminating `structured_output` tool, and the run fails if it finishes without submitting a matching value. Keep schemas small and task-specific; the validated JSON is delivered to the parent and preserved in a private content-addressed artifact. Omit the option for ordinary text reports. diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index 82a26273..372b3871 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -14,8 +14,10 @@ The `workflow` script is an async JavaScript function body executed in a restric `await agent(prompt, options)` runs one child and always resolves to `{ ok, output, structured?, ref?, acceptance?, acceptanceWarning?, error? }`. Check `ok` before reading output. Children receive normal trust-aware resources but cannot recursively orchestrate or ask the user. -Useful options include `agent_type`, `label`, `phase`, `schema`, `model`, `provider`, `effort`, `isolation`, `operator`, and `inputs`. The legacy `acceptance` option remains readable only during the 0.x migration window described below. +Useful options include `agent_type`, `label`, `phase`, `schema`, `model`, `provider`, `effort`, `working_dir`, `isolation`, `operator`, and `inputs`. The legacy `acceptance` option remains readable only during the 0.x migration window described below. +- Set `working_dir` when tools must run in another repository. Relative paths resolve against the parent cwd; prompt text alone does not change it. The directory must exist. Project resource trust is checked for the target independently. +- Built-in roles inherit currently active parent child-eligible tools, including shell/network when available. Explicit custom tool lists only narrow this surface. Built-ins with inherited tools execute for real on resume; custom bounded read-only calls retain the replay filesystem boundary below. - Prefer a matching `agent_type`. Model precedence is explicit model/provider, type file, configured built-in role, then parent. Effort precedence is explicit effort, type default, then parent. - `schema` validates structured output. Use it whenever later workflow logic branches on fields. - `acceptance` is deprecated since OpenPI 0.5 and scheduled for removal in 1.0. Compatibility calls still return the child-authored ledger with `authority: "model-self-attestation"` and a migration warning, but it never determines `ok`. Use ordinary `schema` for findings, then let the parent evaluate them alongside runtime-observed exit codes, test receipts, file fingerprints, and tool results. Old DSL, journals, and artifacts remain readable during 0.x. diff --git a/tests/extensions/ai-providers/cursor.test.ts b/tests/extensions/ai-providers/cursor.test.ts index 437580d3..71f78923 100644 --- a/tests/extensions/ai-providers/cursor.test.ts +++ b/tests/extensions/ai-providers/cursor.test.ts @@ -8,6 +8,7 @@ import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, test } from "node:test"; +import { Agent } from "@earendil-works/pi-agent-core"; import type { Api, AssistantMessage, @@ -21,6 +22,7 @@ import { type ExtensionContext, VERSION as PI_VERSION, } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; import { fetchCursorModels, fetchCursorUsableModels, @@ -36,11 +38,15 @@ import { type AgentClientMessage, AgentClientMessageSchema, AgentServerMessageSchema, + CursorToolCallSchema, ExecServerMessageSchema, GetUsableModelsResponseSchema, InteractionQueryPayloadSchema, InteractionQuerySchema, InteractionUpdateSchema, + McpArgsSchema, + McpToolCallSchema, + type McpToolDefinition, ModelDetailsSchema, TextDeltaUpdateSchema, ThinkingCompletedUpdateSchema, @@ -51,6 +57,8 @@ import { } from "../../../extensions/ai-providers/cursor/proto.ts"; import { create, + decodeJsonValue, + encodeJsonValue, fromBinary, toBinary, } from "../../../extensions/ai-providers/cursor/protobuf.ts"; @@ -1153,3 +1161,522 @@ test("Cursor rejects custom fetch and bounds an idle HTTP/2 stream", async () => /idle timeout after 10ms/, ); }); + +test("Cursor advertises the active Pi tool schema over local request_context", async () => { + let advertised: McpToolDefinition[] | undefined; + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + let buffer: Buffer = Buffer.alloc(0); + peer.on("data", (chunk) => { + buffer = appendChunk(buffer, chunk); + while (buffer.length >= 5) { + const size = buffer.readUInt32BE(1); + if (buffer.length < size + 5) break; + const message = fromBinary( + AgentClientMessageSchema, + buffer.subarray(5, size + 5), + ).message; + buffer = buffer.subarray(size + 5); + if (message.case === "runRequest") { + peer.write( + frameServerMessage( + create(AgentServerMessageSchema, { + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 1, + execId: "context", + message: { case: "requestContextArgs", value: {} }, + }), + }, + }), + ), + ); + } else if ( + message.case === "execClientMessage" && + message.value.message.case === "requestContextResult" + ) { + const result = message.value.message.value.result; + if (result.case === "success") + advertised = result.value.requestContext?.tools; + peer.end( + responseUpdate( + create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: {} }, + }), + ), + ); + } + } + }); + }); + servers.push(server); + await collectEvents( + streamCursor( + localModel(server.baseUrl), + { + ...CONTEXT, + tools: [ + { + name: "lookup", + description: "Look up a public page", + parameters: Type.Object({ url: Type.String() }), + }, + ], + }, + { apiKey: "token" }, + ), + ); + assert.equal(advertised?.length, 1); + assert.equal(advertised?.[0]?.providerIdentifier, "openpi"); + assert.equal(advertised?.[0]?.toolName, "lookup"); + assert.deepEqual(decodeJsonValue(advertised![0]!.inputSchema), { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }); +}); + +const LOOKUP = { + name: "lookup", + description: "Look up a public page", + parameters: Type.Object({ url: Type.String() }), +}; +function mcpExec( + overrides: Partial> = {}, +) { + return frameServerMessage( + create(AgentServerMessageSchema, { + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 2, + execId: "mcp", + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + providerIdentifier: "openpi", + toolName: "lookup", + name: "lookup", + toolCallId: "lookup-1", + args: { url: encodeJsonValue("https://example.test/page") }, + ...overrides, + }), + }, + }), + }, + }), + ); +} +function receiveClient( + peer: ServerHttp2Stream, + handler: (message: AgentClientMessage["message"]) => void, +) { + let buffer: Buffer = Buffer.alloc(0); + peer.on("data", (chunk) => { + buffer = appendChunk(buffer, chunk); + while (buffer.length >= 5) { + const size = buffer.readUInt32BE(1); + if (buffer.length < size + 5) break; + const message = fromBinary( + AgentClientMessageSchema, + buffer.subarray(5, size + 5), + ).message; + buffer = buffer.subarray(size + 5); + handler(message); + } + }); +} + +test("Cursor MCP handoff executes through Pi exactly once and resumes with the paired result", async () => { + let requests = 0; + let executed = 0; + let history: unknown[] = []; + const lifecycle: string[] = []; + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case !== "runRequest") return; + requests++; + if (requests === 1) { + assert.equal(message.value.action?.action.case, "userMessageAction"); + const preview = responseUpdate( + create(InteractionUpdateSchema, { + message: { + case: "partialToolCall", + value: { + callId: "preview-1", + argsTextDelta: '{"url":', + toolCall: create(CursorToolCallSchema, { + tool: { + case: "mcpToolCall", + value: create(McpToolCallSchema, {}), + }, + }), + }, + }, + }), + ); + peer.write(Buffer.concat([preview, mcpExec(), mcpExec()])); + } else { + assert.equal(message.value.action?.action.case, "resumeAction"); + peer.end( + Buffer.concat([ + responseUpdate( + create(InteractionUpdateSchema, { + message: { + case: "textDelta", + value: create(TextDeltaUpdateSchema, { + text: "Page checked.", + }), + }, + }), + ), + responseUpdate( + create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: {} }, + }), + ), + ]), + ); + } + }); + }); + servers.push(server); + const agent = new Agent({ + initialState: { + model: localModel(server.baseUrl), + systemPrompt: "Use lookup once.", + tools: [ + { + ...LOOKUP, + label: "Lookup", + execute: async (_id, args) => { + executed++; + assert.deepEqual(args, { url: "https://example.test/page" }); + return { + content: [{ type: "text", text: "Page contents" }], + details: {}, + }; + }, + }, + ], + }, + getApiKey: () => "token", + streamFn: async (model, context, options) => { + if (context.messages.at(-1)?.role === "toolResult") { + const built = await buildCursorRequest(model, context, options); + history = built.conversationState.rootPromptMessagesJson.map((id) => + JSON.parse( + Buffer.from( + built.blobStore.get(Buffer.from(id).toString("hex"))!, + ).toString(), + ), + ); + } + return streamCursor(model, context, options); + }, + }); + agent.subscribe((event) => { + lifecycle.push(event.type); + }); + await agent.prompt("Check the page."); + assert.equal(requests, 2); + assert.equal(executed, 1); + assert.equal( + lifecycle.filter((type) => type === "tool_execution_start").length, + 1, + ); + assert.equal( + lifecycle.filter((type) => type === "tool_execution_end").length, + 1, + ); + assert.match(JSON.stringify(history), /"tool-call".*lookup-1/); + assert.match(JSON.stringify(history), /"tool-result".*Page contents/); + assert.equal(agent.state.messages.at(-1)?.role, "assistant"); + assert.match(JSON.stringify(agent.state.messages.at(-1)), /Page checked/); +}); + +test("Cursor rejects unadvertised, conflicting and malformed MCP requests without executable calls", async () => { + const cases = [ + [ + responseUpdate( + create(InteractionUpdateSchema, { + message: { + case: "toolCallStarted", + value: { + toolCall: create(CursorToolCallSchema, { + tool: { case: undefined }, + $unknown: [{ no: 2, wireType: 2, data: new Uint8Array([0]) }], + }), + }, + }, + }), + ), + ], + [ + mcpExec(), + frameConnectMessage( + Buffer.from( + JSON.stringify({ + error: { + code: "resource_exhausted", + message: "fixture rate limit", + }, + }), + ), + 2, + ), + ], + [mcpExec({ providerIdentifier: "other" })], + [mcpExec({ toolName: "bash", name: "bash" })], + [mcpExec({ toolCallId: "" })], + [mcpExec({ args: { url: new Uint8Array([255]) } })], + [mcpExec({ args: { url: new Uint8Array() } })], + [mcpExec({ args: { url: encodeJsonValue(Number.NaN) } })], + [mcpExec(), mcpExec({ args: { url: encodeJsonValue("different") } })], + ]; + for (const frames of cases) { + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case === "runRequest") peer.end(Buffer.concat(frames)); + }); + }); + servers.push(server); + const events = await collectEvents( + streamCursor( + localModel(server.baseUrl), + { ...CONTEXT, tools: [LOOKUP] }, + { apiKey: "token" }, + ), + ); + assert.equal(events.at(-1)?.type, "error"); + assert.equal( + events.some((event) => event.type === "toolcall_end"), + false, + ); + } +}); + +test("Cursor approval-only probes reject without execution and can continue to ordinary text", async () => { + let rejected = false; + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case === "runRequest") + peer.write(mcpExec({ smartModeApprovalOnly: true })); + if ( + message.case === "execClientMessage" && + message.value.message.case === "mcpResult" + ) { + rejected = message.value.message.value.result.case === "rejected"; + peer.end( + responseUpdate( + create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: {} }, + }), + ), + ); + } + }); + }); + servers.push(server); + const events = await collectEvents( + streamCursor( + localModel(server.baseUrl), + { ...CONTEXT, tools: [LOOKUP] }, + { apiKey: "token" }, + ), + ); + assert.equal(rejected, true); + assert.equal(events.at(-1)?.type, "done"); + assert.equal( + events.some((event) => event.type === "toolcall_end"), + false, + ); +}); + +test("Cursor tool handoff preserves Pi permission denial and failed-tool results", async () => { + for (const deny of [true, false]) { + let requests = 0; + let executions = 0; + let replay = ""; + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case !== "runRequest") return; + if (++requests === 1) peer.write(mcpExec()); + else + peer.end( + responseUpdate( + create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: {} }, + }), + ), + ); + }); + }); + servers.push(server); + const agent = new Agent({ + initialState: { + model: localModel(server.baseUrl), + tools: [ + { + ...LOOKUP, + label: "Lookup", + execute: async () => { + executions++; + throw new Error("Local fixture tool failed"); + }, + }, + ], + }, + getApiKey: () => "token", + beforeToolCall: async () => + deny ? { block: true, reason: "Policy denied this tool" } : undefined, + streamFn: async (model, context, options) => { + if (context.messages.at(-1)?.role === "toolResult") { + const built = await buildCursorRequest(model, context, options); + replay = built.conversationState.rootPromptMessagesJson + .map((id) => + Buffer.from( + built.blobStore.get(Buffer.from(id).toString("hex"))!, + ).toString(), + ) + .join("\n"); + } + return streamCursor(model, context, options); + }, + }); + await agent.prompt("Look up the page."); + assert.equal(executions, deny ? 0 : 1); + assert.equal(requests, 2); + assert.match(replay, /"isError":true/); + assert.match( + replay, + deny ? /Policy denied this tool/ : /Local fixture tool failed/, + ); + } +}); + +test("Cursor partial previews never execute and cancellation closes the request", async () => { + const controller = new AbortController(); + const sawRequest = Promise.withResolvers(); + const peerClosed = Promise.withResolvers(); + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + peer.on("close", () => peerClosed.resolve()); + receiveClient(peer, (message) => { + if (message.case !== "runRequest") return; + peer.write( + responseUpdate( + create(InteractionUpdateSchema, { + message: { + case: "partialToolCall", + value: { + callId: "partial", + argsTextDelta: '{"url":', + toolCall: create(CursorToolCallSchema, { + tool: { + case: "mcpToolCall", + value: create(McpToolCallSchema, {}), + }, + }), + }, + }, + }), + ), + ); + sawRequest.resolve(); + }); + }); + servers.push(server); + const collected = collectEvents( + streamCursor( + localModel(server.baseUrl), + { ...CONTEXT, tools: [LOOKUP] }, + { apiKey: "token", signal: controller.signal }, + ), + ); + await sawRequest.promise; + controller.abort(); + const events = await collected; + const terminal = events.at(-1); + assert.ok(terminal?.type === "error" && terminal.reason === "aborted"); + assert.equal( + events.some((event) => event.type === "toolcall_end"), + false, + ); + await peerClosed.promise; +}); + +test("Cursor bridge preserves nested special JSON keys and rejects replayed invocation identities", async () => { + const special = JSON.parse( + '{"__proto__":{"unexpected":true},"constructor":"value"}', + ); + assert.deepEqual(decodeJsonValue(encodeJsonValue(special)), special); + assert.equal( + Object.getPrototypeOf(decodeJsonValue(encodeJsonValue(special))), + Object.prototype, + ); + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case === "runRequest") peer.write(mcpExec()); + }); + }); + servers.push(server); + const previous: AssistantMessage = { + role: "assistant", + api: MODEL.api, + provider: MODEL.provider, + model: MODEL.id, + timestamp: 0, + stopReason: "toolUse", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + content: [ + { + type: "toolCall", + id: "lookup-1", + name: "lookup", + arguments: { url: "https://example.test/page" }, + }, + ], + }; + const events = await collectEvents( + streamCursor( + localModel(server.baseUrl), + { + ...CONTEXT, + tools: [LOOKUP], + messages: [ + ...CONTEXT.messages, + previous, + { + role: "toolResult", + toolCallId: "lookup-1", + toolName: "lookup", + content: [], + isError: false, + timestamp: 0, + }, + ], + }, + { apiKey: "token" }, + ), + ); + assert.equal(events.at(-1)?.type, "error"); + assert.match(JSON.stringify(events.at(-1)), /already present in Pi history/); + assert.equal( + events.some((event) => event.type === "toolcall_end"), + false, + ); +}); diff --git a/tests/extensions/shared/child-session.test.ts b/tests/extensions/shared/child-session.test.ts index 4864fc9a..8084efd9 100644 --- a/tests/extensions/shared/child-session.test.ts +++ b/tests/extensions/shared/child-session.test.ts @@ -34,6 +34,7 @@ import { createChildResources, type DisposableChildSession, effectiveChildToolAllowlist, + inheritedChildToolAllowlist, resolveGitInfoPathOrThrow, resolveStandaloneChildProjectTrust, shutdownAndDisposeChildSession, @@ -1499,3 +1500,23 @@ test("git-info exclusion: ENOENT degrades, other errors fail closed", async () = "non-ENOENT realpath failures must fail closed", ); }); + +test("child delegation inherits active tools and custom restrictions only narrow", () => { + const parent = ["read", "bash", "web_search", "workflow", "subagent_spawn"]; + assert.deepEqual(inheritedChildToolAllowlist(parent), [ + "read", + "bash", + "web_search", + ]); + assert.deepEqual( + inheritedChildToolAllowlist(parent, [ + "read", + "rg", + "web_search", + "workflow", + ]), + ["read", "web_search"], + ); + assert.deepEqual(inheritedChildToolAllowlist(parent, []), []); + assert.deepEqual(inheritedChildToolAllowlist([], ["bash"]), []); +}); diff --git a/tests/extensions/subagents/agent-types.test.ts b/tests/extensions/subagents/agent-types.test.ts index 7cc3561a..38669c3e 100644 --- a/tests/extensions/subagents/agent-types.test.ts +++ b/tests/extensions/subagents/agent-types.test.ts @@ -64,7 +64,7 @@ async function seed( return { agentDir, cwd }; } -test("built-in roles have exact capability boundaries and no fixed model or effort defaults", () => { +test("built-in roles inherit the parent tool surface and no fixed model or effort defaults", () => { assert.deepEqual( BUILT_IN_AGENT_TYPES.map((role) => ({ name: role.name, @@ -75,73 +75,36 @@ test("built-in roles have exact capability boundaries and no fixed model or effo [ { name: "explorer", - tools: [ - "read", - "grep", - "find", - "ls", - "fd", - "rg", - "git_show", - "git_diff", - "git_log", - ], + tools: undefined, effort: undefined, model: undefined, }, { name: "implementer", - tools: [ - "read", - "bash", - "edit", - "write", - "grep", - "find", - "ls", - "fd", - "rg", - "git_show", - "git_diff", - "git_log", - ], + tools: undefined, effort: undefined, model: undefined, }, { name: "reviewer", - tools: [ - "read", - "grep", - "find", - "ls", - "fd", - "rg", - "git_show", - "git_diff", - "git_log", - ], + tools: undefined, effort: undefined, model: undefined, }, { name: "advisor", - tools: [ - "read", - "grep", - "find", - "ls", - "fd", - "rg", - "git_show", - "git_diff", - "git_log", - ], + tools: undefined, effort: undefined, model: undefined, }, ], ); + assert.deepEqual( + BUILT_IN_AGENT_TYPES.filter((role) => role.planningCompatible).map( + (role) => role.name, + ), + ["explorer", "reviewer", "advisor"], + ); assert.match( BUILT_IN_AGENT_TYPES[0]?.description ?? "", /moderate reasoning/, @@ -335,6 +298,7 @@ test("a project agent type overrides the global one of the same name", async () assert.equal(agentTypes.size, 4); assert.deepEqual(agentTypes.get("explorer")?.tools, ["read"]); + assert.equal(agentTypes.get("explorer")?.planningCompatible, undefined); // Global replaces the built-in, then the trusted project replaces global. const messages = diagnostics.map((entry) => entry.message).join("\n"); assert.match(messages, /from built-in:explorer/); diff --git a/tests/extensions/subagents/manager.test.ts b/tests/extensions/subagents/manager.test.ts index e0f7498e..5e70a9a8 100644 --- a/tests/extensions/subagents/manager.test.ts +++ b/tests/extensions/subagents/manager.test.ts @@ -496,114 +496,19 @@ test("a wait issued alongside a restart does not return the stale run", () => { }); }); -test("a run with no first response is settled by the watchdog and frees its slot", async () => { - await withManager( - async (manager, runtime) => { - const settled: Array<{ id: string; status: string; consumed: boolean }> = - []; - manager.view.setOnSettled((snap, consumed) => - settled.push({ id: snap.id, status: snap.status, consumed }), - ); - - // Fill every model slot with runs that accept the prompt but never - // emit a first assistant event (stalled provider requests). - const hung = await runTool( - runtime, - Effect.forEach( - [1, 2, 3, 4], - (n) => manager.spawn("pi", task(`HANG: stall ${n}`)), - { concurrency: "unbounded" }, - ), - ); - await assert.rejects( - runTool(runtime, manager.spawn("pi", task("Task 5"))), - /Max 4 subagent sessions/, - ); - - // The watchdog settles each hung run as an explicit error through the - // normal settle path (so waits and result delivery observe it). - await runTool(runtime, manager.waitFor(hung.map((snap) => snap.id))); - for (const snap of hung) { - const failed = manager.view.get(snap.id); - assert.equal(failed?.status, "error"); - assert.match( - failed?.errorText ?? "", - /no assistant response event.*provider request may be stalled/, - ); - } - assert.deepEqual( - settled.sort((a, b) => a.id.localeCompare(b.id)), - hung - .map((snap) => ({ - id: snap.id, - status: "error", - consumed: true, - })) - .sort((a, b) => a.id.localeCompare(b.id)), - ); - - // The freed slots accept new spawns again. - const fresh = await runTool( - runtime, - manager.spawn("pi", task("ok after the stall")), - ); - assert.equal(fresh.status, "running"); - await runTool(runtime, manager.waitFor([fresh.id])); - assert.equal(manager.view.get(fresh.id)?.status, "done"); - }, - { firstResponseTimeoutMs: 150 }, - ); -}); - -test("a first response clears the watchdog so slower runs are not killed", async () => { - await withManager( - async (manager, runtime) => { - // The stub streams its first assistant delta within one cadence - // (~40ms) but needs well over the watchdog budget to finish the whole - // turn; without clearing on first response it would be killed mid-run. - const snap = await runTool( - runtime, - manager.spawn("pi", task("Slow but responsive")), - ); - await runTool(runtime, manager.waitFor([snap.id])); - const done = manager.view.get(snap.id); - assert.equal(done?.status, "done"); - assert.match( - done?.finalText ?? "", - /\[stub:pi\] completed: Slow but responsive/, - ); - assert.equal(done?.errorText, undefined); - }, - { firstResponseTimeoutMs: 250 }, - ); -}); - -test("a restart whose run never starts is settled by the watchdog", async () => { - await withManager( - async (manager, runtime) => { - const snap = await runTool(runtime, manager.spawn("pi", task("First"))); - await runTool(runtime, manager.waitFor([snap.id])); - assert.equal(manager.view.get(snap.id)?.status, "done"); - - // The backend accepts the send but the new run never emits RunStarted, - // so the restarting entry holds its slot with nothing to clear it. - await runTool(runtime, manager.send(snap.id, "HANG: stalled restart")); - await runTool(runtime, manager.waitFor([snap.id])); - const after = manager.view.get(snap.id); - assert.equal(after?.status, "error"); - assert.match( - after?.errorText ?? "", - /no assistant response event.*provider request may be stalled/, - ); - - // The freed slot accepts a fresh spawn again. - const fresh = await runTool( - runtime, - manager.spawn("pi", task("ok after the stalled restart")), - ); - await runTool(runtime, manager.waitFor([fresh.id])); - assert.equal(manager.view.get(fresh.id)?.status, "done"); - }, - { firstResponseTimeoutMs: 150 }, - ); +test("responsive runs preserve their final outcome", async () => { + await withManager(async (manager, runtime) => { + const snap = await runTool( + runtime, + manager.spawn("pi", task("Slow but responsive")), + ); + await runTool(runtime, manager.waitFor([snap.id])); + const done = manager.view.get(snap.id); + assert.equal(done?.status, "done"); + assert.match( + done?.finalText ?? "", + /\[stub:pi\] completed: Slow but responsive/, + ); + assert.equal(done?.errorText, undefined); + }); }); diff --git a/tests/extensions/subagents/pi-backend-lifecycle.test.ts b/tests/extensions/subagents/pi-backend-lifecycle.test.ts index 307efea7..bd7c5d0b 100644 --- a/tests/extensions/subagents/pi-backend-lifecycle.test.ts +++ b/tests/extensions/subagents/pi-backend-lifecycle.test.ts @@ -126,18 +126,13 @@ function harnessFactory( return { factory, creations, harnesses }; } -function createManagerRuntime( - backend: SubagentBackend, - firstResponseTimeoutMs = 500, -) { +function createManagerRuntime(backend: SubagentBackend) { const registry = Layer.succeed( BackendRegistry, new Map([["pi", backend]]), ); return ManagedRuntime.make( - makeSubagentManagerLayer({ firstResponseTimeoutMs }).pipe( - Layer.provide(registry), - ), + makeSubagentManagerLayer().pipe(Layer.provide(registry)), ); } @@ -362,7 +357,7 @@ test("prompt rejection wins over an earlier agent_settled event", async () => { sessionFactory: fixtures.factory, shutdownTimeoutMs: 50, }); - const runtime = createManagerRuntime(backend, 10_000); + const runtime = createManagerRuntime(backend); try { const manager = await runtime.runPromise(SubagentManager); const spawned = await runtime.runPromise( @@ -400,7 +395,7 @@ test("a prompt that resolves without lifecycle events fails explicitly", async ( sessionFactory: fixtures.factory, shutdownTimeoutMs: 50, }); - const runtime = createManagerRuntime(backend, 10_000); + const runtime = createManagerRuntime(backend); try { const manager = await runtime.runPromise(SubagentManager); const spawned = await runtime.runPromise( @@ -1041,7 +1036,7 @@ test("a preflight that ignores abort is force-closed without reopening the sessi throw new Error("unsafe worktree cleanup should not run"); }, }); - const runtime = createManagerRuntime(backend, 10_000); + const runtime = createManagerRuntime(backend); try { const manager = await runtime.runPromise(SubagentManager); const spawned = await runtime.runPromise( @@ -1214,94 +1209,65 @@ test("prompt and tool cancellation ignore late completion and free capacity", as } }); -test("a silent provider is terminalized once, disposed, and releases all slots", async () => { - const never = new Promise(() => {}); - const fixtures = harnessFactory((_options, index) => { - if (index >= 4) return {}; - if (index % 2 === 0) return { shutdown: () => never }; - return { - shutdown: async () => { - throw new Error("shutdown fixture failed"); - }, - dispose: () => { - throw new Error("dispose fixture failed"); - }, - }; - }); - const backend = makePiBackend({ - sessionFactory: fixtures.factory, - shutdownTimeoutMs: 30, - }); - const runtime = createManagerRuntime(backend, 40); +test("quiet provider thinking past 45 seconds remains live and can complete", async (t) => { + const fixtures = harnessFactory(() => ({})); + const runtime = createManagerRuntime( + makePiBackend({ sessionFactory: fixtures.factory }), + ); try { const manager = await runtime.runPromise(SubagentManager); - let settlements = 0; - manager.view.setOnSettled(() => settlements++); - const stalled = await Promise.all( - [0, 1, 2, 3].map((index) => - runtime.runPromise(manager.spawn("pi", task(`silent ${index}`))), - ), - ); - - await assert.rejects( - runtime.runPromise(manager.spawn("pi", task("over capacity"))), - /Max 4 subagent sessions/, - ); - await runtime.runPromise( - manager.waitFor(stalled.map((snapshot) => snapshot.id)), - ); - for (const snapshot of stalled) { - const failed = manager.view.get(snapshot.id); - assert.equal(failed?.status, "error"); - assert.match(failed?.errorText ?? "", /no assistant response event/); - } - assert.equal(settlements, 4); - await waitFor( - () => - fixtures.harnesses.every((harness) => harness.calls.disposals === 1), - "silent sessions to be disposed despite cleanup failures", + t.mock.timers.enable({ apis: ["setTimeout"] }); + const spawned = await runtime.runPromise( + manager.spawn("pi", task("quiet thinking")), ); - - const late = fixtures.harnesses[0]; - assert.ok(late); - late.emitAssistant("late provider completion"); - late.emit({ type: "agent_settled" }); - late.resolvePrompt(); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.equal(settlements, 4); - assert.equal(manager.view.get(stalled[0]!.id)?.finalText, ""); - - const fresh = await runtime.runPromise( - manager.spawn("pi", task("fresh after watchdog")), + const harness = fixtures.harnesses[0]; + assert.ok(harness); + harness.emit({ type: "agent_start" }); + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(46_000); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(manager.view.get(spawned.id)?.status, "running"); + assert.equal(harness.calls.aborts, 0); + t.mock.timers.reset(); + harness.emitAssistant("completed after quiet thinking"); + harness.emit({ type: "agent_settled" }); + harness.resolvePrompt(); + await runtime.runPromise(manager.waitFor([spawned.id])); + assert.equal(manager.view.get(spawned.id)?.status, "done"); + assert.equal( + manager.view.get(spawned.id)?.finalText, + "completed after quiet thinking", ); - assert.equal(fresh.status, "running"); - const freshCancellation = runtime.runPromise(manager.cancel([fresh.id])); - const freshHarness = fixtures.harnesses[4]; - assert.ok(freshHarness); - await waitFor(() => freshHarness.calls.aborts === 1, "fresh run abort"); - freshHarness.emit({ type: "agent_settled" }); - freshHarness.resolvePrompt(); - await freshCancellation; } finally { + t.mock.timers.reset(); await runtime.dispose(); } }); -test("adapter scope cleanup is bounded across shutdown timeouts and failures", async () => { +test("adapter scope cleanup is bounded across shutdown timeouts and failures", async (t) => { const never = new Promise(() => {}); const timedOutFixtures = harnessFactory(() => ({ prompt: async () => {}, - shutdown: () => never, + shutdown: () => { + // Start the clock at the owned cleanup seam, after resource loading. + // Advancing the configured deadline verifies the bound without a wall- + // time assertion that also charges unrelated parallel-suite startup. + t.mock.timers.enable({ apis: ["setTimeout"] }); + setImmediate(() => t.mock.timers.tick(25)); + return never; + }, })); const timedOutBackend = makePiBackend({ sessionFactory: timedOutFixtures.factory, shutdownTimeoutMs: 25, }); - const timeoutStartedAt = Date.now(); - await Effect.runPromise( - Effect.scoped(timedOutBackend.spawn(task("timeout cleanup"))), - ); - assert.ok(Date.now() - timeoutStartedAt < 500); + try { + await Effect.runPromise( + Effect.scoped(timedOutBackend.spawn(task("timeout cleanup"))), + ); + } finally { + t.mock.timers.reset(); + } const timedOutHarness = timedOutFixtures.harnesses[0]; assert.ok(timedOutHarness); assert.equal(timedOutHarness.calls.shutdowns, 1); diff --git a/tests/extensions/subagents/prompt.test.ts b/tests/extensions/subagents/prompt.test.ts index a62df2bb..1b6e0fd3 100644 --- a/tests/extensions/subagents/prompt.test.ts +++ b/tests/extensions/subagents/prompt.test.ts @@ -77,6 +77,12 @@ test("the generated agent_type schema exposes a compact, enforced role index", ( assert.match(description, /implementer.*medium-high reasoning/); assert.match(description, /reviewer.*high reasoning/); assert.match(description, /advisor.*high reasoning/); + for (const name of ["explorer", "implementer", "reviewer", "advisor"]) { + assert.match( + description, + new RegExp(`${name}[^\\n]*\\[inherited-tools\\]`), + ); + } assert.doesNotMatch(description, /default reasoning_effort/); assert.match(description, /parent-only.*read-only/); assert.doesNotMatch(description, /only: read/); diff --git a/tests/extensions/workflows/execute.e2e.test.ts b/tests/extensions/workflows/execute.e2e.test.ts index 5841f17a..ac587aed 100644 --- a/tests/extensions/workflows/execute.e2e.test.ts +++ b/tests/extensions/workflows/execute.e2e.test.ts @@ -17,7 +17,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import test from "node:test"; import type { AgentSession, @@ -56,6 +56,14 @@ writeFileSync(join(repoDir, "fixture.txt"), "fixture\n"); git(["add", "."]); git(["commit", "-q", "-m", "fixture"]); +// Replay tests deliberately opt into a bounded custom role. Built-in roles +// now inherit tools and cannot promise a repository-only replay identity. +mkdirSync(join(agentDir, "agents"), { recursive: true }); +writeFileSync( + join(agentDir, "agents", "bounded-reviewer.md"), + "---\nname: bounded-reviewer\ndescription: Replay fixture\ntools: [read]\n---\nInspect the repository without modifications.\n", +); + const { default: workflows, __setWorkflowTestAgentSessionFactory, @@ -84,7 +92,7 @@ type SentMessage = { }; const tools = new Map(); -let activeTools: string[] = []; +let activeTools: string[] = ["read"]; const handlers = new Map< string, Array<(event: unknown, ctx: ExtensionContext) => unknown> @@ -476,7 +484,7 @@ test("interrupting an inline wait leaves the run stoppable and delivers one term { script: 'export const meta = { name: "interrupted-inline-wait" };\n' + - 'return await agent("wait for interruption", { agent_type: "reviewer" });', + 'return await agent("wait for interruption", { agent_type: "bounded-reviewer" });', wait: true, }, controller.signal, @@ -611,7 +619,7 @@ test("a settled launch card does not repaint while its detached run stays active { script: 'export const meta = { name: "detached-render" };\n' + - 'return await agent("wait for release", { agent_type: "reviewer" });', + 'return await agent("wait for release", { agent_type: "bounded-reviewer" });', }, undefined, undefined, @@ -812,7 +820,7 @@ test("cancelled detached delivery preserves aborted status after artifact persis { script: 'export const meta = { name: "cancelled-persistence-failure" };\n' + - 'return await agent("wait for cancellation", { agent_type: "reviewer" });', + 'return await agent("wait for cancellation", { agent_type: "bounded-reviewer" });', background: true, }, undefined, @@ -959,7 +967,7 @@ test("agent calls run through the injected session factory and resume replays th const agentScript = 'export const meta = { name: "agent-run" };\n' + - 'const r = await agent("say something", { agent_type: "reviewer", label: "speaker" });\n' + + 'const r = await agent("say something", { agent_type: "bounded-reviewer", label: "speaker" });\n' + 'log("agent said: " + r.output);\n' + "return { ok: r.ok, output: r.output };"; @@ -1142,7 +1150,7 @@ async function runTamperedAcceptanceReplay( const fixtureId = ++replayAcceptanceFixtureId; const script = `export const meta = { name: "acceptance-replay-${fixtureId}" };\n` + - 'const r = await agent("verify the fixture", { agent_type: "reviewer", acceptance: { criteria: [{ id: "tests", description: "Focused tests pass", requiredEvidence: ["command"] }] } });\n' + + 'const r = await agent("verify the fixture", { agent_type: "bounded-reviewer", acceptance: { criteria: [{ id: "tests", description: "Focused tests pass", requiredEvidence: ["command"] }] } });\n' + "return { ok: r.ok, error: r.error, acceptanceWarning: r.acceptanceWarning };"; try { @@ -1294,8 +1302,8 @@ test("structured agent results survive handoff refs and downstream inputs", asyn { script: 'export const meta = { name: "structured-handoff" };\n' + - 'const first = await agent("produce a verdict", { agent_type: "reviewer", schema: { type: "object", properties: { verdict: { type: "string" }, score: { type: "number" } }, required: ["verdict", "score"] } });\n' + - 'const second = await agent("consume the upstream verdict", { agent_type: "reviewer", inputs: [first.ref] });\n' + + 'const first = await agent("produce a verdict", { agent_type: "bounded-reviewer", schema: { type: "object", properties: { verdict: { type: "string" }, score: { type: "number" } }, required: ["verdict", "score"] } });\n' + + 'const second = await agent("consume the upstream verdict", { agent_type: "bounded-reviewer", inputs: [first.ref] });\n' + "return { firstOk: first.ok, firstRef: first.ref ?? null, secondOk: second.ok, secondOutput: second.output };", wait: true, }, @@ -1389,7 +1397,7 @@ test("oversized authoritative agent results fail without a success record", asyn { script: 'export const meta = { name: "oversized-agent-result" };\n' + - 'const r = await agent("return a large fixture", { agent_type: "reviewer", schema: { type: "object", properties: { blob: { type: "string" } }, required: ["blob"] } });\n' + + 'const r = await agent("return a large fixture", { agent_type: "bounded-reviewer", schema: { type: "object", properties: { blob: { type: "string" } }, required: ["blob"] } });\n' + "return { ok: r.ok, error: r.error };", wait: true, }, @@ -1434,7 +1442,7 @@ test("an oversized legacy replay is rejected without a success record", async () __setWorkflowTestAgentSessionFactory(factory); const script = 'export const meta = { name: "oversized-replay" };\n' + - 'const r = await agent("replay fixture", { agent_type: "reviewer" });\n' + + 'const r = await agent("replay fixture", { agent_type: "bounded-reviewer" });\n' + "return { ok: r.ok, error: r.error };"; try { @@ -1510,7 +1518,7 @@ test("extension retention stays bounded and reports evictions under settled-run }); const script = 'export const meta = { name: "retention-pressure" };\n' + - 'const r = await agent("pressure fixture", { agent_type: "reviewer", label: "pressure-agent" });\n' + + 'const r = await agent("pressure fixture", { agent_type: "bounded-reviewer", label: "pressure-agent" });\n' + 'log("pressure log: " + r.output);\n' + "return { ok: r.ok, output: r.output };"; @@ -1572,6 +1580,8 @@ test("extension retention stays bounded and reports evictions under settled-run }); test("forced settlement persists worktree cleanup that finishes later", async () => { + const selectedRepo = join(agentDir, "worktree-source"); + execFileSync("git", ["clone", "--quiet", repoDir, selectedRepo]); modelIdle = true; for (const handler of handlers.get("agent_settled") ?? []) { await handler({}, ctx); @@ -1610,6 +1620,7 @@ test("forced settlement persists worktree cleanup that finishes later", async () activeRun = run; }, async reclaimWorktree(repoCwd, worktree) { + assert.equal(repoCwd, selectedRepo); markCleanupStarted(); await cleanupGate; actualCleanup = await reclaimWorktree(repoCwd, worktree); @@ -1623,7 +1634,9 @@ test("forced settlement persists worktree cleanup that finishes later", async () { script: 'export const meta = { name: "forced-worktree-cleanup" };\n' + - 'await agent("finish in isolation", { agent_type: "reviewer", isolation: "worktree" });\n' + + 'await agent("finish in isolation", { agent_type: "bounded-reviewer", isolation: "worktree", working_dir: ' + + JSON.stringify(selectedRepo) + + " });\n" + "return true;", background: true, }, @@ -1709,3 +1722,84 @@ test.after(() => { rmSync(agentDir, { recursive: true, force: true }); rmSync(repoDir, { recursive: true, force: true }); }); + +test("built-in Workflow children inherit active shell/network tools in the selected cwd without replay", async () => { + const previousTools = [...activeTools]; + activeTools = [...activeTools, "bash", "fixture_network", "subagent_spawn"]; + const alternate = join(agentDir, "alternate"); + const targetExtensions = join(alternate, ".pi", "extensions"); + mkdirSync(targetExtensions, { recursive: true }); + const targetExtension = join(targetExtensions, "target.ts"); + writeFileSync( + targetExtension, + 'export default function () { throw new Error("untrusted target extension loaded"); }', + ); + const calls: Array<{ cwd: string; tools: readonly string[] | undefined }> = + []; + __setWorkflowTestAgentSessionFactory(async (options) => { + assert.ok(options); + assert.ok(options.resourceLoader); + assert.equal( + options.resourceLoader + .getExtensions() + .errors.some((error) => + JSON.stringify(error).includes("untrusted target extension"), + ), + false, + ); + assert.equal( + options.resourceLoader + .getExtensions() + .extensions.some((extension) => extension.path === targetExtension), + false, + ); + calls.push({ cwd: options.cwd!, tools: options.tools }); + const session = fakeAgentSession("alternate output"); + session.getActiveToolNames = () => ["read", "bash", "fixture_network"]; + session.getAllTools = () => + ["read", "bash", "fixture_network"].map((name) => ({ + name, + })) as ReturnType; + return { session }; + }); + const script = `return await agent("inspect", { agent_type: "explorer", working_dir: ${JSON.stringify(relative(repoDir, alternate))} });`; + try { + const first = (await workflow.execute( + "cwd-inherit", + { script, wait: true }, + undefined, + undefined, + { ...ctx, isProjectTrusted: () => true }, + )) as { details: { runId: string } }; + assert.equal(calls[0]?.cwd, alternate); + assert.deepEqual(calls[0]?.tools, ["read", "bash", "fixture_network"]); + assert.equal( + existsSync(join(runDirFor(first.details.runId), "journal.json")), + false, + ); + await workflow.execute( + "cwd-inherit-resume", + { script, wait: true, resume_from_run_id: first.details.runId }, + undefined, + undefined, + { ...ctx, isProjectTrusted: () => true }, + ); + assert.equal(calls.length, 2); + for (const working_dir of ["", 42, join(alternate, "missing")]) { + await workflow.execute( + "cwd-invalid", + { + script: `return await agent("inspect", { working_dir: ${JSON.stringify(working_dir)} });`, + wait: true, + }, + undefined, + undefined, + { ...ctx, isProjectTrusted: () => true }, + ); + } + assert.equal(calls.length, 2, "invalid cwd must not create child sessions"); + } finally { + activeTools = previousTools; + __setWorkflowTestAgentSessionFactory(undefined); + } +}); diff --git a/tests/extensions/workflows/runner.test.ts b/tests/extensions/workflows/runner.test.ts index caa3a4f1..2bdf2854 100644 --- a/tests/extensions/workflows/runner.test.ts +++ b/tests/extensions/workflows/runner.test.ts @@ -15,11 +15,9 @@ import { import { Type } from "typebox"; import { agentFailureMessage, - createModelProgressWatchdog, guardWorkflowChildTools, observeAssistantSettlement, recordToolExecutionTiming, - resolveModelProgressTimeoutMs, runAgent, type ToolExecutionTiming, transcriptFromMessages, @@ -860,7 +858,68 @@ test("cancel during a hanging tool ignores late events and progress writers", as prompt.resolve(); }); -test("slow preflight does not arm the provider-turn watchdog", async () => { +test("silent provider turns can complete after the former 45 second cutoff", async (t) => { + const prompt = deferred(); + const harness = runnerHarness({ prompt: () => prompt.promise }); + const pending = runHarnessAgent(harness); + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.enable({ apis: ["setTimeout"] }); + try { + harness.emit({ type: "turn_start" }); + t.mock.timers.tick(46_000); + assert.equal(harness.aborts(), 0); + const completed = assistantTextMessage( + "provider completed after quiet thinking", + ); + harness.messages.push(completed); + harness.emit({ type: "message_end", message: completed }); + prompt.resolve(); + const outcome = await pending; + assert.equal(outcome.ok, true); + assert.equal(outcome.output, "provider completed after quiet thinking"); + } finally { + t.mock.timers.reset(); + prompt.resolve(); + await pending; + } +}); + +test("later quiet turns retain Pi transport failures instead of manufacturing a timeout", async (t) => { + const prompt = deferred(); + const harness = runnerHarness({ prompt: () => prompt.promise }); + const pending = runHarnessAgent(harness); + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.enable({ apis: ["setTimeout"] }); + try { + const first = assistantTextMessage("earlier evidence"); + harness.emit({ type: "turn_start" }); + harness.messages.push(first); + harness.emit({ type: "message_end", message: first }); + harness.emit({ type: "turn_start" }); + t.mock.timers.tick(60_000); + assert.equal(harness.aborts(), 0); + const failure = { + ...assistantTextMessage(""), + stopReason: "error" as const, + errorMessage: + "Provider HTTP 429: account rate limit after native retries", + }; + harness.messages.push(failure); + harness.emit({ type: "message_end", message: failure }); + prompt.resolve(); + const outcome = await pending; + assert.equal(outcome.ok, false); + assert.equal(outcome.aborted, false); + assert.equal(outcome.error, failure.errorMessage); + assert.match(outcome.output, /earlier evidence/); + } finally { + t.mock.timers.reset(); + prompt.resolve(); + await pending; + } +}); + +test("slow preflight can complete before the first provider turn", async () => { let run = async () => {}; const harness = runnerHarness({ prompt: () => run() }); run = async () => { @@ -871,104 +930,14 @@ test("slow preflight does not arm the provider-turn watchdog", async () => { harness.emit({ type: "message_end", message: completed }); }; - const outcome = await runHarnessAgent(harness, { - modelProgressTimeoutMs: 10, - }); + const outcome = await runHarnessAgent(harness); assert.equal(outcome.ok, true); assert.equal(outcome.output, "completed after preflight"); assert.equal(harness.aborts(), 0); }); -test("a later provider turn with no visible progress is aborted and cannot become success", async () => { - const prompt = deferred(); - let emitAbort = () => {}; - const harness = runnerHarness({ - prompt: () => prompt.promise, - abort: async () => emitAbort(), - }); - emitAbort = () => { - const aborted = { - ...assistantTextMessage(""), - content: [], - stopReason: "aborted" as const, - errorMessage: "Request was aborted", - }; - harness.messages.push(aborted); - harness.emit({ type: "message_end", message: aborted }); - }; - const outcomePromise = runHarnessAgent(harness, { - modelProgressTimeoutMs: 10, - shutdownTimeoutMs: 20, - }); - - await new Promise((resolve) => setImmediate(resolve)); - const first = { - ...assistantTextMessage("first turn evidence"), - content: [ - { type: "text" as const, text: "first turn evidence" }, - { - type: "toolCall" as const, - id: "read-1", - name: "read", - arguments: { path: "fixture.txt" }, - }, - ], - stopReason: "toolUse" as const, - }; - const result = { - role: "toolResult" as const, - toolCallId: "read-1", - toolName: "read", - content: [{ type: "text" as const, text: "retained tool evidence" }], - isError: false, - timestamp: 1_100, - }; - harness.emit({ type: "turn_start" }); - harness.messages.push(first); - harness.emit({ type: "message_start", message: first }); - harness.emit({ type: "message_end", message: first }); - harness.emit({ - type: "tool_execution_start", - toolCallId: "read-1", - toolName: "read", - args: { path: "fixture.txt" }, - }); - harness.messages.push(result); - harness.emit({ - type: "tool_execution_end", - toolCallId: "read-1", - toolName: "read", - result, - isError: false, - }); - harness.emit({ type: "turn_end", message: first, toolResults: [result] }); - harness.emit({ type: "turn_start" }); - harness.emit({ type: "message_start", message: assistantTextMessage("") }); - - const outcome = await settleWithin(outcomePromise); - const late = assistantTextMessage("late success"); - harness.messages.push(late); - harness.emit({ type: "message_end", message: late }); - prompt.resolve(); - - assert.equal(outcome.ok, false); - assert.equal(outcome.aborted, false); - assert.match(outcome.error ?? "", /no model-visible progress/i); - assert.equal(outcome.output, "first turn evidence"); - assert.equal(outcome.usage.turns, 2); - assert.equal( - outcome.transcript.some( - (entry) => - entry.role === "toolResult" && entry.text === "retained tool evidence", - ), - true, - ); - assert.equal(harness.aborts(), 1); - assert.equal(harness.disposals(), 1); -}); - -test("model-visible deltas extend a provider turn until assistant completion", async () => { +test("model-visible deltas preserve assistant completion", async () => { let run = async () => {}; const harness = runnerHarness({ prompt: () => run() }); run = async () => { @@ -993,9 +962,7 @@ test("model-visible deltas extend a provider turn until assistant completion", a harness.emit({ type: "message_end", message: completed }); }; - const outcome = await runHarnessAgent(harness, { - modelProgressTimeoutMs: 30, - }); + const outcome = await runHarnessAgent(harness); assert.equal(outcome.ok, true); assert.equal(outcome.output, "done"); @@ -1010,83 +977,6 @@ test("cleanup timeout is surfaced instead of reporting agent success", async () assert.equal(harness.disposals(), 1); }); -test("model-progress watchdog aborts a silent provider turn", async () => { - let aborted = false; - const watchdog = createModelProgressWatchdog( - async () => { - aborted = true; - }, - { timeoutMs: 10, model: "fixture-model" }, - ); - watchdog.armTurn(); - - await assert.rejects( - watchdog.waitFor(new Promise(() => {})), - /provider turn for fixture-model.*no model-visible progress for 10 ms.*stalled/i, - ); - assert.equal(aborted, true); -}); - -test("model-progress timeout preserves the default and honors wider Pi idle settings", () => { - assert.equal( - resolveModelProgressTimeoutMs(SettingsManager.inMemory()), - 45_000, - ); - assert.equal( - resolveModelProgressTimeoutMs( - SettingsManager.inMemory({ httpIdleTimeoutMs: 120_000 }), - ), - 120_000, - ); - assert.equal( - resolveModelProgressTimeoutMs( - SettingsManager.inMemory({ httpIdleTimeoutMs: 120_000 }), - 5, - ), - 5, - ); -}); - -test("model progress refreshes its turn while completion leaves tool time unrestricted", async () => { - let timedOut = false; - const watchdog = createModelProgressWatchdog( - async () => { - timedOut = true; - }, - { timeoutMs: 30 }, - ); - watchdog.armTurn(); - - const result = await watchdog.waitFor( - (async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - watchdog.markProgress(); - await new Promise((resolve) => setTimeout(resolve, 20)); - watchdog.completeTurn(); - await new Promise((resolve) => setTimeout(resolve, 40)); - return "done"; - })(), - ); - assert.equal(result, "done"); - assert.equal(timedOut, false); -}); - -test("explicit watchdog cancellation disarms a pending operation", async () => { - let timedOut = false; - const watchdog = createModelProgressWatchdog( - async () => { - timedOut = true; - }, - { timeoutMs: 5 }, - ); - watchdog.armTurn(); - void watchdog.waitFor(new Promise(() => {})); - watchdog.cancel(); - - await new Promise((resolve) => setTimeout(resolve, 15)); - assert.equal(timedOut, false); -}); - test("structured role children keep their terminating tool without widening capabilities", () => { assert.deepEqual(workflowChildTools(["read", "rg"], true), [ "read", diff --git a/tests/extensions/workflows/sandbox.test.ts b/tests/extensions/workflows/sandbox.test.ts index 673dac1d..049bb971 100644 --- a/tests/extensions/workflows/sandbox.test.ts +++ b/tests/extensions/workflows/sandbox.test.ts @@ -701,3 +701,17 @@ test("replayed calls raise the backstop instead of killing a resumed run", async /exceeded its agent request budget/, ); }); + +test("sandbox preserves an explicit working_dir for child admission", async () => { + let selected: unknown; + await run( + 'return await agent("inspect", { working_dir: "../other-project" });', + { + onAgent: async (_prompt, options) => { + selected = options.working_dir; + return { ok: true, output: "done" }; + }, + }, + ); + assert.equal(selected, "../other-project"); +}); diff --git a/tests/support/subagents-stub.ts b/tests/support/subagents-stub.ts index 451ca6f0..7b911e9b 100644 --- a/tests/support/subagents-stub.ts +++ b/tests/support/subagents-stub.ts @@ -11,7 +11,7 @@ * - fails the run when the prompt starts with "FAIL:", and refuses to spawn * at all when it starts with "SPAWNFAIL:" (error-path testing); * - hangs the run after RunStarted without any assistant event when the - * prompt starts with "HANG:" (first-response watchdog testing); + * prompt starts with "HANG:" (cancellation and stalled-provider testing); * - appends every event to a JSONL "session file" in tmpdir so the * "full transcript in session file" pointers resolve. */ From c3809e1f5c39d736054694724dce69a1099fe49d Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 7 Sep 2026 01:05:34 +0800 Subject: [PATCH 2/2] docs: link child reliability repair receipt --- docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md b/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md index bbc92e29..ef21b0c2 100644 --- a/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md +++ b/docs/research/WORKFLOW_CHILD_FAILURES_2026-09-07.md @@ -5,6 +5,7 @@ - Observed runtime: `c8f2c13d49f2e6cd3b389dfff72ccc2eaca970c1`, the unique OpenPI source returned by `pi list` - Repair base: `a7455cd378ef7befa9c7cf099c1fa1e4ee5dc3e3` - Issue: [#424](https://github.com/openpi-dev/openpi/issues/424) +- Repair PR: [#426](https://github.com/openpi-dev/openpi/pull/426) - Related performance repair: [#420](https://github.com/openpi-dev/openpi/issues/420), [PR #422](https://github.com/openpi-dev/openpi/pull/422) - Supersedes: none