diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 94186ce18..ae814abd1 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -64,7 +64,7 @@ model can choose the right profile. ### `provider` -Required built-in provider id: +Required provider instance id. The default instance ids are: ```yaml provider: codex @@ -76,22 +76,29 @@ provider: copilot provider: grok ``` -Unsupported or custom providers are rejected. DevSpace maps providers to their -native integration: +Profiles may also reference named provider instances configured under +`subagents.providers`, such as `provider: codex-work`. Provider instances map +to one of these built-in driver integrations: - `codex`: the host-installed `codex app-server` command - `claude`: Claude Code SDK -- `opencode`: OpenCode SDK -- `pi`: the installed Pi coding-agent SDK, one in-process session per DevSpace agent -- `cursor`: ACP -- `copilot`: ACP -- `grok`: Grok Build ACP (`grok agent stdio`) - -Codex is resolved from the user's environment rather than bundled with -DevSpace. Run `codex login` normally before using it; set `CODEX_COMMAND` when -the executable is not on the normal PATH. OpenCode, Cursor, and Copilot -runtimes are started and reused by the daemon internally, while Pi is embedded -through its Node SDK. +- `opencode`: the host-installed OpenCode CLI; DevSpace probes its major version + and uses the matching 1.x or 2.x protocol internally +- `pi`: Pi's normal `--mode rpc` CLI process. DevSpace preserves Pi's native + auth, settings, extensions, skills, context files, and session store. +- `acp`: generic Agent Client Protocol integration. Cursor, Copilot, and Grok + are default provider instances using this driver; other ACP agents can be + configured without adding a DevSpace driver. + +Codex and OpenCode are resolved from the user's environment rather than bundled +as executables. Run their normal login/setup flows before using them; set +`CODEX_COMMAND` when Codex is not on the normal PATH. OpenCode is discovered as +`opencode`, and DevSpace automatically routes OpenCode 1.x and 2.x to their +matching runtime implementations. OpenCode, Cursor, and Copilot runtimes are +started and reused by the daemon internally. Pi is launched in RPC mode and +uses its native persisted session file as DevSpace's continuation identity; +`PI_COMMAND` selects another Pi executable (otherwise `pi` on `PATH`, then the +bundled package). Pi 0.80.5 or newer is required for the `agent_settled` event. ### `model` @@ -118,10 +125,10 @@ DevSpace passes this through to providers that expose a matching control: - `claude`: SDK effort with adaptive thinking. - `codex`: app-server model reasoning effort. -- `pi`: the AgentSession thinking-level control. +- `pi`: RPC `set_thinking_level`. - `opencode`: model variant. -- `cursor` and `copilot`: ACP thought-level config when supported. -- `grok`: `--reasoning-effort` on startup and xAI's ACP model metadata for resumed sessions. +- ACP: standard model/thought-level config when advertised by the agent. The + Grok flavor also understands xAI's typed model metadata and startup effort. ### `disabled` @@ -154,6 +161,7 @@ devspace agents run "" devspace agents continue "" devspace agents show devspace agents wait ... +devspace agents events [--follow] ``` The commands do not add a document-level wrapper. `targets`, `ls`, and `wait` @@ -163,9 +171,9 @@ model-facing result small: ```xml Read-only code review. - -Review complete. -Provider disconnected. + +Review complete. +Claude agent turn failed.Claude returned an error result: usage limit reached Subagent not found. ``` @@ -175,9 +183,30 @@ agents finish. With `--timeout `, it returns each unique agent in first-seen order and marks unfinished work with `status="running" wait="timeout"`. +Every turn has a DevSpace-owned `turn` id. A failed turn includes the +provider's own explanation in ``, redacted and truncated. + +`events` prints the current turn's activity as normalized fragments, and +`--follow` keeps printing until the turn ends. `--turn ` selects an +earlier turn: + +```xml + +git status --short +git status --short +The tree is clean. + +``` + +Providers that cannot stream (currently OpenCode) report only `turn_started` +and `turn_completed`. The daemon keeps at most 500 events and 256 KiB per turn, +keeps events for the 20 most recent turns of each agent, truncates messages and +tool summaries, and redacts common credential shapes. Tool summaries carry the +identifying argument (a command, path, or query), never the full input or output. + `--json` remains available for scripts that need it, but the bundled skill does -not request it. Internal turn records, prompts, provider session IDs, workspace -paths, and timestamps are absent from both output formats. +not request it. Prompts, provider session IDs, and workspace paths are absent +from both output formats. `open_workspace` exposes compact profile metadata: diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..cfadaefa4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,6 +149,27 @@ Subagent providers are explicit. Omitted providers are disabled: } ``` +`id` is the provider instance id used by profiles and `devspace agents run`. +For the default built-in instance ids, `driver` is optional and defaults to the +same value. Named instances declare the driver explicitly, which allows +multiple independently configured instances of one integration: + +```jsonc +{ + "id": "codex-work", + "driver": "codex", + "enabled": true, + "env": { + "CODEX_HOME": "/home/alice/.codex-work", + }, +} +``` + +Profiles can then select that instance with `provider: codex-work`. The driver +is the implementation (`codex`, `claude`, `opencode`, `pi`, or `acp`); +provider-native session ids remain internal to DevSpace. Cursor, Copilot, and +Grok are default ACP provider instances rather than separate drivers. + `subagents.instructions` controls when ChatGPT receives the managed workflow: | Value | Behavior | @@ -171,15 +192,42 @@ does not expand `$NAME` references in these values. All subagent providers accept `env`. The daemon inherits its startup environment, then overlays the provider's `env` without mutating the daemon's process environment. OpenCode receives that environment on its managed server -process; embedded Pi scopes it to its provider requests and command execution. +process; Pi receives it on its managed RPC process. + +The `opencode` driver runs `opencode --version` before creating its first +runtime and automatically selects the OpenCode 1.x or 2.x protocol. Both major +versions use the same provider instance and profile configuration; `opencode2` +is not a separate driver. + +Codex, Claude, Pi, and ACP instances also accept `command`. OpenCode does not +expose a command override. Cursor, Copilot, and Grok retain their built-in ACP +commands when `command` is omitted. An arbitrary ACP instance declares its +executable and may provide fixed startup arguments under `config.args`: + +```jsonc +{ + "id": "kiro", + "driver": "acp", + "enabled": true, + "command": "kiro", + "config": { "args": ["acp"] } +} +``` + +`config.flavor` is reserved for the small built-in protocol quirks currently +needed by `cursor`, `copilot`, and `grok`; generic ACP agents normally omit it. +Those built-in flavors derive their sandbox arguments from the write mode, so +they reject `config.args`. DevSpace cannot enforce `read_only` or `allowed` on an +arbitrary ACP agent, so generic ACP instances run only with `full_access`. -Codex, Claude, Cursor, Copilot, and Grok also accept `command`. OpenCode and Pi -do not expose a command override. For providers that support it, an explicit -`command` wins over both the inherited command override and a command override -placed in `env`. +Pi runs as the normal CLI in `--mode rpc`, so user-installed extensions, +skills, context files, auth, models, and settings load normally. Restricted +DevSpace modes inject a sandbox extension and pin Pi's active built-in tools to +the matching read-only/workspace-safe set; `full_access` leaves Pi's normal +tool configuration untouched. Existing process-level overrides remain supported: `CODEX_COMMAND`, -`CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, +`CODEX_HOME`, `CLAUDE_COMMAND`, `PI_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. Provider configuration takes precedence where the same value is set in both places. diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index f940cfa21..099bcb850 100644 --- a/docs/local-agent-daemon.md +++ b/docs/local-agent-daemon.md @@ -22,6 +22,11 @@ functionality, but `devspace serve` is not required for local-agent execution. The daemon is scoped to one DevSpace `stateDir`, so one SQLite store and one runtime owner serve all clients using that configuration. +Experimental persisted workflows (`DEVSPACE_EXPERIMENTAL_WORKFLOWS=1`) use the same daemon and local agent manager. Active +workflows keep it alive after the submitting CLI exits. Startup reconciles +agent turns before workflow records; shutdown closes workflow supervisors +before the agent manager. + Communication uses a private Unix domain socket on Linux/macOS or a named pipe on Windows. The endpoint is not exposed through the public MCP HTTP port. Provider session identifiers and logical agent records are durable; live diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 000000000..17527319d --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,64 @@ +# Workflows + +> **Experimental and hidden.** Workflows ship disabled in v1.1. Set `DEVSPACE_EXPERIMENTAL_WORKFLOWS=1` in the environment of both the CLI and the daemon (run `devspace agents daemon stop` first if a daemon is already running) to enable the `devspace workflow` commands and the managed `workflows` skill. There is no config-file option and no MCP tool for workflows yet. + +DevSpace workflows run persisted multi-agent plans through the same on-demand daemon as local subagents. A CLI process submits a run and exits immediately; the daemon remains alive until active workflows finish. + +Place reusable workflow scripts in `.devspace/workflows/` and run them by name, or provide a file directly: + +```js +export const meta = { name: "review", concurrency: 2 }; + +const findings = await agent("Review the current changes", { + target: "reviewer", + writeMode: "read_only", +}); +return { findings }; +``` + +The script body receives `args` plus the `agent`, `workflow`, `parallel`, `pipeline`, `phase`, and `log` helpers. It must return a JSON value. `agent(prompt, options)` requires a configured profile or provider in `options.target`; `workflow(name, args)` may call one named workflow, with nesting limited to one level. + +`parallel(items, worker, concurrency)` returns one outcome per input instead of failing the whole batch when one worker throws. `pipeline(items, ...stages)` passes each raw item through every stage in order; each stage receives `(currentValue, originalItem, index)`. `phase(name, run)` records durable start, completion, and failure events and gives `run` an agent helper that labels its calls with that phase. + +Run the script by name or provide a file directly: + +```bash +devspace workflow run --name review --args '{"base":"main"}' +devspace workflow run --file ./review.workflow.js --args-file ./inputs.json +``` + +Exactly one of `--file`, `--name`, or `--resume` is required. `--file` reads the source before contacting the daemon. Named workflows are resolved inside the current workspace. Runs default to `read_only`; `--write-mode allowed` permits workflow calls configured for writes. + +`run` returns an asynchronous receipt. Use the returned workflow ID with: + +```bash +devspace workflow status +devspace workflow wait --timeout 60 +devspace workflow calls +devspace workflow call +devspace workflow events --after --json +devspace workflow cancel +devspace workflow ls +``` + +`wait` accepts a timeout from 0 to 60 seconds and returns the latest durable run state on timeout. `calls` returns compact call summaries without prompts, results, or fingerprints. `call` returns the full stored record for one zero-based call index. `events` returns up to 100 persisted log and phase entries after the supplied sequence. All commands accept `--json` for scripts. + +Workflow records, calls, and provider continuation state survive CLI exits and daemon restarts. A daemon restart marks unfinished workflows interrupted for explicit inspection or resume; it does not dispatch calls automatically. At startup the daemon first reconciles local agent turns, then workflow runs. During shutdown it closes workflows before the local agent manager so supervisors can stop their child turns cleanly. Both phases share one shutdown budget, and workflows get at most half of it so the agent manager always gets time to close. + +Named workflows and their arguments are workspace scoped. IDs from another checkout, workspace, or managed worktree are rejected even when the caller can read the shared DevSpace state directory. + +## Bounds + +Workflow metadata may set `concurrency` from 1 to 16; the default is 4. The daemon admits at most eight active workflows and eight aggregate workflow agent turns, with at most sixteen script runners including nested scripts. One run may make at most 128 agent turns, including schema-correction turns, and may invoke at most eight nested workflows. Nesting is limited to one level and shares the parent run's admission, cancellation, and call budgets. + +Scripts run in QuickJS with a 32 MiB heap, 512 KiB stack, and 15-minute wall clock limit. Source is limited to 64 KiB, arguments to 128 KiB, and workflow or agent results to 256 KiB. Guest `log()` and `phase()` events share a 64 KiB limit per run; bounded internal events such as agent turns and nested workflow source snapshots are additional. These are hard product boundaries rather than tuning controls. + +An agent call may include an inline JSON Schema in `options.schema`. DevSpace compiles strict draft-07 structural schemas with at most 16 KiB, 512 nodes, and 16 levels. It rejects `$ref`, `$dynamicRef`, `$recursiveRef`, `$async`, `pattern`, `patternProperties`, and `format`. Invalid output gets one read-only correction in the same agent session; the correction counts toward the 128-turn budget. + +## Resume and worktrees + +Resume reuses only a matching prefix of completed read-only calls. Any call with write authority, isolation, or a named workflow workspace disables replay for the run. DevSpace also hashes the workspace context before the original and resumed runs: `HEAD`, the staged diff, and every file Git tracks or would add (`git ls-files --cached --others --exclude-standard`). Ignored files such as `node_modules` or build output are not part of the context, so changing them does not block resume. More than 20,000 entries, more than 64 MiB of files, any symlink, special file, unreadable path, or other hash failure produces `RECOVERY_CONTEXT_CHANGED`. + +An isolated call creates a managed worktree and records it as a workspace. DevSpace retains those worktrees after the run for inspection. Normal stale-worktree cleanup preserves recovery snapshots for commits and dirty tracked changes and skips worktrees with untracked files. + +Profiles are resolved in each call’s execution workspace. For isolated worktrees, use global profiles or commit workspace profiles so they exist in the new worktree. A new worktree starts at the source’s committed `HEAD`; uncommitted source changes are not copied. diff --git a/package.json b/package.json index fa39fb46c..b342f07a9 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,15 @@ "@anthropic-ai/claude-agent-sdk": "0.3.200", "@anthropic-ai/sandbox-runtime": "0.0.71", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.3", + "@earendil-works/pi-coding-agent": "^0.80.10", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "1.17.13", + "@opencode/client": "2.0.18", "@pierre/diffs": "^1.3.6", + "ajv": "^8.20.0", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", "cross-spawn": "^7.0.6", @@ -66,8 +68,9 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "jsonc-parser": "^3.3.1", - "koffi": "^3.1.2", + "koffi": "3.2.1", "lucide": "^1.24.0", + "quickjs-emscripten": "^0.32.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca7f368..fd9d71f5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.5.1 version: 1.5.1 '@earendil-works/pi-coding-agent': - specifier: ^0.80.3 - version: 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.10 + version: 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@modelcontextprotocol/ext-apps': specifier: ^1.7.2 version: 1.7.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) @@ -38,9 +38,15 @@ importers: '@opencode-ai/sdk': specifier: 1.17.13 version: 1.17.13 + '@opencode/client': + specifier: 2.0.18 + version: 2.0.18(effect@4.0.0-rc.112) '@pierre/diffs': specifier: ^1.3.6 version: 1.3.6(@shikijs/themes@3.23.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + ajv: + specifier: ^8.20.0 + version: 8.20.0 better-result: specifier: ^2.10.0 version: 2.10.0 @@ -63,11 +69,14 @@ importers: specifier: ^3.3.1 version: 3.3.1 koffi: - specifier: ^3.1.2 + specifier: 3.2.1 version: 3.2.1 lucide: specifier: ^1.24.0 version: 1.24.0 + quickjs-emscripten: + specifier: ^0.32.0 + version: 0.32.0 react: specifier: ^19.2.6 version: 19.2.6 @@ -311,22 +320,22 @@ packages: resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==} engines: {node: '>= 20.12.0'} - '@earendil-works/pi-agent-core@0.80.3': - resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + '@earendil-works/pi-agent-core@0.80.10': + resolution: {integrity: sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.3': - resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + '@earendil-works/pi-ai@0.80.10': + resolution: {integrity: sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.3': - resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + '@earendil-works/pi-coding-agent@0.80.10': + resolution: {integrity: sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-tui@0.80.10': + resolution: {integrity: sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -509,6 +518,21 @@ packages: peerDependencies: hono: ^4 + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@koromix/koffi-android-arm64@3.2.1': resolution: {integrity: sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==} cpu: [arm64] @@ -717,6 +741,36 @@ packages: resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} engines: {node: '>=20'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -729,6 +783,23 @@ packages: '@opencode-ai/sdk@1.17.13': resolution: {integrity: sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==} + '@opencode/client@2.0.18': + resolution: {integrity: sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ==} + peerDependencies: + effect: 4.0.0-rc.112 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode/protocol@2.0.18': + resolution: {integrity: sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ==} + + '@opencode/schema@2.0.18': + resolution: {integrity: sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1317,6 +1388,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1373,6 +1447,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1737,6 +1815,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1769,6 +1854,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} @@ -1869,10 +1958,20 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2492,9 +2591,9 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@earendil-works/pi-agent-core@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -2506,7 +2605,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -2527,11 +2626,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.3 + '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.10 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -2557,7 +2656,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.3': + '@earendil-works/pi-tui@0.80.10': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -2673,6 +2772,24 @@ snapshots: dependencies: hono: 4.12.25 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@koromix/koffi-android-arm64@3.2.1': optional: true @@ -2830,6 +2947,24 @@ snapshots: '@modelcontextprotocol/core': 2.0.0 zod: 4.4.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2843,6 +2978,23 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opencode/client@2.0.18(effect@4.0.0-rc.112)': + dependencies: + '@opencode/protocol': 2.0.18 + '@opencode/schema': 2.0.18 + optionalDependencies: + effect: 4.0.0-rc.112 + + '@opencode/protocol@2.0.18': + dependencies: + '@opencode/schema': 2.0.18 + effect: 4.0.0-rc.112 + + '@opencode/schema@2.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-rc.112 + '@opentelemetry/api@1.9.0': {} '@opentelemetry/semantic-conventions@1.41.1': {} @@ -3280,6 +3432,11 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.10.2 + msgpackr: 2.1.0 + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -3375,6 +3532,10 @@ snapshots: extend@3.0.2: {} + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-sha256@1.3.0: {} @@ -3742,6 +3903,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} @@ -3765,6 +3942,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-pty@1.1.0: dependencies: node-addon-api: 7.1.1 @@ -3874,10 +4056,24 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + range-parser@1.2.1: {} raw-body@3.0.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b10c94d08..f7842d097 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ allowBuilds: better-sqlite3: true esbuild: true koffi: true + msgpackr-extract: false node-pty: true protobufjs: false diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index cf9ce8f84..ecd6270c9 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -177,92 +177,77 @@ "providers": { "type": "array", "items": { - "oneOf": [ - { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "driver": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "acp", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + }, + "env": { "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "codex", - "claude", - "cursor", - "copilot", - "grok" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } - }, - "command": { - "type": "string", - "minLength": 1, - "pattern": "\\S" - } + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" }, - { + "config": { "type": "object", "properties": { - "id": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "flavor": { "type": "string", "enum": [ - "opencode", - "pi" + "cursor", + "copilot", + "grok" ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } } }, - "required": [ - "id", - "enabled" - ], "additionalProperties": false } - ] + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false } } }, diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 5ef4f5bb7..68e367c06 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -28,7 +28,7 @@ devspace agents run "" devspace agents run --model --effort "" ``` -The command returns an `` receipt. Keep the DevSpace agent ID for inspection, waiting, or follow-up. +The command returns an `` receipt. Keep the DevSpace agent ID for inspection, waiting, or follow-up. ## Wait or inspect @@ -40,7 +40,7 @@ devspace agents wait devspace agents wait --timeout 60 ``` -Without `--timeout`, the command waits until every named agent's current work finishes. A timeout returns one fragment per unique agent in first-seen order; unfinished work has `status="running" wait="timeout"`. Completed output is the element text. Failures include `code` and `retryable` attributes. The command does not stream partial results. +Without `--timeout`, the command waits until every named agent's current work finishes. A timeout returns one fragment per unique agent in first-seen order; unfinished work has `status="running" wait="timeout"`. Completed output is the element text. Failures include `code` and `retryable` attributes, and a `` with the provider's explanation when one exists. The command does not stream partial results. Use `show` for an immediate snapshot. Do not poll it when `wait` can express the dependency. @@ -51,6 +51,14 @@ devspace agents ls `ls` lists agents for the current project. Empty `targets` and `ls` results print nothing. +To see what a running agent is doing, print its current turn's activity. Each `` is a tool call, an assistant message, or the turn starting or ending: + +```bash +devspace agents events +``` + +`--follow` keeps printing until the turn ends. Use it when the user wants to watch progress, not as a substitute for `wait`. + ## Continue related work Continue an agent when its existing provider context helps. Start another agent for unrelated work. @@ -60,6 +68,14 @@ devspace agents continue "" devspace agents wait ``` +Stop an active turn when its work is no longer needed: + +```bash +devspace agents stop +``` + +Stopping is scoped to the current project and preserves the durable agent record as `stopped`. + ## Good uses - Review a change for correctness, security, or missing tests. diff --git a/skills/workflows/SKILL.md b/skills/workflows/SKILL.md new file mode 100644 index 000000000..9b91fe472 --- /dev/null +++ b/skills/workflows/SKILL.md @@ -0,0 +1,49 @@ +--- +name: workflows +description: Run a persisted multi-agent DevSpace workflow from a script file or a named project workflow, then wait for or inspect its result. +--- + +# DevSpace workflows + +Use a workflow when a task has a repeatable multi-agent plan. Keep one-off delegation on the `subagents` commands. + +Named workflows live in the current project's `.devspace/workflows/` directory. Run a named workflow or a specific file: + +```bash +devspace workflow run --name [--args ''] +devspace workflow run --file [--args-file ] +``` + +The command returns a compact `` receipt. Keep the ID. Workflow code is read-only by default; add `--write-mode allowed` only when the workflow must modify the project. + +Wait up to 60 seconds at a time. Repeat `wait` if the returned status is still running; do not poll `status` in a loop. + +```bash +devspace workflow wait --timeout 60 +``` + +Inspect current state, calls, or one full call when needed: + +```bash +devspace workflow status +devspace workflow calls +devspace workflow call +devspace workflow events --after +devspace workflow ls +``` + +`events` returns at most 100 persisted log and phase entries. Keep the highest sequence and pass it with `--after` for the next page. + +Cancel only when the parent task no longer needs the run: + +```bash +devspace workflow cancel +``` + +Resume an interrupted or failed run from its durable call history: + +```bash +devspace workflow run --resume [--args ''] +``` + +Use `--json` only for scripts that need structured output. diff --git a/src/cli-workspace.test.ts b/src/cli-workspace.test.ts index 56e5fe357..24a5d7321 100644 --- a/src/cli-workspace.test.ts +++ b/src/cli-workspace.test.ts @@ -45,6 +45,33 @@ try { workspaceRoot: resolve(nestedRoot), }); + let managedChecks = 0; + assert.deepEqual(resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ID: "ws_managed", + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot, (workspaceRoot, workspaceId) => { + managedChecks += 1; + return workspaceId === "ws_managed" && workspaceRoot === plainRoot; + }), { + workspaceId: "ws_managed", + workspaceRoot: plainRoot, + }); + assert.equal(managedChecks, 1); + + resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot, () => { + throw new Error("managed authorization must require an explicit workspace id"); + }); + + assert.throws( + () => resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ID: "ws_wrong", + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot, () => false), + /outside allowed roots/, + ); + if (process.platform !== "win32") { const repositoryAlias = join(root, "repository-alias"); symlinkSync(repositoryRoot, repositoryAlias, "dir"); diff --git a/src/cli-workspace.ts b/src/cli-workspace.ts index d09cb253a..96beec0da 100644 --- a/src/cli-workspace.ts +++ b/src/cli-workspace.ts @@ -13,6 +13,7 @@ export function resolveCliWorkspaceContext( allowedRoots: readonly string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd(), + authorizeManagedWorkspace?: (workspaceRoot: string, workspaceId: string) => boolean, ): CliWorkspaceContext { const workspaceId = env.DEVSPACE_WORKSPACE_ID?.trim() || undefined; const injectedRoot = workspaceId ? env.DEVSPACE_WORKSPACE_ROOT?.trim() : undefined; @@ -21,11 +22,17 @@ export function resolveCliWorkspaceContext( ); if (!workspaceId) return { workspaceId, workspaceRoot: candidate }; - - return { - workspaceId, - workspaceRoot: assertAllowedPath(candidate, allowedRoots.map(canonicalizePath)), - }; + try { + return { + workspaceId, + workspaceRoot: assertAllowedPath(candidate, allowedRoots.map(canonicalizePath)), + }; + } catch (error) { + if (authorizeManagedWorkspace?.(candidate, workspaceId)) { + return { workspaceId, workspaceRoot: candidate }; + } + throw error; + } } function canonicalizePath(path: string): string { diff --git a/src/cli.test.ts b/src/cli.test.ts index 3a622eb19..b65835f8d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -68,7 +68,8 @@ try { workspaceId: "ws_current", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", }).id, @@ -79,7 +80,8 @@ try { workspaceId: "ws_other", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }).id, { status: "running" }, ); @@ -114,7 +116,24 @@ try { })); return; } - const result = request.method === "agent.list" + const eventsPage = (afterSeq: unknown) => ({ + agentId: current.id, + turnId: "trn_follow", + status: afterSeq === 0 ? "running" : "completed", + events: afterSeq === 0 + ? [ + { type: "turn_started", turnId: "trn_follow", seq: 1, at: "t1" }, + { type: "tool_started", tool: "bash", summary: "git status", turnId: "trn_follow", seq: 2, at: "t2" }, + ] + : [ + { type: "tool_completed", tool: "bash", summary: "git status", status: "completed", turnId: "trn_follow", seq: 3, at: "t3" }, + { type: "message", text: "Clean tree.", turnId: "trn_follow", seq: 4, at: "t4" }, + { type: "turn_completed", status: "completed", turnId: "trn_follow", seq: 5, at: "t5" }, + ], + }); + const result = request.method === "agent.events" + ? eventsPage(request.params?.afterSeq) + : request.method === "agent.list" ? [current] : request.method === "agent.get" ? current @@ -268,6 +287,37 @@ try { timeoutMs: 0, }); + const { stdout: followOutput } = await execFileAsync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "events", current.id, "--follow"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal(followOutput, [ + '', + 'git status', + 'git status', + 'Clean tree.', + '', + "", + ].join("\n")); + // Following pins the turn it started on and resumes after the last seen event. + assert.deepEqual( + daemonRequests.filter((request) => request.method === "agent.events").map((request) => request.params), + [ + { id: current.id, scope: { workspaceId: "ws_current", workspaceRoot: realpathSync.native(projectRoot) }, afterSeq: 0 }, + { id: current.id, scope: { workspaceId: "ws_current", workspaceRoot: realpathSync.native(projectRoot) }, turnId: "trn_follow", afterSeq: 2 }, + ], + ); + let commandFailure: unknown; try { await execFileAsync( diff --git a/src/cli.ts b/src/cli.ts index 3037f65e8..6304ffd15 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { formatLocalAgentProviderStatusSummary, } from "./local-agent-catalog.js"; import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { resolveSubagentsConfig } from "./local-agent-config.js"; import { parseLocalAgentContinueArgs, parseLocalAgentRunArgs, @@ -28,6 +28,7 @@ import { toAgentErrorPayload, type LocalAgentError } from "./local-agent-errors. import { formatAgentCommandError, formatAgentObservation, + formatAgentTurnEvent, formatAgentReceipt, formatAgentSummary, formatAgentTargetCatalog, @@ -56,6 +57,9 @@ import { readReviewRef } from "./review-checkpoints.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { logEvent } from "./logger.js"; import { pruneStaleManagedWorktrees } from "./worktree-prune.js"; +import { runWorkflowCommand } from "./workflow-cli.js"; +import { workflowsEnabled } from "./workflow-flag.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; type Command = | "serve" @@ -64,6 +68,7 @@ type Command = | "config" | "worktrees" | "agents" + | "workflow" | "show-changes" | "help" | "version"; @@ -96,6 +101,11 @@ async function main(argv: string[]): Promise { case "agents": await runAgentsCommand(args); return; + case "workflow": { + const { args: commandArgs, json } = extractJsonOption(args); + await runWorkflowCommand(commandArgs, json); + return; + } case "show-changes": await runShowChanges(args); return; @@ -118,6 +128,7 @@ function normalizeCommand(command: string | undefined): Command { || command === "agents" || command === "show-changes" ) return command; + if (command === "workflow" && workflowsEnabled()) return command; if (command === "help" || command === "--help" || command === "-h") return "help"; if (command === "version" || command === "--version" || command === "-v") return "version"; throw new Error(`Unknown command: ${command}`); @@ -218,7 +229,7 @@ async function runInit({ force }: { force: boolean }): Promise { })); } - const currentSubagents = files.config.subagents; + const currentSubagents = resolveSubagentsConfig(files.config.subagents); const availability = getLocalAgentProviderAvailabilitySnapshot( process.env, currentSubagents, @@ -247,7 +258,7 @@ async function runInit({ force }: { force: boolean }): Promise { required: true, }); if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); - const selectedProviders = providerAnswer as LocalAgentProvider[]; + const selectedProviders = providerAnswer as string[]; const subagents = updateOnboardingSubagentsConfig( currentSubagents, selectedProviders, @@ -497,7 +508,10 @@ function printHelp(): void { " devspace agents continue [--model ] [--effort ] ", " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", + " devspace agents stop [--json]", + " devspace agents events [--turn ] [--follow] [--json]", " devspace agents daemon ", + ...(workflowsEnabled() ? [" devspace workflow [--json]"] : []), " devspace -v, --version Print the installed version", "", "For temporary tunnels:", @@ -544,6 +558,12 @@ async function runAgentsCommand(args: string[]): Promise { case "wait": await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); return; + case "stop": + await runAgentWorkflowCommand(json, () => runAgentsStop(commandArgs, json)); + return; + case "events": + await runAgentWorkflowCommand(json, () => runAgentsEvents(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; @@ -561,10 +581,19 @@ async function runAgentsCommand(args: string[]): Promise { } } +function resolveAgentCliWorkspaceContext(config: ServerConfig) { + return resolveCliWorkspaceContext( + config.allowedRoots, + process.env, + process.cwd(), + workflowsEnabled() ? (root, id) => isManagedWorkflowWorkspace(config, root, id) : undefined, + ); +} + async function runAgentsTargets(args: string[], json: boolean): Promise { if (args.length > 0) throw new Error("Usage: devspace agents targets [--json]"); const config = loadConfig(); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot); const providers = buildLocalAgentProviderStatuses( config.subagents, @@ -580,7 +609,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { if (args.length > 0) throw new Error("Usage: devspace agents ls [--json]"); const config = loadConfig(); const client = createLocalAgentClient(config); - const result = await client.list(resolveCliWorkspaceContext(config.allowedRoots)); + const result = await client.list(resolveAgentCliWorkspaceContext(config)); const agents = presentAgentWorkflowResult(result, json); if (!agents) return; @@ -596,7 +625,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { async function runAgentsRun(args: string[], json: boolean): Promise { const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const client = createLocalAgentClient(config); const result = await client.start({ target: parsed.target, @@ -620,7 +649,7 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { const parsed = parseLocalAgentContinueArgs(args); const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const result = await client.continue(parsed.agentId, parsed.prompt, { model: parsed.model, effort: parsed.effort, @@ -641,7 +670,7 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const initial = await client.get(id, scope); const record = presentAgentWorkflowResult(initial, json); if (!record) return; @@ -655,7 +684,7 @@ async function runAgentsWait(args: string[], json: boolean): Promise { const { ids, timeoutMs } = parseAgentsWaitArgs(args); const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json); if (!results) return; if (json) { @@ -665,6 +694,76 @@ async function runAgentsWait(args: string[], json: boolean): Promise { printAgentXml(results.map(formatAgentObservation).join("\n")); } +async function runAgentsStop(args: string[], json: boolean): Promise { + const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents stop [--json]"); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveAgentCliWorkspaceContext(config); + const record = presentAgentWorkflowResult(await client.stopAgent(id, scope), json); + if (!record) return; + const observation = presentAgentObservation(record); + if (json) printJson(observation); + else printAgentXml(formatAgentObservation(observation)); +} + +// The daemon protocol is request/response, so following a turn polls for events after the last seen seq. +const AGENT_EVENTS_FOLLOW_POLL_MS = 500; + +async function runAgentsEvents(args: string[], json: boolean): Promise { + const parsed = parseAgentsEventsArgs(args); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveAgentCliWorkspaceContext(config); + let turnId = parsed.turnId; + let afterSeq = 0; + for (;;) { + const page = presentAgentWorkflowResult(await client.events(parsed.id, scope, { turnId, afterSeq }), json); + if (!page) return; + if (json && !parsed.follow) { + printJson(page); + return; + } + let completed = false; + for (const event of page.events) { + if (json) printJson(event); + else printAgentXml(formatAgentTurnEvent(event)); + afterSeq = event.seq; + completed ||= event.type === "turn_completed"; + } + if (!parsed.follow || completed || page.status !== "running") return; + // Stay on the turn being followed even if the agent is continued meanwhile. + turnId = page.turnId; + await new Promise((resolve) => setTimeout(resolve, AGENT_EVENTS_FOLLOW_POLL_MS)); + } +} + +function parseAgentsEventsArgs(args: string[]): { id: string; turnId?: string; follow: boolean } { + const usage = "Usage: devspace agents events [--turn ] [--follow] [--json]"; + const positional: string[] = []; + let turnId: string | undefined; + let follow = false; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--follow" || argument === "-f") { + follow = true; + } else if (argument === "--turn") { + turnId = args[index + 1]; + index += 1; + if (!turnId) throw new Error(usage); + } else if (argument.startsWith("--turn=")) { + turnId = argument.slice("--turn=".length) || undefined; + if (!turnId) throw new Error(usage); + } else if (argument.startsWith("-")) { + throw new Error(`Unknown option: ${argument}.`); + } else { + positional.push(argument); + } + } + if (positional.length !== 1) throw new Error(usage); + return { id: positional[0]!, follow, ...(turnId ? { turnId } : {}) }; +} + function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { const ids: string[] = []; let timeoutMs: number | undefined; @@ -808,6 +907,8 @@ function printAgentsHelp(): void { " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", + " devspace agents stop [--json]", + " devspace agents events [--turn ] [--follow] [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), diff --git a/src/config-migration.ts b/src/config-migration.ts index 837d46f32..e7ec9665b 100644 --- a/src/config-migration.ts +++ b/src/config-migration.ts @@ -5,7 +5,7 @@ import { type DevspaceConfig, } from "./config-schema.js"; import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; +import { LOCAL_AGENT_DEFAULT_PROVIDER_IDS } from "./local-agent-provider.js"; const legacyConfigSchema = z.object({ host: z.string().optional(), @@ -92,7 +92,7 @@ function migrateLegacySubagents( return { enabled: value, providers: value - ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) + ? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ id, enabled: true })) : [], }; } diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..b9cd6c7fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import type { SubagentsConfig } from "./local-agent-config.js"; +import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; export type { ToolMode } from "./config-schema.js"; @@ -75,7 +75,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: stored.subagents, + subagents: resolveSubagentsConfig(stored.subagents), agentDir: normalizePath(stored.skills.agentDir), logging: { ...stored.logging, diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 8bb1f7e4f..5c3c1ff86 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,58 @@ const migrations: Migration[] = [ name: "local-agent-turns", up: migrateLocalAgentTurns, }, + { + version: 9, + name: "local-agent-provider-instances", + up: migrateLocalAgentProviderInstances, + }, + { + version: 10, + name: "local-agent-generic-acp-driver", + up: migrateLocalAgentGenericAcpDriver, + }, + { + version: 11, + name: "local-agent-turn-events", + up: migrateLocalAgentTurnEvents, + }, + { + version: 12, + name: "local-agent-authority", + up: (sqlite) => { + addColumnIfMissing(sqlite, "local_agent_sessions", "write_mode", "text not null default 'allowed'"); + addColumnIfMissing(sqlite, "local_agent_sessions", "dispatch_signature", "text"); + }, + }, + { + version: 13, + name: "dynamic-workflows", + up: (sqlite) => sqlite.exec(` + create table workflow_runs ( + id text primary key, + workspace_root text not null, + workspace_id text, + record_json text not null + ); + create index workflow_runs_workspace_idx on workflow_runs(workspace_root, workspace_id); + create table workflow_calls ( + run_id text not null references workflow_runs(id), + call_index integer not null, + agent_id text not null, + record_json text not null, + primary key (run_id, call_index) + ); + create index workflow_calls_agent_idx on workflow_calls(agent_id); + create table workflow_events ( + sequence integer primary key autoincrement, + run_id text not null references workflow_runs(id), + type text not null, + data_json text not null, + created_at text not null + ); + create index workflow_events_run_idx on workflow_events(run_id, sequence); + `), + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -289,9 +341,64 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { `); } +function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { + const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ + name: string; + }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("provider") && !names.has("provider_instance_id")) { + sqlite.exec("alter table local_agent_sessions rename column provider to provider_instance_id"); + } + addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text not null default ''"); + sqlite.exec(` + update local_agent_sessions + set driver = provider_instance_id + where driver is null or driver = '' + `); +} + +function migrateLocalAgentGenericAcpDriver(sqlite: Database.Database): void { + sqlite.exec(` + update local_agent_sessions + set driver = 'acp' + where driver in ('cursor', 'copilot', 'grok') + `); +} + +function migrateLocalAgentTurnEvents(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "local_agent_turns", "turn_id", "text"); + addColumnIfMissing(sqlite, "local_agent_turns", "error_detail", "text"); + addColumnIfMissing(sqlite, "local_agent_sessions", "latest_turn_id", "text"); + addColumnIfMissing(sqlite, "local_agent_sessions", "error_detail", "text"); + sqlite.exec(` + update local_agent_turns set turn_id = 'trn_' || id where turn_id is null; + + update local_agent_sessions + set latest_turn_id = ( + select turn_id from local_agent_turns + where local_agent_turns.agent_id = local_agent_sessions.id + order by id desc limit 1 + ) + where latest_turn_id is null; + + create unique index if not exists local_agent_turns_turn_id_idx + on local_agent_turns(turn_id); + + create table if not exists local_agent_turn_events ( + turn_row_id integer not null, + seq integer not null, + type text not null, + data text not null, + created_at text not null, + primary key (turn_row_id, seq), + foreign key (turn_row_id) references local_agent_turns(id) on delete cascade + ); + `); +} + function addColumnIfMissing( sqlite: Database.Database, - table: "workspace_sessions" | "local_agent_sessions", + table: "workspace_sessions" | "local_agent_sessions" | "local_agent_turns", column: string, definition: string, ): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 77b234368..c2566551f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -98,7 +98,10 @@ export const localAgentSessions = sqliteTable( workspaceId: text("workspace_id"), workspaceRoot: text("workspace_root").notNull(), profileName: text("profile_name").notNull(), - provider: text("provider").notNull(), + providerInstanceId: text("provider_instance_id").notNull(), + driver: text("driver").notNull(), + writeMode: text("write_mode").notNull().default("allowed"), + dispatchSignature: text("dispatch_signature"), model: text("model"), effort: text("effort"), providerSessionId: text("provider_session_id"), @@ -107,6 +110,8 @@ export const localAgentSessions = sqliteTable( error: text("error"), errorCode: text("error_code"), errorRetryable: text("error_retryable"), + errorDetail: text("error_detail"), + latestTurnId: text("latest_turn_id"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7a5222261..26567bbc6 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -7,6 +7,7 @@ import { AcpLocalAgentDriver, AcpRuntime, acpCommandArgs, + deliverAcpSessionUpdate, resolveAcpCommand, selectAcpPermissionOption, } from "./local-agent-acp.js"; @@ -16,6 +17,7 @@ const requests: Array<{ method: string; params?: unknown }> = []; const queues = new Map(); const connection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { requests.push({ method, params }); const input = params as { sessionId?: string } | undefined; @@ -35,6 +37,17 @@ const connection = { queues.set(sessionId, { values: [] }); return { sessionId }; } + if (method === "session/prompt" && (params as { prompt: Array<{ text: string }> }).prompt[0]?.text === "use tools") { + const sessionId = input?.sessionId ?? ""; + for (const update of [ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Let me " } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "look." } }, + { sessionUpdate: "tool_call", toolCallId: "call_1", title: "Read README.md", kind: "read", status: "pending" }, + { sessionUpdate: "tool_call_update", toolCallId: "call_1", status: "completed" }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done." } }, + ]) deliverAcpSessionUpdate(queues, { sessionId, update } as never); + return { stopReason: "end_turn" }; + } if (method === "session/prompt") { const queue = queues.get(input?.sessionId ?? ""); queue?.values.push({ @@ -54,7 +67,7 @@ const connection = { const sessionIds: string[] = []; const runtime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -99,13 +112,28 @@ assert.equal( false, ); +// Session updates stream as activity; message chunks are joined between tool calls. +const acpEvents: unknown[] = []; +const toolsResult = await runtime.run({ + prompt: "use tools", + workspaceRoot: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, +}, { onEvent: (event) => { acpEvents.push(event); } }); +assert.equal(toolsResult.isOk() && toolsResult.value.finalResponse, "Let me look.Done."); +assert.deepEqual(acpEvents, [ + { type: "message", text: "Let me look." }, + { type: "tool_started", tool: "read", summary: "Read README.md" }, + { type: "tool_completed", tool: "read", summary: "Read README.md", status: "completed" }, + { type: "message", text: "Done." }, +]); + await runtime.releaseSession("cursor_session_1"); assert.equal(queues.has("cursor_session_1"), false); assert.equal(requests.filter(({ method }) => method === "session/close").length, 1); assert.equal(runtime.isAlive(), true); const resumedRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -144,7 +172,7 @@ assert.equal(queues.has("cursor_session_1"), false); assert.equal(requests.filter(({ method }) => method === "session/close").length, 1); const closeOnlyRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -203,6 +231,7 @@ const overlappingPrompt = new Promise((resolvePrompt) => { releaseOverlapp const promptEntered = new Promise((resolveEntered) => { markPromptEntered = resolveEntered; }); const overlapConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { const input = params as { sessionId?: string } | undefined; if (method === "session/new") { @@ -227,7 +256,7 @@ const overlapConnection = { closed: new Promise(() => undefined), }; const overlapRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -256,7 +285,8 @@ await overlapRuntime.close(); const cachedContext = { agentId: "agt_acp", - provider: "cursor" as const, + providerInstanceId: "cursor", + provider: "acp" as const, workspaceRoot: "/tmp/project", writeMode: "allowed" as const, }; @@ -266,12 +296,14 @@ assert.deepEqual(acpCommandArgs("cursor", cachedContext), [ ]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, - provider: "grok", + providerInstanceId: "grok", + provider: "acp", effort: "low", }), ["agent", "--reasoning-effort", "low", "stdio"]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, - provider: "grok", + providerInstanceId: "grok", + provider: "acp", effort: "low", }, { GROK_AGENT_PROFILE: " /tmp/grok-coding-only.md " }), [ "agent", "--agent-profile", "/tmp/grok-coding-only.md", "--reasoning-effort", "low", "stdio", @@ -286,11 +318,20 @@ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "full_ "--acp", "--no-sandbox", "--allow-all", "-C", resolvedProject, ]); -const missingCommandDriver = new AcpLocalAgentDriver( - "cursor", - process.env, - () => join(tmpdir(), "devspace-definitely-missing-acp-command"), -); +const missingCommandDriver = new AcpLocalAgentDriver({ + flavor: "cursor", + env: process.env, + commandResolver: () => join(tmpdir(), "devspace-definitely-missing-acp-command"), +}); +// A stop that outlives the cancel grace period closes the runtime, so it must not be shared. +assert.equal(missingCommandDriver.runtimePolicy.scope, "agent"); +// Generic ACP agents cannot be held to a restricted write mode, so they run only with full_access. +const genericDriver = new AcpLocalAgentDriver({ command: process.execPath, args: ["-e", ""] }); +for (const writeMode of ["read_only", "allowed"] as const) { + const restricted = await genericDriver.createRuntime({ ...cachedContext, writeMode }); + assert.equal(restricted.isErr(), true); + if (restricted.isErr()) assert.match(restricted.error.message, /full_access/); +} const missingCommand = await missingCommandDriver.createRuntime(cachedContext); assert.equal(missingCommand.isErr(), true); if (missingCommand.isErr()) { @@ -313,8 +354,13 @@ if (process.platform === "win32") { `require("node:fs").writeFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(2)));\n`, ); await writeFile(command, `@ECHO OFF\r\n"${process.execPath}" "${recorder}" %*\r\n`); - const shimDriver = new AcpLocalAgentDriver("copilot", process.env, () => command); - const shimStartup = await shimDriver.createRuntime({ ...cachedContext, provider: "copilot", workspaceRoot }); + const shimDriver = new AcpLocalAgentDriver({ flavor: "copilot", env: process.env, commandResolver: () => command }); + const shimStartup = await shimDriver.createRuntime({ + ...cachedContext, + providerInstanceId: "copilot", + provider: "acp", + workspaceRoot, + }); assert.equal(shimStartup.isErr(), true); if (shimStartup.isErr()) assert.equal(shimStartup.error.code, "PROVIDER_PROTOCOL_ERROR"); const forwarded = JSON.parse(await readFile(marker, "utf8")) as string[]; @@ -348,6 +394,7 @@ const grokQueues = new Map(); const grokCompletionRegistry = new GrokPromptCompletionRegistry(); const grokConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { grokRequests.push({ method, params }); const input = params as { sessionId?: string; _meta?: { promptId?: string } } | undefined; @@ -386,7 +433,7 @@ const grokConnection = { closed: new Promise(() => undefined), }; const grokRuntime = new AcpRuntime({ - provider: "grok", + flavor: "grok", command: "grok", args: ["agent", "--reasoning-effort", "low", "stdio"], env: {}, @@ -413,6 +460,7 @@ await grokRuntime.close(); const grokConfigurationConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string): Promise { if (method === "session/new") { return { @@ -433,7 +481,7 @@ const grokConfigurationConnection = { closed: new Promise(() => undefined), }; const grokConfigurationRuntime = new AcpRuntime({ - provider: "grok", + flavor: "grok", command: "grok", args: ["agent", "stdio"], env: {}, @@ -465,3 +513,64 @@ await grokConfigurationRuntime.close(); await resumedRuntime.close(); await resumedRuntime.close(); assert.equal(resumedRuntime.isAlive(), false); + +// Stop: a provider that never answers session/prompt after session/cancel is given up on. +const stuckRequests: string[] = []; +const stuckNotifications: string[] = []; +const stuckRuntime = new AcpRuntime({ + flavor: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, + cancelGraceMs: 50, +}, { + agent: { + async notify(method: string): Promise { stuckNotifications.push(method); }, + async request(method: string): Promise { + stuckRequests.push(method); + if (method === "session/new") return { sessionId: "stuck_session" }; + return new Promise(() => undefined); + }, + }, + close() {}, + closed: new Promise(() => undefined), +}); +const stuckAbort = new AbortController(); +const stuckRun = stuckRuntime.run({ prompt: "never answers", workspaceRoot: "/tmp/project", signal: stuckAbort.signal }); +await new Promise((resolveTick) => setTimeout(resolveTick, 10)); +stuckAbort.abort(); +const stuckResult = await stuckRun; +assert.equal(stuckResult.isErr(), true); +if (stuckResult.isErr()) assert.equal(stuckResult.error.code, "PROVIDER_CANCELLED"); +assert.deepEqual(stuckNotifications, ["session/cancel"]); +assert.equal(stuckRuntime.isAlive(), false, "an unresponsive provider is dropped from the pool"); + +// Stop requested before the prompt is sent must not start provider work. +const preAbortRequests: string[] = []; +const preAbortRuntime = new AcpRuntime({ + flavor: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, +}, { + agent: { + async notify(): Promise {}, + async request(method: string): Promise { + preAbortRequests.push(method); + return method === "session/new" ? { sessionId: "pre_abort_session" } : { stopReason: "end_turn" }; + }, + }, + close() {}, + closed: new Promise(() => undefined), +}); +const preAborted = new AbortController(); +preAborted.abort(); +const preAbortResult = await preAbortRuntime.run({ + prompt: "should not run", + workspaceRoot: "/tmp/project", + signal: preAborted.signal, +}); +assert.equal(preAbortResult.isErr(), true); +if (preAbortResult.isErr()) assert.equal(preAbortResult.error.code, "PROVIDER_CANCELLED"); +assert.equal(preAbortRequests.includes("session/prompt"), false); +await preAbortRuntime.close(); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 078391ea9..810e7061e 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -3,11 +3,14 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { + AgentProviderExecutionError, AgentProviderProtocolError, AgentProviderUnavailableError, captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { summarizeToolInput, type LocalAgentActivityEvent } from "./local-agent-events.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; import { @@ -29,18 +32,21 @@ import type { } from "./local-agent-runtime.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; -export type AcpProvider = "cursor" | "copilot" | "grok"; +export type AcpFlavor = "cursor" | "copilot" | "grok" | "generic"; +export type AcpProvider = AcpFlavor; const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; const ACP_INITIALIZE_TIMEOUT_MS = 10_000; const ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS = 10 * 60_000; +// How long a provider gets to answer session/prompt after session/cancel before its process is dropped. +const ACP_CANCEL_GRACE_MS = 10_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; const observeChildError = (): void => {}; -const ACP_COMMANDS: Record = { +const ACP_COMMANDS: Partial> = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], grok: ["grok", "agent", "stdio"], @@ -49,6 +55,7 @@ const ACP_COMMANDS: Record = { interface AcpConnectionLike { agent: { request(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): Promise; }; close(error?: unknown): void; closed: Promise; @@ -62,10 +69,12 @@ interface AcpCapabilities { interface AcpSessionQueue { values: unknown[]; + /** Set while a turn runs so session/update notifications are reported as they arrive. */ + onUpdate?: (value: unknown) => void; } export interface AcpRuntimeOptions { - provider: AcpProvider; + flavor: AcpFlavor; command: string; args: string[]; env: NodeJS.ProcessEnv; @@ -77,10 +86,12 @@ export interface AcpRuntimeOptions { sessionMetadata?: Map; grokCompletionRegistry?: GrokPromptCompletionRegistry; promptCompletionTimeoutMs?: number; + cancelGraceMs?: number; } export class AcpRuntime implements LocalAgentRuntime { - readonly provider: AcpProvider; + readonly provider = "acp" as const; + private readonly flavor: AcpFlavor; private readonly child?: ChildProcessWithoutNullStreams; private readonly connection: AcpConnectionLike; private readonly capabilities: AcpCapabilities; @@ -90,13 +101,14 @@ export class AcpRuntime implements LocalAgentRuntime { private readonly sessionMetadata: Map; private readonly grokCompletionRegistry?: GrokPromptCompletionRegistry; private readonly promptCompletionTimeoutMs: number; + private readonly cancelGraceMs: number; private readonly activeSessions = new Set(); private promptSequence = 0; private alive = true; private closed = false; constructor(options: AcpRuntimeOptions, connection: AcpConnectionLike) { - this.provider = options.provider; + this.flavor = options.flavor; this.child = options.child; this.connection = connection; this.capabilities = options.capabilities ?? { resume: false, close: false }; @@ -106,6 +118,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.sessionMetadata = options.sessionMetadata ?? new Map(); this.grokCompletionRegistry = options.grokCompletionRegistry; this.promptCompletionTimeoutMs = options.promptCompletionTimeoutMs ?? ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS; + this.cancelGraceMs = options.cancelGraceMs ?? ACP_CANCEL_GRACE_MS; void this.connection.closed.then(() => { if (!this.closed) this.alive = false; this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); @@ -138,13 +151,34 @@ export class AcpRuntime implements LocalAgentRuntime { }); } const sessionId = await this.openSession(input, callbacks); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (this.activeSessions.has(sessionId)) { throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); } this.activeSessions.add(sessionId); + const removeAbort = bindLocalAgentAbort(input.signal, () => ( + this.connection.agent.notify("session/cancel", { sessionId }) + )); + // A provider that ignores session/cancel would leave the turn running forever: + // give up on it after a grace period and drop the process so the next turn starts clean. + let cancelTimer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const cancelDeadline = input.signal && new Promise((_, reject) => { + onAbort = () => { + cancelTimer = setTimeout(() => { + void this.close(); + reject(localAgentCancelledError(this.provider, "run", new Error( + `${this.provider} did not acknowledge session/cancel within ${this.cancelGraceMs}ms.`, + ))); + }, this.cancelGraceMs); + }; + if (input.signal?.aborted) onAbort(); + else input.signal?.addEventListener("abort", onAbort, { once: true }); + }); + cancelDeadline?.catch(() => undefined); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); - const promptId = this.provider === "grok" ? this.nextPromptId() : undefined; + const promptId = this.flavor === "grok" ? this.nextPromptId() : undefined; const completion = promptId && this.grokCompletionRegistry ? this.grokCompletionRegistry.wait( sessionId, @@ -159,6 +193,8 @@ export class AcpRuntime implements LocalAgentRuntime { }), ) : undefined; + const activity = callbacks?.onEvent ? new AcpActivityReporter(callbacks.onEvent) : undefined; + if (activity) queue.onUpdate = (value) => activity.handle(value); try { queue.values.length = 0; const standardResponse = this.connection.agent.request("session/prompt", { @@ -166,15 +202,25 @@ export class AcpRuntime implements LocalAgentRuntime { prompt: [{ type: "text", text: input.prompt }], ...(promptId ? { _meta: { promptId, requestId: promptId } } : {}), }); - const response = completion - ? await Promise.race([standardResponse, completion]) - : await standardResponse; + let response: unknown; + try { + response = await Promise.race([ + standardResponse, + ...(completion ? [completion] : []), + ...(cancelDeadline ? [cancelDeadline] : []), + ]); + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); + throw cause; + } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (completion && isGrokPromptCompletion(response)) { await yieldToAcpQueue(); } else if (promptId) { this.grokCompletionRegistry?.markCompleted(sessionId, promptId); } const updates = queue.values.splice(0); + activity?.flush(); const finalResponse = extractAcpText(updates); if (!finalResponse) { throw new AgentProviderProtocolError({ @@ -193,6 +239,10 @@ export class AcpRuntime implements LocalAgentRuntime { items: updates, }; } finally { + queue.onUpdate = undefined; + removeAbort(); + clearTimeout(cancelTimer); + if (onAbort) input.signal?.removeEventListener("abort", onAbort); if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } @@ -296,7 +346,7 @@ export class AcpRuntime implements LocalAgentRuntime { } private cacheSessionMetadata(sessionId: string, response: unknown): void { - if (hasAcpConfigOptions(response) || (this.provider === "grok" && readGrokSessionState(response))) { + if (hasAcpConfigOptions(response) || (this.flavor === "grok" && readGrokSessionState(response))) { this.sessionMetadata.set(sessionId, response); } } @@ -308,7 +358,7 @@ export class AcpRuntime implements LocalAgentRuntime { isNewSession = false, ): Promise { const metadata = response ?? this.sessionMetadata.get(sessionId); - if (this.provider === "grok") { + if (this.flavor === "grok") { await this.configureGrokSession(sessionId, input, metadata, isNewSession); return; } @@ -335,11 +385,11 @@ export class AcpRuntime implements LocalAgentRuntime { return; } if (input.model) { - const config = resolveAcpModelConfigUpdate(metadata, input.model, this.provider, sessionId); + const config = resolveAcpModelConfigUpdate(metadata, input.model, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } if (input.effort) { - const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.provider, sessionId); + const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } } @@ -409,25 +459,42 @@ export class AcpRuntime implements LocalAgentRuntime { } export class AcpLocalAgentDriver implements LocalAgentDriver { - readonly provider: AcpProvider; - // Keep ACP warm briefly, then let the generic pool close the process so the - // daemon can reach its own idle shutdown state. - readonly idleTimeoutMs = 5 * 60_000; + readonly provider = "acp" as const; + readonly providerInstanceId = "acp"; + // Agent-scoped: a stop that outlives the cancel grace period closes the + // runtime, which must not take other agents' turns down with it. + readonly runtimePolicy = { + scope: "agent", + authority: "write_mode", + idleTimeoutMs: 5 * 60_000, + } as const; + readonly capabilities = { + sessions: { resume: true, close: true }, + turns: { interrupt: true, streaming: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private commandResolved = false; private resolvedCommand?: string; - - constructor( - provider: AcpProvider, - private readonly env: NodeJS.ProcessEnv = process.env, - private readonly commandResolver: AcpCommandResolver = resolveAcpCommand, - ) { - this.provider = provider; - } - - runtimeKey(context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand() ?? ACP_COMMANDS[this.provider][0]; - const writeMode = context.writeMode ?? "allowed"; - return `acp:${this.provider}:${command}:${writeMode}:${resolve(context.workspaceRoot)}`; + private readonly flavor: AcpFlavor; + private readonly env: NodeJS.ProcessEnv; + private readonly configuredCommand?: string; + private readonly configuredArgs?: string[]; + private readonly commandResolver: AcpCommandResolver; + + constructor(options: { + flavor?: Exclude; + command?: string; + args?: string[]; + env?: NodeJS.ProcessEnv; + commandResolver?: AcpCommandResolver; + } = {}) { + this.flavor = options.flavor ?? "generic"; + this.env = options.env ?? process.env; + this.configuredCommand = options.command; + this.configuredArgs = options.args; + this.commandResolver = options.commandResolver ?? resolveAcpCommand; } async createRuntime(context: LocalAgentRuntimeContext) { @@ -447,7 +514,23 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `${this.provider} executable was not found.`, }); } - const args = acpCommandArgs(this.provider, context, this.env); + if (this.flavor === "generic" && context.writeMode !== "full_access") { + // DevSpace cannot bound what an arbitrary ACP agent writes: it may act + // without asking for permission, so only an explicit full_access turn may run it. + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: this.provider, + agentId: context.agentId, + operation: "create_runtime", + retryable: false, + message: `Generic ACP providers cannot enforce the ${context.writeMode ?? "allowed"} write mode; run them with full_access.`, + }); + } + // Built-in flavors derive their sandbox flags from the write mode, so only + // generic providers take configured arguments. + const args = this.flavor === "generic" + ? this.configuredArgs ?? [] + : acpCommandArgs(this.flavor, context, this.env); const child = spawn(command, args, { cwd: resolve(context.workspaceRoot), env: this.env, @@ -489,21 +572,19 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { const { client, methods, ndJsonStream } = await import("@agentclientprotocol/sdk"); const queues = new Map(); const sessionWriteModes = new Map(); - const grokCompletionRegistry = this.provider === "grok" + const grokCompletionRegistry = this.flavor === "grok" ? new GrokPromptCompletionRegistry() : undefined; const app = client({ name: "DevSpace" }) .onRequest(methods.client.session.requestPermission, (context) => { const writeMode = sessionWriteModes.get(context.params.sessionId); - const selected = selectAcpPermissionOption(context.params.options, writeMode, this.provider); + const selected = selectAcpPermissionOption(context.params.options, writeMode, this.flavor); return selected ? { outcome: { outcome: "selected", optionId: selected.optionId } } : { outcome: { outcome: "cancelled" } }; }) .onNotification(methods.client.session.update, (context) => { - const sessionId = context.params.sessionId; - const queue = queues.get(sessionId); - if (queue) appendAcpQueueValue(queue, context.params); + deliverAcpSessionUpdate(queues, context.params); }); if (grokCompletionRegistry) { for (const method of [ @@ -535,7 +616,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { ); const capabilities = readAcpCapabilities(init); const runtime = new AcpRuntime({ - provider: this.provider, + flavor: this.flavor, command, args, env: this.env, @@ -581,7 +662,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { private resolveCommand(): string | undefined { if (!this.commandResolved) { - this.resolvedCommand = this.commandResolver(this.provider, this.env); + this.resolvedCommand = this.commandResolver(this.flavor, this.env, this.configuredCommand); this.commandResolved = true; } return this.resolvedCommand; @@ -608,22 +689,30 @@ async function waitForProcessExit( } export function resolveAcpCommand( - provider: AcpProvider, + provider: AcpFlavor, env: NodeJS.ProcessEnv = process.env, + explicitCommand?: string, ): string | undefined { - const configured = provider === "cursor" + const configured = explicitCommand ?? (provider === "cursor" ? env.CURSOR_COMMAND : provider === "copilot" ? env.COPILOT_COMMAND - : env.GROK_COMMAND; - const command = configured ?? ACP_COMMANDS[provider][0]; + : provider === "grok" + ? env.GROK_COMMAND + : undefined); + const command = configured ?? ACP_COMMANDS[provider]?.[0]; + if (!command) return undefined; return resolveExecutableCommand(command, env); } -export type AcpCommandResolver = (provider: AcpProvider, env: NodeJS.ProcessEnv) => string | undefined; +export type AcpCommandResolver = ( + provider: AcpFlavor, + env: NodeJS.ProcessEnv, + explicitCommand?: string, +) => string | undefined; export function acpCommandArgs( - provider: AcpProvider, + provider: AcpFlavor, context: LocalAgentRuntimeContext, env: NodeJS.ProcessEnv = process.env, ): string[] { @@ -649,6 +738,7 @@ export function acpCommandArgs( "stdio", ]; } + if (provider === "generic") return []; const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; @@ -749,7 +839,7 @@ export function selectAcpAllowPermissionOption( export function selectAcpPermissionOption( options: Array<{ optionId: string; kind: string }>, writeMode: LocalAgentWriteMode | undefined, - provider?: AcpProvider, + provider?: AcpFlavor, ): { optionId: string } | undefined { if (!writeMode) return undefined; // Copilot's native sandbox has a per-command escape hatch enabled by @@ -805,9 +895,73 @@ function hasAcpConfigOptions(value: unknown): boolean { return Array.isArray(response?.configOptions); } +/** Routes one session/update notification to the session's queue, if DevSpace is tracking it. */ +export function deliverAcpSessionUpdate( + queues: Map, + params: { sessionId: string }, +): void { + const queue = queues.get(params.sessionId); + if (queue) appendAcpQueueValue(queue, params); +} + function appendAcpQueueValue(queue: AcpSessionQueue, value: unknown): void { if (queue.values.length >= MAX_ACP_QUEUE_ITEMS) queue.values.shift(); queue.values.push(value); + queue.onUpdate?.(value); +} + +/** + * Maps ACP session updates onto activity events. Message chunks are deltas, + * so they are buffered into one message per stretch of assistant text. + */ +export class AcpActivityReporter { + private readonly toolCalls = new Map(); + private text = ""; + + constructor(private readonly onEvent: (event: LocalAgentActivityEvent) => void) {} + + handle(value: unknown): void { + const update = asRecord(asRecord(value)?.update); + switch (update?.sessionUpdate) { + case "agent_message_chunk": { + const content = asRecord(update.content); + if (content?.type === "text" && typeof content.text === "string") this.text += content.text; + return; + } + case "tool_call": { + this.flush(); + const call = { + tool: typeof update.kind === "string" && update.kind ? update.kind : "tool", + summary: typeof update.title === "string" ? update.title : summarizeToolInput(update.rawInput), + }; + const id = typeof update.toolCallId === "string" ? update.toolCallId : undefined; + if (id) this.toolCalls.set(id, call); + this.onEvent({ type: "tool_started", ...call }); + this.complete(id, update.status); + return; + } + case "tool_call_update": { + const id = typeof update.toolCallId === "string" ? update.toolCallId : undefined; + const call = id ? this.toolCalls.get(id) : undefined; + if (call && typeof update.title === "string") call.summary = update.title; + this.complete(id, update.status); + return; + } + } + } + + flush(): void { + if (this.text.trim()) this.onEvent({ type: "message", text: this.text }); + this.text = ""; + } + + private complete(id: string | undefined, status: unknown): void { + if (status !== "completed" && status !== "failed") return; + const call = id ? this.toolCalls.get(id) : undefined; + if (!call || !id) return; + this.toolCalls.delete(id); + this.onEvent({ type: "tool_completed", ...call, status }); + } } function appendTail(current: string, chunk: string, maxBytes: number): string { diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 395072a1d..29315ce83 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -2,12 +2,14 @@ import assert from "node:assert/strict"; import { delimiter } from "node:path"; import { claudeCommandEnvironment, + createLocalAgentDrivers, extractOpenCodeFinalResponse, extractPiFinalResponse, extractPiProviderError, resolveAcpModelConfigUpdate, resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; assert.deepEqual( resolveAcpModelConfigUpdate({ @@ -330,5 +332,20 @@ assert.equal( removeDevspaceNodeModulesBinFromPath([devspaceBin, userBin].join(delimiter)), userBin, ); +} +{ + const subagents = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "claude-work", driver: "claude", enabled: true }, + { id: "claude-personal", driver: "claude", enabled: true }, + ], + }); + const drivers = createLocalAgentDrivers({ subagents }); + assert.deepEqual( + drivers.map((driver) => [driver.providerInstanceId, driver.provider]), + [["claude-work", "claude"], ["claude-personal", "claude"]], + ); + assert.deepEqual(drivers.map((driver) => driver.runtimePolicy.scope), ["agent", "agent"]); } diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d83d2fc8e..765a439a7 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,9 +1,12 @@ import { - localAgentProviderEnvironment, - localAgentProviderEnvironmentOverrides, + subagentAcpFlavor, + type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, + LOCAL_AGENT_DRIVER_KINDS, +} from "./local-agent-provider.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -25,9 +28,10 @@ import { PiLocalAgentDriver, extractPiFinalResponse, extractPiProviderError, - type PiSessionFactory, + type PiRpcFactory, } from "./local-agent-pi.js"; import type { LocalAgentDriver } from "./local-agent-runtime.js"; +import { LocalAgentProviderRegistry } from "./local-agent-provider-registry.js"; export type LocalAgentAdapter = LocalAgentDriver; @@ -36,28 +40,36 @@ export interface LocalAgentDriverOptions { subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; - piSessionFactory?: PiSessionFactory; + piRpcFactory?: PiRpcFactory; } export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { const env = options.env ?? process.env; - const providerEnv = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironment(options.subagents, provider, env) - : env; - const providerEnvOverrides = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironmentOverrides(options.subagents, provider) - : {}; - return [ - new CodexLocalAgentDriver(providerEnv("codex")), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), - new OpencodeLocalAgentDriver(options.opencodeFactory, providerEnv("opencode")), - new PiLocalAgentDriver(options.piSessionFactory, providerEnvOverrides("pi")), - new AcpLocalAgentDriver("cursor", providerEnv("cursor")), - new AcpLocalAgentDriver("copilot", providerEnv("copilot")), - new AcpLocalAgentDriver("grok", providerEnv("grok")), - ]; + const instances = options.subagents?.providers ?? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ + id, + driver: id === "cursor" || id === "copilot" || id === "grok" ? "acp" : id, + enabled: true, + } satisfies SubagentProviderConfig)); + const registry = createLocalAgentProviderRegistry(options); + return instances.map((instance) => registry.create(instance, options.subagents, env)); +} + +export function createLocalAgentProviderRegistry( + options: LocalAgentDriverOptions = {}, +): LocalAgentProviderRegistry { + return new LocalAgentProviderRegistry() + .register("codex", ({ env }) => new CodexLocalAgentDriver(env)) + .register("claude", ({ env }) => new ClaudeLocalAgentDriver(options.claudeQueryFactory, env)) + .register("opencode", ({ env }) => new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env })) + .register("pi", ({ env }) => new PiLocalAgentDriver(options.piRpcFactory, env)) + .register("acp", ({ instance, env }) => new AcpLocalAgentDriver({ + env, + command: instance.command, + args: instance.config?.args, + flavor: subagentAcpFlavor(instance), + })); } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index a5592b42a..c2f3d8e7b 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -43,6 +43,7 @@ assert.equal( instructions: "on-demand", providers: [{ id: "codex", + driver: "codex", enabled: true, command: executable, env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, @@ -59,3 +60,45 @@ assert.equal( rmSync(directory, { recursive: true, force: true }); } } + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-acp-availability-")); + try { + const grok = join(directory, "grok"); + writeFileSync(grok, "#!/bin/sh\nexit 0\n"); + chmodSync(grok, 0o700); + const env = { PATH: directory }; + const config = { + enabled: true, + instructions: "on-demand" as const, + providers: [ + { id: "cursor", driver: "acp" as const, enabled: true, config: { flavor: "cursor" as const } }, + { id: "cursor-work", driver: "acp" as const, enabled: true, config: { flavor: "grok" as const } }, + { id: "kiro", driver: "acp" as const, enabled: true, command: "kiro" }, + ], + }; + const snapshot = getLocalAgentProviderAvailabilitySnapshot(env, config); + assert.deepEqual(snapshot.filter((provider) => provider.name === "cursor").length, 1, "configured built-in ACP ids are listed once"); + assert.equal(snapshot.find((provider) => provider.name === "cursor-work")?.available, true, "preflight checks the flavor's executable"); + assert.equal(snapshot.find((provider) => provider.name === "kiro")?.reason, "kiro executable not found"); + assert.equal(snapshot.find((provider) => provider.name === "cursor")?.available, false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-pi-availability-")); + try { + const pi = join(directory, "pi"); + writeFileSync(pi, "#!/bin/sh\nexit 0\n"); + chmodSync(pi, 0o700); + const availability = (env: NodeJS.ProcessEnv) => getLocalAgentProviderAvailabilitySnapshot(env) + .find((provider) => provider.name === "pi"); + assert.equal(availability({ PATH: directory })?.available, true, "a system pi on PATH is launchable"); + assert.equal(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.available, false); + assert.match(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.reason ?? "", /missing\/pi/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 14764488c..9ebcf4349 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,15 +1,21 @@ -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; +import { resolveAcpCommand } from "./local-agent-acp.js"; +import { resolvePiCommand } from "./local-agent-pi.js"; import { localAgentProviderEnvironment, + subagentAcpFlavor, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; +import { + defaultDriverForProviderId, + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderAvailability { - name: LocalAgentProvider; + name: LocalAgentProviderInstanceId; available: boolean; reason?: string; note?: string; @@ -19,67 +25,105 @@ export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => ( - checkLocalAgentProviderAvailability(provider, env, config) + const configured = new Map(config?.providers.map((provider) => [provider.id, provider]) ?? []); + const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); + const instances = [ + ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => configured.get(id) ?? { id, driver: defaultDriverForProviderId(id)! }), + ...(config?.providers.filter((provider) => !defaultIds.has(provider.id)) ?? []), + ]; + return instances.map((instance) => checkLocalAgentProviderAvailability( + instance.id, + instance.driver, + env, + config, )); } function checkLocalAgentProviderAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, + driver: LocalAgentDriverKind, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability { - const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; - switch (provider) { + const providerEnv = config ? localAgentProviderEnvironment(config, providerInstanceId, env) : env; + switch (driver) { case "codex": - return codexAvailability(providerEnv); + return codexAvailability(providerInstanceId, providerEnv); case "claude": return providerEnv.CLAUDE_COMMAND - ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) - : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + ? commandAvailability(providerInstanceId, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(providerInstanceId, "@anthropic-ai/claude-agent-sdk"); case "opencode": - return packageAvailability(provider, "@opencode-ai/sdk/v2"); + return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": - return packageAvailability(provider, "@earendil-works/pi-coding-agent"); - case "cursor": - return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); - case "copilot": - return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); - case "grok": - return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); + // Same resolution as the runtime: PI_COMMAND, then `pi` on PATH, then the bundled package. + try { + resolvePiCommand(providerEnv); + return { name: providerInstanceId, available: true }; + } catch (cause) { + return { + name: providerInstanceId, + available: false, + reason: cause instanceof Error ? cause.message : "Pi executable not found", + }; + } + case "acp": { + // Resolve exactly as the driver does, so preflight and startup agree on the executable. + const configured = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const flavor = subagentAcpFlavor({ id: providerInstanceId, config: configured?.config }); + if (resolveAcpCommand(flavor ?? "generic", providerEnv, configured?.command)) { + return { name: providerInstanceId, available: true }; + } + return { + name: providerInstanceId, + available: false, + reason: configured?.command + ? `${configured.command} executable not found` + : flavor + ? `${flavor} executable not found` + : "ACP command is not configured", + }; + } } } export function assertLocalAgentProviderAvailable( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env, config); + const provider = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const driver = provider?.driver + ?? defaultDriverForProviderId(providerInstanceId); + if (!driver) throw new Error(`${providerInstanceId} provider is not configured.`); + const availability = checkLocalAgentProviderAvailability(providerInstanceId, driver, env, config); if (availability.available) return; throw new Error( - `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, + `${providerInstanceId} provider is not available: ${availability.reason ?? "provider preflight failed"}`, ); } function packageAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, packageName: string, ): LocalAgentProviderAvailability { try { import.meta.resolve(packageName); - return { name: provider, available: true }; + return { name: providerInstanceId, available: true }; } catch { return { - name: provider, + name: providerInstanceId, available: false, reason: `${packageName} package not found`, }; } } -function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailability { - const availability = commandAvailability("codex", env.CODEX_COMMAND ?? "codex", env); +function codexAvailability( + providerInstanceId: LocalAgentProviderInstanceId, + env: NodeJS.ProcessEnv, +): LocalAgentProviderAvailability { + const availability = commandAvailability(providerInstanceId, env.CODEX_COMMAND ?? "codex", env); return availability.available ? { ...availability, @@ -89,13 +133,13 @@ function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailabili } function commandAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, command: string, env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { - if (resolveExecutableCommand(command, env)) return { name: provider, available: true }; + if (resolveExecutableCommand(command, env)) return { name: providerInstanceId, available: true }; return { - name: provider, + name: providerInstanceId, available: false, reason: `${command} executable not found`, }; diff --git a/src/local-agent-cancellation.ts b/src/local-agent-cancellation.ts new file mode 100644 index 000000000..e8d47a9d7 --- /dev/null +++ b/src/local-agent-cancellation.ts @@ -0,0 +1,28 @@ +import { AgentProviderCancelledError } from "./local-agent-errors.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; + +export function localAgentCancelledError( + provider: LocalAgentDriverKind, + operation: string, + cause?: unknown, +): AgentProviderCancelledError { + return new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider, + operation, + retryable: false, + ...(cause === undefined ? {} : { cause }), + message: `${provider} agent turn was stopped.`, + }); +} + +export function bindLocalAgentAbort( + signal: AbortSignal | undefined, + interrupt: () => void | Promise, +): () => void { + if (!signal) return () => undefined; + const onAbort = () => { void Promise.resolve(interrupt()).catch(() => undefined); }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); +} diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts index cc156251d..42acea196 100644 --- a/src/local-agent-catalog.test.ts +++ b/src/local-agent-catalog.test.ts @@ -10,9 +10,9 @@ const config: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true, model: "sonnet" }, - { id: "pi", enabled: false }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "pi", driver: "pi", enabled: false }, ], }; const statuses = buildLocalAgentProviderStatuses(config, [ @@ -23,7 +23,7 @@ const statuses = buildLocalAgentProviderStatuses(config, [ assert.equal(statuses.find((provider) => provider.id === "codex")?.usable, true); assert.equal(statuses.find((provider) => provider.id === "claude")?.usable, false); assert.equal(statuses.find((provider) => provider.id === "pi")?.usable, false); -assert.equal(statuses.find((provider) => provider.id === "opencode")?.enabled, false); +assert.equal(statuses.find((provider) => provider.id === "opencode"), undefined); const profiles: LocalAgentProfile[] = [ { diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts index c19904ba2..b446adab0 100644 --- a/src/local-agent-catalog.ts +++ b/src/local-agent-catalog.ts @@ -1,14 +1,14 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProfileSummary, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile, LocalAgentProfileSummary } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderStatus { - id: LocalAgentProvider; + id: LocalAgentProviderInstanceId; + driver: LocalAgentDriverKind; enabled: boolean; available: boolean; usable: boolean; @@ -28,13 +28,14 @@ export function buildLocalAgentProviderStatuses( config: SubagentsConfig, availability: readonly LocalAgentProviderAvailability[], ): LocalAgentProviderStatus[] { - return LOCAL_AGENT_PROVIDERS.map((id) => { - const configured = config.providers.find((entry) => entry.id === id); + return config.providers.map((configured) => { + const id = configured.id; const live = availability.find((entry) => entry.name === id); const enabled = configured?.enabled === true; const available = live?.available === true; return { id, + driver: configured.driver, enabled, available, usable: config.enabled && enabled && available, diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index 14e364005..d65e93257 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -6,11 +6,12 @@ import { type ClaudeUserMessage, } from "./local-agent-claude.js"; import { createLocalAgentDrivers } from "./local-agent-adapters.js"; -import { subagentsConfigSchema } from "./local-agent-config.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { private readonly iterator: AsyncIterator; + private readonly pending: unknown[] = []; closeCount = 0; model?: string; permissionModes: string[] = []; @@ -25,8 +26,23 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { } async next(): Promise> { + if (this.pending.length > 0) return { done: false, value: this.pending.shift() }; const next = await this.iterator.next(); if (next.done) return { done: true, value: undefined }; + if (next.value.message.content === "use tools") { + this.pending.push( + { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "toolu_1", is_error: true }] } }, + { type: "assistant", message: { content: [{ type: "text", text: "The tests fail." }] } }, + { type: "result", session_id: "claude_session_1", result: "done" }, + ); + return { + done: false, + value: { + type: "assistant", + message: { content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "pnpm test" } }] }, + }, + }; + } return { done: false, value: { @@ -41,6 +57,8 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { this.closeCount += 1; } + async interrupt(): Promise {} + async setPermissionMode(mode: string): Promise { this.permissionModes.push(mode); } @@ -56,6 +74,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { const context: LocalAgentRuntimeContext = { agentId: "agt_claude", + providerInstanceId: "claude", provider: "claude", workspaceRoot: "/tmp/project", model: "sonnet", @@ -71,17 +90,11 @@ const driver = new ClaudeLocalAgentDriver(({ prompt, options }) => { query = new FakeClaudeQuery(prompt); return query; }, { PATH: "/usr/bin" }); -assert.equal(driver.runtimeKey(context), "claude:agt_claude:restricted"); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "allowed" }), - "claude:agt_claude:restricted", - "restricted Claude modes can share one query because per-turn settings are dynamic", -); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "full_access" }), - "claude:agt_claude:full_access", - "full access uses a query initialized with the explicit dangerous-permission opt-in", -); +assert.deepEqual(driver.runtimePolicy, { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, +}); const runtimeResult = await driver.createRuntime(context); assert.equal(runtimeResult.isOk(), true); @@ -212,6 +225,7 @@ const brokenStreamQuery: ClaudeQueryLike = { }; }, close() {}, + async interrupt() {}, async setPermissionMode() {}, async applyFlagSettings() {}, }; @@ -241,7 +255,7 @@ const configuredDriver = createLocalAgentDrivers({ ANTHROPIC_API_KEY: "inherited", INHERITED: "yes", }, - subagents: subagentsConfigSchema.parse({ + subagents: parseSubagentsConfig({ enabled: true, providers: [{ id: "claude", @@ -267,4 +281,17 @@ assert.deepEqual(configuredOptions?.env, { INHERITED: "yes", EMPTY_VALUE: "", }); + +// Complete SDK messages map onto tool and message events; tool results resolve their tool_use. +assert.equal(configuredDriver.capabilities.turns.streaming, true); +const claudeEvents: unknown[] = []; +const streamed = await configuredRuntime.value.run({ prompt: "use tools", workspaceRoot: "/tmp/project" }, { + onEvent: (event) => { claudeEvents.push(event); }, +}); +assert.equal(streamed.isOk() && streamed.value.finalResponse, "done"); +assert.deepEqual(claudeEvents, [ + { type: "tool_started", tool: "Bash", summary: "pnpm test" }, + { type: "tool_completed", tool: "Bash", summary: "pnpm test", status: "failed" }, + { type: "message", text: "The tests fail." }, +]); await configuredRuntime.value.close(); diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index a639f2f46..41012d3f6 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -5,7 +5,9 @@ import { captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { summarizeToolInput, type LocalAgentActivityEvent } from "./local-agent-events.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -28,6 +30,7 @@ const CLAUDE_WORKSPACE_ALLOWED_TOOLS = [ export interface ClaudeQueryLike extends AsyncIterable { close(): void; + interrupt(): Promise; setPermissionMode(mode: ClaudePermissionMode): Promise; applyFlagSettings(settings: Record): Promise; setModel?(model?: string): Promise; @@ -77,7 +80,7 @@ class AsyncInputQueue implements AsyncIterable { } export class ClaudeQueryRuntime implements LocalAgentRuntime { - readonly provider: LocalAgentProvider = "claude"; + readonly provider: LocalAgentDriverKind = "claude"; private readonly iterator: AsyncIterator; private alive = true; private closed = false; @@ -106,6 +109,8 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude runtime is not running.", }); } + const removeAbort = bindLocalAgentAbort(input.signal, () => this.query.interrupt()); + try { if (this.providerSessionId) await callbacks?.onSessionId?.(this.providerSessionId); const flagSettings = claudeAuthoritySettings(input.workspaceRoot, input.writeMode); if (input.effort) { @@ -117,6 +122,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { await this.query.applyFlagSettings(flagSettings); await this.query.setPermissionMode(claudePermissionMode(input.writeMode)); if (input.model && this.query.setModel) await this.query.setModel(input.model); + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); this.inputQueue.push({ type: "user", message: { role: "user", content: input.prompt }, @@ -124,11 +130,13 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { }); const items: unknown[] = []; + const toolCalls = new Map(); for (;;) { let next: IteratorResult; try { next = await this.iterator.next(); } catch (error) { + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run", error); this.alive = false; if (isProgrammerDefect(error)) throw error; throw new AgentProviderUnavailableError({ @@ -153,6 +161,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { const message = next.value; items.push(message); const record = asRecord(message); + if (record && callbacks?.onEvent) reportClaudeActivity(record, toolCalls, callbacks.onEvent); if (typeof record?.session_id === "string") { const previousSessionId = this.providerSessionId; this.providerSessionId = record.session_id; @@ -161,6 +170,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { } } if (record?.type !== "result") continue; + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); const resultError = claudeResultError(record); if (resultError) { @@ -190,6 +200,9 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { items, }; } + } finally { + removeAbort(); + } }, }); } @@ -213,18 +226,25 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; - readonly idleTimeoutMs = 3 * 60_000; + readonly providerInstanceId = "claude"; + readonly runtimePolicy = { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, + } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true, streaming: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; constructor( private readonly factory: ClaudeQueryFactory = defaultClaudeQueryFactory, private readonly env: NodeJS.ProcessEnv = process.env, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - const authority = context.writeMode === "full_access" ? "full_access" : "restricted"; - return `claude:${context.agentId}:${authority}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, @@ -370,6 +390,37 @@ export function claudeResultError(record: Record): string | und return `Claude returned an error result: ${message}`; } +interface ClaudeToolCall { + tool: string; + summary?: string; +} + +/** Maps complete SDK messages (no partial deltas) onto DevSpace activity events. */ +export function reportClaudeActivity( + record: Record, + toolCalls: Map, + onEvent: (event: LocalAgentActivityEvent) => void, +): void { + if (record.type !== "assistant" && record.type !== "user") return; + const content = asRecord(record.message)?.content; + if (!Array.isArray(content)) return; + for (const value of content) { + const block = asRecord(value); + if (record.type === "assistant" && block?.type === "text" && typeof block.text === "string") { + onEvent({ type: "message", text: block.text }); + } else if (record.type === "assistant" && block?.type === "tool_use" && typeof block.name === "string") { + const call = { tool: block.name, summary: summarizeToolInput(block.input) }; + if (typeof block.id === "string") toolCalls.set(block.id, call); + onEvent({ type: "tool_started", ...call }); + } else if (record.type === "user" && block?.type === "tool_result" && typeof block.tool_use_id === "string") { + const call = toolCalls.get(block.tool_use_id); + if (!call) continue; + toolCalls.delete(block.tool_use_id); + onEvent({ type: "tool_completed", ...call, status: block.is_error === true ? "failed" : "completed" }); + } + } +} + export interface ClaudeUserMessage { type: "user"; message: { role: "user"; content: string }; diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 5aa5bce35..cec008f83 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -27,7 +27,13 @@ import { decodeDaemonHello, decodeDaemonLogs, decodeDaemonStatus, + decodeTurnEvents, decodeLocalAgentDaemonResponse, + decodeWorkflowCall, + decodeWorkflowCallList, + decodeWorkflowEventList, + decodeWorkflowRun, + decodeWorkflowRunList, encodeLocalAgentDaemonRequest, LocalAgentDaemonProtocolError, type LocalAgentDaemonErrorPayload, @@ -49,13 +55,24 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentEventsError, + AgentStopError, AgentWaitError, + LocalAgentTurnEvents, + LocalAgentTurnEventsQuery, LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; import { devspaceConfigDir } from "./user-config.js"; +import { + WorkflowError, + type WorkflowCall, + type WorkflowEvent, + type WorkflowRun, + type WorkflowRunInput, +} from "./workflow-types.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -67,7 +84,9 @@ type RequestError = : M extends "agent.get" ? AgentLookupError | AgentDaemonError : M extends "agent.list" ? AgentListError | AgentDaemonError : M extends "agent.wait" ? AgentWaitError | AgentDaemonError - : AgentDaemonError; + : M extends "agent.events" ? AgentEventsError | AgentDaemonError + : M extends `workflow.${string}` ? WorkflowError | AgentDaemonError + : AgentDaemonError; export interface LocalAgentClientOptions { stateDir: string; @@ -160,6 +179,88 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); } + async stopAgent( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("agent.stop", { id: agentId, scope }); + return decodeRequestResult(result, "agent.stop", decodeAgentRecord); + } + + async events( + agentId: string, + scope: LocalAgentWorkspaceScope, + query: LocalAgentTurnEventsQuery = {}, + ): Promise> { + const result = await this.request("agent.events", { id: agentId, scope, ...query }); + return decodeRequestResult(result, "agent.events", decodeTurnEvents); + } + + async runWorkflow( + input: WorkflowRunInput, + ): Promise> { + const result = await this.request("workflow.run", input); + return decodeRequestResult(result, "workflow.run", decodeWorkflowRun); + } + + async getWorkflow( + id: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.get", { id, scope }); + return decodeRequestResult(result, "workflow.get", decodeWorkflowRun); + } + + async listWorkflows( + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.list", scope); + return decodeRequestResult(result, "workflow.list", decodeWorkflowRunList); + } + + async waitWorkflow( + id: string, + scope: LocalAgentWorkspaceScope, + timeoutMs = 60_000, + ): Promise> { + const result = await this.request("workflow.wait", { id, scope, timeoutMs }, timeoutMs + this.requestTimeoutMs); + return decodeRequestResult(result, "workflow.wait", decodeWorkflowRun); + } + + async workflowCalls( + id: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.calls", { id, scope }); + return decodeRequestResult(result, "workflow.calls", decodeWorkflowCallList); + } + + async workflowCall( + id: string, + index: number, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.call", { id, index, scope }); + return decodeRequestResult(result, "workflow.call", decodeWorkflowCall); + } + + async workflowEvents( + id: string, + scope: LocalAgentWorkspaceScope, + after = 0, + ): Promise> { + const result = await this.request("workflow.events", { id, scope, after }); + return decodeRequestResult(result, "workflow.events", decodeWorkflowEventList); + } + + async cancelWorkflow( + id: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.cancel", { id, scope }); + return decodeRequestResult(result, "workflow.cancel", decodeWorkflowRun); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -272,7 +373,7 @@ export class LocalAgentClient { const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello); if (decoded.isErr()) return decoded; if (!decoded.value.configMatches) { - if (allowStaleBusyConfig && decoded.value.status.activeTurns > 0) { + if (allowStaleBusyConfig && daemonBusy(decoded.value.status)) { return Result.ok(decoded.value.status); } return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status); @@ -288,11 +389,11 @@ export class LocalAgentClient { code: "DAEMON_CONFIG_CHANGED", operation: "startup", retryable: true, - message: status.activeTurns > 0 + message: daemonBusy(status) ? "The local agent daemon is running active turns with an older provider configuration. Retry after they finish." : "The local agent daemon is using an older provider configuration.", }); - if (status.activeTurns > 0) return Result.err(changed); + if (daemonBusy(status)) return Result.err(changed); return this.stopIdleDaemon(authToken, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, status, changed); } @@ -312,7 +413,7 @@ export class LocalAgentClient { if (statusResponse.isErr() || !statusResponse.value.ok) return Result.err(mismatch); const status = decodeValue(statusResponse.value.result, "hello", decodeDaemonStatus); if (status.isErr()) return status; - if (status.value.activeTurns > 0) { + if (daemonBusy(status.value)) { return Result.err(new AgentDaemonProtocolMismatchError({ code: "DAEMON_PROTOCOL_MISMATCH", operation: "startup", @@ -403,7 +504,9 @@ export class LocalAgentClient { } as LocalAgentDaemonRequest, timeoutMs ?? undefined); if (response.isErr()) return response as BetterResult>; if (!response.value.ok) { - const error = decodeRemoteError(response.value.error, method); + const error = isWorkflowMethod(method) + ? decodeWorkflowRemoteError(response.value.error) + : decodeRemoteError(response.value.error, method); if (!isRequestError(method, error)) { return Result.err(new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", @@ -491,8 +594,11 @@ export class LocalAgentClient { function isObservationRequest( method: LocalAgentDaemonRequest["method"], -): method is "agent.get" | "agent.list" | "agent.wait" { - return method === "agent.get" || method === "agent.list" || method === "agent.wait"; +): boolean { + return method === "agent.get" || method === "agent.list" || method === "agent.wait" || method === "agent.events" + || method === "workflow.get" || method === "workflow.list" || method === "workflow.wait" + || method === "workflow.calls" || method === "workflow.call" || method === "workflow.events" + || method === "workflow.cancel"; } export function createLocalAgentClient( @@ -621,7 +727,7 @@ async function sendRequest( }); } -function decodeRequestResult( +function decodeRequestResult( result: BetterResult, operation: string, decode: (value: unknown) => T, @@ -661,10 +767,24 @@ function decodeRemoteError( }); } +function decodeWorkflowRemoteError(payload: LocalAgentDaemonErrorPayload): LocalAgentError | WorkflowError { + const decoded = agentErrorFromPayload(payload); + return decoded && isAgentDaemonError(decoded) + ? decoded + : new WorkflowError(payload.code, payload.message, payload.retryable ?? false); +} + +type WorkflowDaemonMethod = Extract; + +function isWorkflowMethod(method: LocalAgentDaemonRequest["method"]): method is WorkflowDaemonMethod { + return method.startsWith("workflow."); +} + function isRequestError( method: LocalAgentDaemonRequest["method"], - error: LocalAgentError, + error: LocalAgentError | WorkflowError, ): boolean { + if (error instanceof WorkflowError) return method.startsWith("workflow."); const category = matchError(error, { AgentTargetError: () => "target" as const, AgentConflictError: () => "conflict" as const, @@ -694,9 +814,24 @@ function isRequestError( || category === "store"; case "agent.get": case "agent.wait": + case "agent.events": return category === "target" || category === "scope" || category === "store"; + case "agent.stop": + return category === "target" + || category === "scope" + || category === "conflict" + || category === "store"; case "agent.list": return category === "scope" || category === "store"; + case "workflow.run": + case "workflow.get": + case "workflow.list": + case "workflow.wait": + case "workflow.calls": + case "workflow.call": + case "workflow.events": + case "workflow.cancel": + return false; case "hello": case "daemon.status": case "daemon.stop": @@ -705,6 +840,10 @@ function isRequestError( } } +function daemonBusy(status: LocalAgentDaemonStatus): boolean { + return status.activeTurns > 0 || (status.activeWorkflows ?? 0) > 0; +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index 1dcebc043..8eaed495f 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -11,8 +11,14 @@ import { sandboxFor, } from "./local-agent-codex.js"; import { toAgentErrorPayload } from "./local-agent-errors.js"; +import { providerErrorDetail } from "./local-agent-events.js"; -const cachedContext = { agentId: "agt_test", provider: "codex" as const, workspaceRoot: "/tmp/project" }; +const cachedContext = { + agentId: "agt_test", + providerInstanceId: "codex", + provider: "codex" as const, + workspaceRoot: "/tmp/project", +}; assert.equal(parseCodexVersion("codex-cli 0.9.1"), "0.9.1"); assert.equal(sandboxFor("read_only"), "read-only"); @@ -73,6 +79,16 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { output({ method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: turnId, status: "completed", items: [] } } }); return; } + if (message.params.input[0].text === "tools") { + const threadId = message.params.threadId; + const command = { type: "commandExecution", id: "cmd_1", command: "pnpm test", status: "inProgress" }; + const reply = { type: "agentMessage", text: "Tests fail." }; + output({ method: "item/started", params: { threadId, turnId, item: command } }); + output({ method: "item/completed", params: { threadId, turnId, item: { ...command, status: "failed", exitCode: 1 } } }); + output({ method: "item/completed", params: { threadId, turnId, item: reply } }); + output({ method: "turn/completed", params: { threadId, turn: { id: turnId, status: "completed", items: [reply] } } }); + return; + } if (message.params.input[0].text === "notLoaded") { const item = { type: "agentMessage", text: "CODEXOK" }; output({ method: "item/completed", params: { threadId: message.params.threadId, turnId, item } }); @@ -127,7 +143,20 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { assert.equal(failed.error.code, "PROVIDER_EXECUTION_ERROR"); assert.equal(failed.error.provider, "codex"); assert.equal(failed.error.retryable, false); + assert.equal(providerErrorDetail(failed.error), "fake failure"); } + const codexEvents: unknown[] = []; + const tools = await runtime.run({ + prompt: "tools", + workspaceRoot: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, + }, { onEvent: (event) => { codexEvents.push(event); } }); + assert.equal(tools.isOk() && tools.value.finalResponse, "Tests fail."); + assert.deepEqual(codexEvents, [ + { type: "tool_started", tool: "shell", summary: "pnpm test" }, + { type: "tool_completed", tool: "shell", summary: "pnpm test", status: "failed" }, + { type: "message", text: "Tests fail." }, + ]); const protocolFailure = await runtime.run({ prompt: "empty", workspaceRoot: "/tmp/project", diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 937e44481..6d063bbac 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -8,6 +8,8 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { summarizeToolInput, type LocalAgentActivityEvent } from "./local-agent-events.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; @@ -147,7 +149,15 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(threadId); - const completed = await this.rpc.runTurn(threadId, turnParams(input, threadId)); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); + let completed: CodexTurnResult; + try { + completed = await this.rpc.runTurn(threadId, turnParams(input, threadId), input.signal, callbacks?.onEvent); + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); + throw cause; + } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); const parsed = parseCompletedTurn(completed.event.params, completed.items); if (parsed.failure) { throw new AgentProviderExecutionError({ @@ -155,7 +165,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: false, - cause: completed.event.params, + cause: new Error(parsed.failure), message: "Codex agent turn failed.", }); } @@ -230,7 +240,15 @@ async function waitForProcessExit( export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; - readonly idleTimeoutMs = 5 * 60_000; + readonly providerInstanceId = "codex"; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true, streaming: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private commandResolved = false; private resolvedCommand?: ResolvedCodexCommand; @@ -240,13 +258,6 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { private readonly commandResolver: CodexCommandResolver = resolveCodexCommand, ) {} - runtimeKey(_context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand(); - const executable = command?.executable ?? this.env.CODEX_COMMAND ?? "codex"; - const codexHome = resolve(this.env.CODEX_HOME ?? join(homedir(), ".codex")); - return `codex:${executable}:${codexHome}`; - } - async createRuntime(_context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, @@ -320,6 +331,7 @@ interface CodexTurnAccumulator { threadId: string; turnId?: string; items: unknown[]; + onEvent?: (event: LocalAgentActivityEvent) => void; completed?: CodexEvent; resolve: (result: CodexTurnResult) => void; reject: (error: Error) => void; @@ -360,7 +372,12 @@ class CodexAppServerRpc { this.write({ method, ...(params === undefined ? {} : { params }) }); } - async runTurn(threadId: string, params: unknown): Promise { + async runTurn( + threadId: string, + params: unknown, + signal?: AbortSignal, + onEvent?: (event: LocalAgentActivityEvent) => void, + ): Promise { if (this.fatalError) throw this.fatalError; if (this.turns.has(threadId)) throw new Error(`Codex thread ${threadId} already has an active turn.`); let resolveTurn!: (result: CodexTurnResult) => void; @@ -372,16 +389,27 @@ class CodexAppServerRpc { const turn: CodexTurnAccumulator = { threadId, items: [], + onEvent, resolve: resolveTurn, reject: rejectTurn, }; this.turns.set(threadId, turn); + const removeAbort = bindLocalAgentAbort(signal, async () => { + if (!turn.turnId) return; + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }); + }); try { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); + if (signal?.aborted && turn.turnId) { + // A failed interrupt must not report the turn as stopped: keep waiting for the + // provider's terminal event; the manager bounds how long stop waits for it. + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }).catch(() => undefined); + } if (turn.completed) return { event: turn.completed, items: turn.items }; return await completion; } finally { + removeAbort(); if (this.turns.get(threadId) === turn) this.turns.delete(threadId); } } @@ -434,6 +462,7 @@ class CodexAppServerRpc { if (params?.item !== undefined) { turn.items.push(params.item); if (turn.items.length > MAX_TURN_ITEMS) turn.items.shift(); + if (turn.onEvent) reportCodexActivity(event.method, params.item, turn.onEvent); } if (event.method !== "turn/completed" || !turnMatchesEvent(turn, event)) return; turn.completed = event; @@ -452,6 +481,49 @@ class CodexAppServerRpc { } } +export function reportCodexActivity( + method: string, + item: unknown, + onEvent: (event: LocalAgentActivityEvent) => void, +): void { + if (method !== "item/started" && method !== "item/completed") return; + const record = asRecord(item); + if (!record) return; + if (record.type === "agentMessage" || record.type === "agent_message") { + if (method === "item/completed" && typeof record.text === "string") onEvent({ type: "message", text: record.text }); + return; + } + const tool = codexTool(record); + if (!tool) return; + if (method === "item/started") { + onEvent({ type: "tool_started", ...tool }); + return; + } + const failed = record.status === "failed" || record.status === "declined"; + onEvent({ type: "tool_completed", ...tool, status: failed ? "failed" : "completed" }); +} + +function codexTool(item: Record): { tool: string; summary?: string } | undefined { + switch (item.type) { + case "commandExecution": + return { tool: "shell", summary: summarizeToolInput(item) }; + case "fileChange": { + const changes = Array.isArray(item.changes) ? item.changes : []; + const paths = changes.map((change) => readString(change, "path")).filter(Boolean); + return { tool: "edit", ...(paths.length > 0 ? { summary: paths.join(", ") } : {}) }; + } + case "mcpToolCall": { + const server = directString(item.server); + const name = directString(item.tool) ?? "tool"; + return { tool: server ? `${server}.${name}` : name, summary: summarizeToolInput(item.arguments) }; + } + case "webSearch": + return { tool: "web_search", summary: summarizeToolInput(item) }; + default: + return undefined; + } +} + function threadParams(input: LocalAgentRunInput): Record { return { ...(input.providerSessionId ? { threadId: input.providerSessionId } : {}), diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 6cf19515a..f936c175d 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -3,11 +3,12 @@ import { isSubagentProviderEnabled, localAgentProviderConfigRevision, localAgentProviderEnvironment, + parseSubagentsConfig, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; -const config = subagentsConfigSchema.parse({ +const config = parseSubagentsConfig({ enabled: true, providers: [ { @@ -27,13 +28,14 @@ assert.deepEqual(config, { providers: [ { id: "codex", + driver: "codex", enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: false, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: false, model: "sonnet" }, ], }); assert.equal(isSubagentProviderEnabled(config, "codex"), true); @@ -63,7 +65,7 @@ assert.deepEqual(inherited, { }); assert.equal( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ enabled: true, providers: [ { id: "claude", enabled: false, model: "sonnet" }, @@ -81,7 +83,7 @@ assert.equal( ); assert.notEqual( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ ...config, providers: config.providers.map((provider) => provider.id === "codex" ? { ...provider, command: "/opt/bin/another-wrapper" } @@ -100,8 +102,59 @@ assert.throws( enabled: true, providers: [{ id: "unknown", enabled: true }], }), - /Invalid discriminator value/, + /must declare a driver/, ); +const namedInstance = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }], +}); +assert.deepEqual(namedInstance.providers[0], { + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", +}); +assert.equal(subagentProviderConfig(namedInstance, "codex-work")?.driver, "codex"); +const genericAcp = parseSubagentsConfig({ + enabled: true, + providers: [{ + id: "kiro", + driver: "acp", + enabled: true, + command: "kiro", + config: { args: ["acp"] }, + }], +}); +assert.deepEqual(genericAcp.providers[0], { + id: "kiro", + driver: "acp", + enabled: true, + command: "kiro", + config: { args: ["acp"] }, +}); +// Built-in ACP flavors derive sandbox flags from the write mode; custom args would drop them. +for (const provider of [ + { id: "cursor", driver: "cursor", enabled: true, config: { args: ["acp"] } }, + { id: "copilot-work", driver: "acp", enabled: true, command: "copilot", config: { flavor: "copilot", args: ["--acp"] } }, +]) { + assert.throws(() => parseSubagentsConfig({ enabled: true, providers: [provider] }), /config\.args applies only to generic/); +} +const legacyAcp = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "cursor", driver: "cursor", enabled: true }], +}); +assert.equal(legacyAcp.providers[0]?.driver, "acp"); +assert.deepEqual(legacyAcp.providers[0]?.config, { flavor: "cursor" }); +const namedLegacyAcp = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "cursor-work", driver: "cursor", enabled: true, command: "cursor-agent" }, + { id: "grok", driver: "codex", enabled: true }, + ], +}); +assert.equal(namedLegacyAcp.providers[0]?.driver, "acp"); +assert.deepEqual(namedLegacyAcp.providers[0]?.config, { flavor: "cursor" }, "a named legacy-driver instance keeps its flavor"); +assert.equal(namedLegacyAcp.providers[1]?.config, undefined, "a built-in id with another driver is not an ACP provider"); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -124,15 +177,20 @@ assert.throws( /Invalid environment variable name/, ); for (const id of ["opencode", "pi"] as const) { - const embedded = subagentsConfigSchema.parse({ + const embedded = parseSubagentsConfig({ enabled: true, providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); assert.equal(localAgentProviderEnvironment(embedded, id, {}).HARNESS_ENV, id); - assert.throws( - () => subagentsConfigSchema.parse({ - enabled: true, - providers: [{ id, enabled: true, command: "/opt/bin/agent" }], - }), - ); } +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "opencode", enabled: true, command: "/opt/bin/agent" }], + }), +); +const piCommand = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "pi", enabled: true, command: "/opt/bin/pi" }], +}); +assert.equal(localAgentProviderEnvironment(piCommand, "pi", {}).PI_COMMAND, "/opt/bin/pi"); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 39d42f09d..7720918c0 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,8 +1,14 @@ import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + defaultDriverForProviderId, + isLocalAgentDriverKind, + isLegacyAcpDriverKind, + LEGACY_ACP_DRIVER_KINDS, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; const environmentSchema = z.record( z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), @@ -22,24 +28,70 @@ const commandSchema = z.string() .min(1) .optional(); -const providerSchema = z.discriminatedUnion("id", [ - z.object({ - id: z.enum(["codex", "claude", "cursor", "copilot", "grok"]), - ...providerShape, - command: commandSchema, - }).strict(), - z.object({ - id: z.enum(["opencode", "pi"]), - ...providerShape, - }).strict(), -]); +const acpConfigSchema = z.object({ + args: z.array(z.string()).optional(), + flavor: z.enum(LEGACY_ACP_DRIVER_KINDS).optional(), +}).strict(); + +const inputDriverKinds = [...LOCAL_AGENT_DRIVER_KINDS, ...LEGACY_ACP_DRIVER_KINDS] as const; + +const providerSchema = z.object({ + id: z.string().trim().min(1), + driver: z.enum(inputDriverKinds).optional(), + ...providerShape, + command: commandSchema, + config: acpConfigSchema.optional(), +}).strict().superRefine((provider, context) => { + const driver = provider.driver + ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) + : defaultDriverForProviderId(provider.id); + if (!driver) { + context.addIssue({ + code: "custom", + path: ["driver"], + message: `Subagent provider instance ${provider.id} must declare a driver.`, + }); + return; + } + if (driver === "opencode" && provider.command !== undefined) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `${driver} does not support a command override.`, + }); + } + if (driver !== "acp" && provider.config !== undefined) { + context.addIssue({ + code: "custom", + path: ["config"], + message: `${driver} does not support ACP config.`, + }); + } + const flavor = provider.config?.flavor + ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) + ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + if (driver === "acp" && flavor && provider.config?.args !== undefined) { + context.addIssue({ + code: "custom", + path: ["config", "args"], + message: `${flavor} sets its own ACP arguments for each write mode; config.args applies only to generic ACP providers.`, + }); + } + if (driver === "acp" && !provider.command && !isLegacyAcpDriverKind(provider.id)) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `ACP provider instance ${provider.id} must declare a command.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), instructions: z.enum(["on-demand", "preload"]).default("on-demand"), providers: z.array(providerSchema), }).strict().superRefine((value, context) => { - const seen = new Set(); + const seen = new Set(); for (const [index, provider] of value.providers.entries()) { if (seen.has(provider.id)) { context.addIssue({ @@ -57,53 +109,91 @@ export const storedSubagentsConfigSchema = z.union([ subagentsConfigSchema, ]); -export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; +type ParsedSubagentProviderConfig = z.infer; +type ParsedSubagentsConfig = z.infer; + +export interface SubagentProviderConfig extends Omit { + driver: LocalAgentDriverKind; +} + +export interface SubagentsConfig extends Omit { + providers: SubagentProviderConfig[]; +} + export type StoredSubagentsConfig = z.infer; +export function resolveSubagentsConfig(config: ParsedSubagentsConfig): SubagentsConfig { + return { + ...config, + providers: config.providers.map((provider) => { + const driver = provider.driver + ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) + : defaultDriverForProviderId(provider.id)!; + // `driver: "cursor"` (or a built-in id) predates generic ACP; keep it as the ACP flavor. + const flavor = provider.config?.flavor + ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) + ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + return { + ...provider, + driver, + ...(driver === "acp" && flavor ? { config: { ...provider.config, flavor } } : {}), + }; + }), + }; +} + +/** Built-in ids (`cursor`, `copilot`, `grok`) imply their flavor even when a config object omits it. */ +export function subagentAcpFlavor( + provider: Pick, +): typeof LEGACY_ACP_DRIVER_KINDS[number] | undefined { + return provider.config?.flavor ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); +} + +export function parseSubagentsConfig(value: unknown): SubagentsConfig { + return resolveSubagentsConfig(subagentsConfigSchema.parse(value)); +} + export function subagentProviderConfig( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): SubagentProviderConfig | undefined { - return config.providers.find((entry) => entry.id === provider); + return config.providers.find((entry) => entry.id === providerInstanceId); } export function isSubagentProviderEnabled( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): boolean { - return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; + return config.enabled && subagentProviderConfig(config, providerInstanceId)?.enabled === true; } export function localAgentProviderEnvironment( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, inherited: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - const providerConfig = subagentProviderConfig(config, provider); + const providerConfig = subagentProviderConfig(config, providerInstanceId); const env = { ...inherited, ...providerConfig?.env }; - const commandVariable = providerCommandVariable(provider); - const command = providerConfig && "command" in providerConfig ? providerConfig.command : undefined; + const commandVariable = providerConfig ? providerCommandVariable(providerConfig.driver) : undefined; + const command = providerConfig?.command; if (commandVariable && command) env[commandVariable] = command; return env; } export function localAgentProviderEnvironmentOverrides( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): Record { - return { ...subagentProviderConfig(config, provider)?.env }; + return { ...subagentProviderConfig(config, providerInstanceId)?.env }; } -export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { - switch (provider) { +export function providerCommandVariable(driver: LocalAgentDriverKind): string | undefined { + switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; - case "cursor": return "CURSOR_COMMAND"; - case "copilot": return "COPILOT_COMMAND"; - case "grok": return "GROK_COMMAND"; + case "pi": return "PI_COMMAND"; case "opencode": - case "pi": + case "acp": return undefined; } } @@ -113,6 +203,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin .sort((left, right) => left.id.localeCompare(right.id)) .map((provider) => ({ id: provider.id, + driver: provider.driver, enabled: provider.enabled, ...(provider.model ? { model: provider.model } : {}), ...(provider.effort ? { effort: provider.effort } : {}), @@ -124,6 +215,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin ), } : {}), + ...(provider.config ? { config: provider.config } : {}), })); return createHash("sha256") .update(JSON.stringify({ enabled: config.enabled, providers })) diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 5ac32b93b..77b75b6c5 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 9; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 51a9fc477..ac1dd23f1 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -11,6 +11,10 @@ import { LocalAgentManager } from "./local-agent-manager.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { localAgentProviderConfigRevision } from "./local-agent-config.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; +import { WorkflowManager } from "./workflow-manager.js"; +import { workflowsEnabled } from "./workflow-flag.js"; +import { WorkflowStore } from "./workflow-store.js"; const config = loadConfig(); const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; @@ -30,14 +34,30 @@ const manager = new LocalAgentManager({ allowedRoots: config.allowedRoots, logger: log, subagents: config.subagents, + authorizeManagedWorkspace: workflowsEnabled() + ? (workspaceRoot, workspaceId) => isManagedWorkflowWorkspace(config, workspaceRoot, workspaceId) + : undefined, }); +// Hidden in v1.1: without the flag the daemon has no workflow manager and rejects workflow requests. +const workflows = workflowsEnabled() + ? new WorkflowManager({ + store: new WorkflowStore(paths.stateDir), + agents: manager, + loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot, { includeDisabled: true }), + subagents: config.subagents, + allowedRoots: config.allowedRoots, + config, + }) + : undefined; const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, + workflows, configRevision: localAgentProviderConfigRevision(config.subagents), onLockAcquired: () => { const reconciled = manager.reconcileActiveRuns(); if (reconciled.isErr()) throw reconciled.error; + workflows?.reconcile(); }, onClosed: () => { if (!shuttingDown) process.exit(0); }, idleShutdownMs: parseIdleShutdownMs(process.env.DEVSPACE_AGENTD_IDLE_TIMEOUT_MS), @@ -51,6 +71,7 @@ const shutdown = () => { log("error", "daemon_forced_shutdown", { activeTurns: manager.activeTurnCount, runtimeCount: manager.runtimeCount, + activeWorkflows: workflows?.activeRunCount ?? 0, }); // Active records intentionally remain durable. The next daemon startup // reconciles them to error while preserving provider continuation data. @@ -66,10 +87,12 @@ try { await daemon.start(); } catch (error) { if (error instanceof LocalAgentDaemonAlreadyRunningError) { + await workflows?.close(); await manager.close(); process.exit(0); } log("error", "daemon_start_failed", { error: error instanceof Error ? error.message : String(error) }); + await workflows?.close(); await manager.close(); console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index bd340d5b5..06aa053a4 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -5,6 +5,9 @@ import { decodeDaemonHello, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, + decodeWorkflowCall, + decodeWorkflowEventList, + decodeWorkflowRun, encodeLocalAgentDaemonResponse, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; @@ -119,7 +122,8 @@ const record = decodeAgentRecord({ workspaceId: "ws_test", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "idle", latestResponse: " response whitespace \n", createdAt: "now", @@ -168,7 +172,8 @@ const failedRecord = decodeAgentRecord({ workspaceId: "ws_error", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "error", error: "Timed out waiting for the local agent daemon.", errorCode: "DAEMON_TIMEOUT", @@ -192,9 +197,28 @@ const waitRequest = decodeLocalAgentDaemonRequest({ }); assert.equal(waitRequest.method, "agent.wait"); if (waitRequest.method !== "agent.wait") throw new Error("expected agent.wait request"); + +const stopRequest = decodeLocalAgentDaemonRequest({ + requestId: "stop", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "secret", + method: "agent.stop", + params: { + id: "agt_stop", + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + }, +}); +assert.equal(stopRequest.method, "agent.stop"); +if (stopRequest.method !== "agent.stop") throw new Error("expected agent.stop request"); +assert.equal(stopRequest.params.id, "agt_stop"); assert.deepEqual(waitRequest.params.ids, ["agt_one", "agt_two"]); assert.equal(waitRequest.params.timeoutMs, 5_000); +assert.deepEqual( + decodeAgentWaitResults([{ id: "agt_turn", status: "completed", response: "Ok.", turnId: "trn_abc12345" }]), + [{ id: "agt_turn", status: "completed", response: "Ok.", turnId: "trn_abc12345" }], +); + assert.deepEqual(decodeAgentWaitResults([ { id: "agt_one", status: "completed", response: "Done." }, { id: "agt_empty", status: "completed", response: "" }, @@ -216,3 +240,94 @@ assert.deepEqual(decodeAgentWaitResults([ error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, }, ]); + +const workflowRunRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_run", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.run", + params: { + workspaceId: "ws_test", + workspaceRoot: "/tmp/project", + name: "review", + args: { base: "main" }, + writeMode: "read_only", + }, +}); +assert.equal(workflowRunRequest.method, "workflow.run"); +if (workflowRunRequest.method !== "workflow.run") throw new Error("expected workflow.run request"); +assert.deepEqual(workflowRunRequest.params.args, { base: "main" }); +assert.throws(() => decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_ambiguous", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.run", + params: { workspaceRoot: "/tmp/project", name: "review", resume: "wfl_old" }, +}), /Exactly one workflow/); +assert.throws(() => decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.wait", + params: { + id: "wfl_test", + scope: { workspaceRoot: "/tmp/project" }, + timeoutMs: 60_001, + }, +}), /between 0 and 60000/); + +const workflowRun = decodeWorkflowRun({ + id: "wfl_test", + workspaceId: "ws_test", + workspaceRoot: "/tmp/project", + name: "review", + status: "running", + writeMode: "read_only", + concurrency: 2, + createdAt: "now", + updatedAt: "now", + callCount: 1, +}); +assert.equal(workflowRun.id, "wfl_test"); + +const workflowCall = decodeWorkflowCall({ + runId: "wfl_test", + index: 0, + agentId: "agt_test", + status: "running", + prompt: "Review this", + options: { target: "reviewer", label: "review" }, + fingerprint: "fingerprint", + workspaceRoot: "/tmp/project", + createdAt: "now", + updatedAt: "now", +}); +assert.equal(workflowCall.options.target, "reviewer"); + +const workflowEventsRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_events", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.events", + params: { + id: "wfl_test", + scope: { workspaceRoot: "/tmp/project" }, + after: 12, + }, +}); +assert.equal(workflowEventsRequest.method, "workflow.events"); +if (workflowEventsRequest.method !== "workflow.events") throw new Error("expected workflow.events request"); +assert.equal(workflowEventsRequest.params.after, 12); +assert.deepEqual(decodeWorkflowEventList([{ + sequence: 13, + runId: "wfl_test", + type: "phase_started", + data: { phase: "review" }, + createdAt: "now", +}]), [{ + sequence: 13, + runId: "wfl_test", + type: "phase_started", + data: { phase: "review" }, + createdAt: "now", +}]); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 21aeb6806..40a553677 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -4,12 +4,26 @@ import type { LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import type { + LocalAgentFailure, + LocalAgentTurnEvents, + LocalAgentTurnEventsQuery, LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; +import type { LocalAgentTurnEvent } from "./local-agent-events.js"; import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; +import type { + WorkflowAgentOptions, + WorkflowCall, + WorkflowEvent, + WorkflowRun, + WorkflowRunInput, + WorkflowStatus, + WorkflowWriteMode, +} from "./workflow-types.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentDaemonMethod = | "hello" @@ -18,6 +32,16 @@ export type LocalAgentDaemonMethod = | "agent.get" | "agent.list" | "agent.wait" + | "agent.stop" + | "agent.events" + | "workflow.run" + | "workflow.get" + | "workflow.list" + | "workflow.wait" + | "workflow.calls" + | "workflow.call" + | "workflow.events" + | "workflow.cancel" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -33,6 +57,16 @@ export type LocalAgentDaemonRequest = scope: LocalAgentWorkspaceScope; timeoutMs?: number; }> + | AgentDaemonRequestBase<"agent.stop", { id: string; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"agent.events", { id: string; scope: LocalAgentWorkspaceScope } & LocalAgentTurnEventsQuery> + | AgentDaemonRequestBase<"workflow.run", WorkflowRunInput> + | AgentDaemonRequestBase<"workflow.get", { id: string; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"workflow.list", LocalAgentWorkspaceScope> + | AgentDaemonRequestBase<"workflow.wait", { id: string; scope: LocalAgentWorkspaceScope; timeoutMs?: number }> + | AgentDaemonRequestBase<"workflow.calls", { id: string; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"workflow.call", { id: string; index: number; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"workflow.events", { id: string; scope: LocalAgentWorkspaceScope; after?: number }> + | AgentDaemonRequestBase<"workflow.cancel", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -55,6 +89,7 @@ export interface LocalAgentDaemonStatus { endpoint: string; startedAt: string; activeTurns: number; + activeWorkflows?: number; runtimeCount: number; clientConnections: number; } @@ -168,6 +203,70 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeWaitParams(params), } as LocalAgentDaemonRequest; + case "agent.stop": + return { + requestId, + protocolVersion, + authToken, + method, + params: { + id: requiredString(asRecord(params)?.id, "id"), + scope: decodeWorkspaceScope(asRecord(params)?.scope), + }, + } as LocalAgentDaemonRequest; + case "agent.events": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeEventsParams(params), + } as LocalAgentDaemonRequest; + case "workflow.run": + return { requestId, protocolVersion, authToken, method, params: decodeWorkflowRunInput(params) }; + case "workflow.get": + case "workflow.calls": + case "workflow.cancel": + return { requestId, protocolVersion, authToken, method, params: decodeWorkflowLookup(params) }; + case "workflow.events": { + const lookup = decodeWorkflowLookup(params); + const after = asRecord(params)?.after; + return { + requestId, + protocolVersion, + authToken, + method, + params: { + ...lookup, + ...(after === undefined ? {} : { after: requiredNonNegativeInteger(after, "after") }), + }, + }; + } + case "workflow.list": + return { requestId, protocolVersion, authToken, method, params: decodeWorkspaceScope(params) }; + case "workflow.wait": { + const lookup = decodeWorkflowLookup(params); + return { + requestId, + protocolVersion, + authToken, + method, + params: { ...lookup, ...decodeOptionalTimeout(params, 60_000) }, + }; + } + case "workflow.call": { + const lookup = decodeWorkflowLookup(params); + return { + requestId, + protocolVersion, + authToken, + method, + params: { + ...lookup, + index: requiredNonNegativeInteger(asRecord(params)?.index, "index"), + }, + }; + } case "daemon.logs": return { requestId, @@ -213,20 +312,28 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { const record = asRecord(value); const status = requiredString(record?.status, "status"); if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); + const driver = requiredString(record?.driver, "driver"); + if (!isLocalAgentDriverKind(driver)) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent driver."); + } return { id: requiredString(record?.id, "id"), workspaceId: optionalString(record?.workspaceId), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), profileName: requiredString(record?.profileName, "profileName"), - provider: requiredString(record?.provider, "provider"), + providerInstanceId: requiredString(record?.providerInstanceId, "providerInstanceId"), + driver, model: optionalString(record?.model), effort: optionalString(record?.effort), + writeMode: decodeWriteMode(record?.writeMode) ?? "allowed", providerSessionId: optionalString(record?.providerSessionId), status, latestResponse: typeof record?.latestResponse === "string" ? record.latestResponse : undefined, error: optionalContentString(record?.error), errorCode: optionalString(record?.errorCode), errorRetryable: optionalBoolean(record?.errorRetryable), + errorDetail: optionalContentString(record?.errorDetail), + latestTurnId: optionalString(record?.latestTurnId), createdAt: requiredString(record?.createdAt, "createdAt"), updatedAt: requiredString(record?.updatedAt, "updatedAt"), }; @@ -243,31 +350,182 @@ export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { } return value.map((entry): LocalAgentWaitResult => { const record = asRecord(entry); - const id = requiredString(record?.id, "id"); - const status = requiredString(record?.status, "status"); - switch (status) { - case "running": { - const wait = optionalString(record?.wait); - if (wait !== undefined && wait !== "timeout") { - throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait state."); - } - return { id, status, ...(wait ? { wait } : {}) }; - } - case "completed": { - const response = typeof record?.response === "string" ? record.response : undefined; - return { id, status, ...(response === undefined ? {} : { response }) }; + const turnId = optionalString(record?.turnId); + return turnId === undefined ? decodeWaitBody(record) : { ...decodeWaitBody(record), turnId }; + }); +} + +function decodeWaitBody(record: Record | undefined): LocalAgentWaitResult { + const id = requiredString(record?.id, "id"); + const status = requiredString(record?.status, "status"); + switch (status) { + case "running": { + const wait = optionalString(record?.wait); + if (wait !== undefined && wait !== "timeout") { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait state."); } - case "failed": - return { id, status, error: decodeWaitError(record?.error) }; - case "stopped": - return { - id, - status, - ...(record?.error === undefined ? {} : { error: decodeWaitError(record.error) }), - }; - default: - throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait result status."); + return { id, status, ...(wait ? { wait } : {}) }; + } + case "completed": { + const response = typeof record?.response === "string" ? record.response : undefined; + return { id, status, ...(response === undefined ? {} : { response }) }; + } + case "failed": + return { id, status, error: decodeWaitError(record?.error) }; + case "stopped": + return { + id, + status, + ...(record?.error === undefined ? {} : { error: decodeWaitError(record.error) }), + }; + default: + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait result status."); + } +} + +export function decodeTurnEvents(value: unknown): LocalAgentTurnEvents { + const record = asRecord(value); + const status = requiredString(record?.status, "status"); + if (status !== "running" && status !== "completed" && status !== "failed" && status !== "stopped") { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid turn status."); + } + if (!Array.isArray(record?.events)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid turn events."); + } + return { + agentId: requiredString(record.agentId, "agentId"), + turnId: requiredString(record.turnId, "turnId"), + status, + events: record.events.map(decodeTurnEvent), + }; +} + +function decodeTurnEvent(value: unknown): LocalAgentTurnEvent { + const record = asRecord(value); + const base = { + turnId: requiredString(record?.turnId, "event.turnId"), + seq: requiredInteger(record?.seq, "event.seq"), + at: requiredString(record?.at, "event.at"), + }; + const type = requiredString(record?.type, "event.type"); + const summary = optionalContentString(record?.summary); + switch (type) { + case "turn_started": + return { type, ...base }; + case "message": + return { type, text: requiredContentString(record?.text, "event.text"), ...base }; + case "tool_started": + return { type, tool: requiredString(record?.tool, "event.tool"), ...(summary ? { summary } : {}), ...base }; + case "tool_completed": { + const status = requiredString(record?.status, "event.status"); + if (status !== "completed" && status !== "failed") break; + return { type, tool: requiredString(record?.tool, "event.tool"), ...(summary ? { summary } : {}), status, ...base }; } + case "turn_completed": { + const status = requiredString(record?.status, "event.status"); + if (status !== "completed" && status !== "failed" && status !== "stopped") break; + const errorCode = optionalString(record?.errorCode); + const droppedEvents = typeof record?.droppedEvents === "number" ? record.droppedEvents : undefined; + return { + type, + status, + ...(errorCode ? { errorCode } : {}), + ...(droppedEvents ? { droppedEvents } : {}), + ...base, + }; + } + } + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid turn event."); +} + +export function decodeWorkflowRun(value: unknown): WorkflowRun { + const record = asRecord(value); + const status = requiredString(record?.status, "status"); + if (!isWorkflowStatus(status)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow status."); + } + const writeMode = requiredString(record?.writeMode, "writeMode"); + if (!isWorkflowWriteMode(writeMode)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow write mode."); + } + return { + id: requiredString(record?.id, "id"), + workspaceId: optionalString(record?.workspaceId), + workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), + name: requiredString(record?.name, "name"), + status, + writeMode, + concurrency: requiredNonNegativeInteger(record?.concurrency, "concurrency"), + resumeOf: optionalString(record?.resumeOf), + ...(record && "result" in record ? { result: record.result } : {}), + ...(record?.error === undefined ? {} : { error: decodeWorkflowFailure(record.error) }), + createdAt: requiredString(record?.createdAt, "createdAt"), + updatedAt: requiredString(record?.updatedAt, "updatedAt"), + callCount: requiredNonNegativeInteger(record?.callCount, "callCount"), + }; +} + +export function decodeWorkflowRunList(value: unknown): WorkflowRun[] { + if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow list."); + return value.map(decodeWorkflowRun); +} + +export function decodeWorkflowCall(value: unknown): WorkflowCall { + const record = asRecord(value); + const status = requiredString(record?.status, "status"); + if (!isWorkflowCallStatus(status)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow call status."); + } + const options = asRecord(record?.options); + if (!options) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow call options."); + const decodedOptions: WorkflowAgentOptions = { + target: requiredString(options.target, "options.target"), + model: optionalString(options.model), + effort: optionalString(options.effort), + ...(asRecord(options.schema) ? { schema: options.schema as Record } : {}), + label: optionalString(options.label), + phase: optionalString(options.phase), + ...(isWorkflowWriteMode(options.writeMode) ? { writeMode: options.writeMode } : {}), + ...(options.isolation === "worktree" ? { isolation: "worktree" } : {}), + workspace: optionalString(options.workspace), + }; + return { + runId: requiredString(record?.runId, "runId"), + index: requiredNonNegativeInteger(record?.index, "index"), + agentId: requiredString(record?.agentId, "agentId"), + ...(record?.turnId === undefined ? {} : { turnId: requiredString(record.turnId, "turnId") }), + status, + prompt: requiredContentString(record?.prompt, "prompt"), + options: decodedOptions, + fingerprint: requiredString(record?.fingerprint, "fingerprint"), + workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), + workspaceId: optionalString(record?.workspaceId), + ...(record && "result" in record ? { result: record.result } : {}), + ...(record?.error === undefined ? {} : { error: decodeWorkflowFailure(record.error) }), + reusedFrom: optionalString(record?.reusedFrom), + createdAt: requiredString(record?.createdAt, "createdAt"), + updatedAt: requiredString(record?.updatedAt, "updatedAt"), + }; +} + +export function decodeWorkflowCallList(value: unknown): WorkflowCall[] { + if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow call list."); + return value.map(decodeWorkflowCall); +} + +export function decodeWorkflowEventList(value: unknown): WorkflowEvent[] { + if (!Array.isArray(value) || value.length > 100) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow event page."); + } + return value.map((entry): WorkflowEvent => { + const record = asRecord(entry); + return { + sequence: requiredNonNegativeInteger(record?.sequence, "sequence"), + runId: requiredString(record?.runId, "runId"), + type: requiredString(record?.type, "type"), + data: record?.data, + createdAt: requiredString(record?.createdAt, "createdAt"), + }; }); } @@ -284,6 +542,9 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { endpoint: requiredString(record?.endpoint, "endpoint"), startedAt: requiredString(record?.startedAt, "startedAt"), activeTurns: requiredInteger(record?.activeTurns, "activeTurns"), + ...(record?.activeWorkflows === undefined ? {} : { + activeWorkflows: requiredNonNegativeInteger(record.activeWorkflows, "activeWorkflows"), + }), runtimeCount: requiredInteger(record?.runtimeCount, "runtimeCount"), clientConnections: requiredInteger(record?.clientConnections, "clientConnections"), }; @@ -399,15 +660,76 @@ function decodeWaitParams(value: unknown): { }; } -function decodeWaitError(value: unknown): { code: string; message: string; retryable: boolean } { +function decodeWorkflowRunInput(value: unknown): WorkflowRunInput { const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workflow input must be an object."); + const source = optionalContentString(record.source); + const name = optionalString(record.name); + const resume = optionalString(record.resume); + if ([source, name, resume].filter((entry) => entry !== undefined).length !== 1) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Exactly one workflow source, name, or resume id is required."); + } + const writeMode = record.writeMode; + if (writeMode !== undefined && writeMode !== "read_only" && writeMode !== "allowed") { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Invalid workflow write mode."); + } + return { + ...decodeWorkspaceScope(record), + ...(source === undefined ? {} : { source }), + ...(name === undefined ? {} : { name }), + ...(resume === undefined ? {} : { resume }), + ...(record.args === undefined ? {} : { args: record.args }), + ...(writeMode === undefined ? {} : { writeMode }), + }; +} + +function decodeWorkflowLookup(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } { + const record = asRecord(value); + return { + id: requiredString(record?.id, "id"), + scope: decodeWorkspaceScope(record?.scope), + }; +} + +function decodeOptionalTimeout(value: unknown, max: number): { timeoutMs?: number } { + const timeoutMs = asRecord(value)?.timeoutMs; + if (timeoutMs === undefined) return {}; + if (!Number.isSafeInteger(timeoutMs) || (timeoutMs as number) < 0 || (timeoutMs as number) > max) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Wait timeout must be an integer between 0 and ${max} milliseconds.`); + } + return { timeoutMs: timeoutMs as number }; +} + +function decodeWaitError(value: unknown): LocalAgentFailure { + const record = asRecord(value); + const detail = optionalContentString(record?.detail); return { code: requiredString(record?.code, "error.code"), message: requiredContentString(record?.message, "error.message"), retryable: optionalBoolean(record?.retryable) ?? false, + ...(detail ? { detail } : {}), + }; +} + +function decodeEventsParams(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } & LocalAgentTurnEventsQuery { + const record = asRecord(value); + const afterSeq = record?.afterSeq; + if (afterSeq !== undefined && (typeof afterSeq !== "number" || !Number.isSafeInteger(afterSeq) || afterSeq < 0)) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "afterSeq must be a non-negative integer."); + } + const turnId = optionalString(record?.turnId); + return { + id: requiredString(record?.id, "id"), + scope: decodeWorkspaceScope(record?.scope), + ...(turnId ? { turnId } : {}), + ...(afterSeq === undefined ? {} : { afterSeq }), }; } +function decodeWorkflowFailure(value: unknown): { code: string; message: string; retryable: boolean } { + return decodeWaitError(value); +} + function decodeLogsParams(value: unknown): { lines?: number } { if (value === undefined) return {}; const record = asRecord(value); @@ -430,6 +752,20 @@ function isLocalAgentStatus(value: string): value is LocalAgentStatus { return value === "starting" || value === "running" || value === "idle" || value === "error" || value === "stopped"; } +function isWorkflowStatus(value: string): value is WorkflowStatus { + return value === "starting" || value === "running" || value === "stopping" || value === "completed" + || value === "failed" || value === "cancelled" || value === "interrupted"; +} + +function isWorkflowWriteMode(value: unknown): value is WorkflowWriteMode { + return value === "read_only" || value === "allowed"; +} + +function isWorkflowCallStatus(value: string): value is WorkflowCall["status"] { + return value === "queued" || value === "running" || value === "completed" || value === "failed" + || value === "cancelled" || value === "interrupted"; +} + function requiredString(value: unknown, field: string): string { const result = optionalString(value); if (!result) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); @@ -449,6 +785,12 @@ function requiredInteger(value: unknown, field: string): number { return value; } +function requiredNonNegativeInteger(value: unknown, field: string): number { + const result = requiredInteger(value, field); + if (result < 0) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `${field} must be non-negative.`); + return result; +} + function requiredBoolean(value: unknown, field: string): boolean { if (typeof value !== "boolean") { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 88722dafa..0f3f3bdaa 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -10,7 +10,11 @@ import { localAgentDaemonEnvironment, LocalAgentClient, } from "./local-agent-client.js"; -import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; +import { + LocalAgentDaemon, + type LocalAgentDaemonManager, + type LocalAgentDaemonWorkflowManager, +} from "./local-agent-daemon.js"; import { ensureLocalAgentDaemonSecret, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -22,19 +26,56 @@ import { } from "./local-agent-daemon-protocol.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentRecord } from "./local-agent-store.js"; +import type { WorkflowCall, WorkflowEvent, WorkflowRun, WorkflowRunInput } from "./workflow-types.js"; -const root = await mkdtemp(join(tmpdir(), "devspace-agentd-test-")); +// macOS limits the complete Unix socket path to 104 bytes. +const root = await mkdtemp(join(tmpdir(), "ds-ad-")); const CONFIG_REVISION = "test-provider-config"; const record: LocalAgentRecord = { id: "agt_test", workspaceId: "ws_test", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", + writeMode: "read_only", + status: "running", + createdAt: "now", + updatedAt: "now", +}; +const workflowRun: WorkflowRun = { + id: "wfl_test", + workspaceId: "ws_test", + workspaceRoot: join(root, "project"), + name: "review", status: "running", + writeMode: "read_only", + concurrency: 1, createdAt: "now", updatedAt: "now", + callCount: 1, }; +const workflowCall: WorkflowCall = { + runId: workflowRun.id, + index: 0, + agentId: record.id, + status: "running", + prompt: "Review this", + options: { target: "reviewer" }, + fingerprint: "fingerprint", + workspaceRoot: workflowRun.workspaceRoot, + workspaceId: workflowRun.workspaceId, + createdAt: "now", + updatedAt: "now", +}; +const workflowEvent: WorkflowEvent = { + sequence: 1, + runId: workflowRun.id, + type: "phase_started", + data: { phase: "review" }, + createdAt: "now", +}; +const closeOrder: string[] = []; class FakeManager implements LocalAgentDaemonManager { activeTurnCount = 1; @@ -95,19 +136,78 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); } + async stop(_agentId: string, _scope: unknown) { + this.activeTurnCount = 0; + return Result.ok({ ...record, status: "stopped" } as LocalAgentRecord); + } + + lastEventsQuery?: unknown; + + events(agentId: string, _scope: unknown, query?: { turnId?: string; afterSeq?: number }) { + this.lastEventsQuery = query; + return Result.ok({ + agentId, + turnId: "trn_test", + status: "running" as const, + events: [ + { type: "turn_started" as const, turnId: "trn_test", seq: 1, at: "now" }, + { type: "tool_started" as const, tool: "bash", summary: "pnpm test", turnId: "trn_test", seq: 2, at: "now" }, + ], + }); + } + async evictIdle(): Promise {} async close(): Promise { + closeOrder.push("agents"); this.closed = true; this.activeTurnCount = 0; } } +class FakeWorkflows implements LocalAgentDaemonWorkflowManager { + activeRunCount = 0; + closed = false; + cancelled = false; + hangClose = false; + blockWaitUntilAbort = false; + waitAborted = false; + lastInput?: WorkflowRunInput; + async run(input: WorkflowRunInput): Promise { + this.lastInput = input; + return workflowRun; + } + get(): WorkflowRun { return workflowRun; } + list(): WorkflowRun[] { return [workflowRun]; } + async wait(_id: string, _scope: unknown, _timeoutMs?: number, signal?: AbortSignal): Promise { + if (this.blockWaitUntilAbort) { + await new Promise((resolveAbort) => { + const onAbort = () => { this.waitAborted = true; resolveAbort(); }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return workflowRun; + } + calls(): WorkflowCall[] { return [workflowCall]; } + call(): WorkflowCall { return workflowCall; } + events(): WorkflowEvent[] { return [workflowEvent]; } + async cancel(): Promise { this.cancelled = true; return { ...workflowRun, status: "cancelled" }; } + async close(): Promise { + closeOrder.push("workflows"); + if (this.hangClose) await new Promise(() => {}); + this.closed = true; + this.activeRunCount = 0; + } +} + const manager = new FakeManager(); +const workflows = new FakeWorkflows(); const daemon = new LocalAgentDaemon({ stateDir: join(root, "state"), configRevision: CONFIG_REVISION, manager, + workflows, idleShutdownMs: 60_000, }); const client = new LocalAgentClient({ @@ -173,14 +273,146 @@ try { assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ { id: record.id, status: "running" }, ]); + assert.equal(unwrap(await client.stopAgent(record.id, recordScope)).status, "stopped"); + const events = unwrap(await client.events(record.id, recordScope, { afterSeq: 0 })); + assert.deepEqual(manager.lastEventsQuery, { turnId: undefined, afterSeq: 0 }); + assert.equal(events.turnId, "trn_test"); + assert.deepEqual(events.events.map((event) => event.type), ["turn_started", "tool_started"]); + assert.equal(unwrap(await client.runWorkflow({ + ...recordScope, + name: "review", + })).id, workflowRun.id); + assert.equal(workflows.lastInput?.name, "review"); + assert.equal(workflows.lastInput?.workspaceId, recordScope.workspaceId); + assert.equal(workflows.lastInput?.workspaceRoot, recordScope.workspaceRoot); + assert.equal(unwrap(await client.getWorkflow(workflowRun.id, recordScope)).id, workflowRun.id); + assert.equal(unwrap(await client.listWorkflows(recordScope))[0]?.id, workflowRun.id); + assert.equal(unwrap(await client.waitWorkflow(workflowRun.id, recordScope, 0)).status, "running"); + assert.equal(unwrap(await client.workflowCalls(workflowRun.id, recordScope))[0]?.index, 0); + assert.equal(unwrap(await client.workflowCall(workflowRun.id, 0, recordScope)).agentId, record.id); + assert.equal(unwrap(await client.workflowEvents(workflowRun.id, recordScope))[0]?.type, "phase_started"); + assert.equal(unwrap(await client.cancelWorkflow(workflowRun.id, recordScope)).status, "cancelled"); assert.equal(unwrap(await client.status()).state, "ready"); + closeOrder.length = 0; unwrap(await client.stop()); await waitFor(() => manager.closed && !existsSync(daemon.paths.socketPath)); + assert.deepEqual(closeOrder, ["workflows", "agents"]); } finally { await daemon.close(); } +const workflowWaitStateDir = join(root, "ww"); +const workflowWaitManager = new FakeManager(); +workflowWaitManager.activeTurnCount = 0; +const workflowWaits = new FakeWorkflows(); +workflowWaits.blockWaitUntilAbort = true; +workflowWaits.activeRunCount = 1; +const workflowWaitDaemon = new LocalAgentDaemon({ + stateDir: workflowWaitStateDir, + configRevision: CONFIG_REVISION, + manager: workflowWaitManager, + workflows: workflowWaits, + idleShutdownMs: 60_000, +}); +const workflowWaitClient = new LocalAgentClient({ + stateDir: workflowWaitStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 2_000, + requestTimeoutMs: 20, + spawnDaemon: () => { void workflowWaitDaemon.start(); }, +}); +try { + const result = await workflowWaitClient.waitWorkflow(workflowRun.id, { + workspaceId: workflowRun.workspaceId, + workspaceRoot: workflowRun.workspaceRoot, + }, 0); + assert.equal(result.isErr(), true); + await waitFor(() => workflowWaits.waitAborted); + assert.equal(workflowWaits.cancelled, false, "disconnecting a wait must not cancel the workflow"); + assert.equal(workflowWaits.activeRunCount, 1); +} finally { + workflowWaits.activeRunCount = 0; + await workflowWaitDaemon.close(); +} + +// A stalled workflow shutdown must leave budget for the agent manager phase. +const stalledManager = new FakeManager(); +stalledManager.activeTurnCount = 0; +const stalledWorkflows = new FakeWorkflows(); +stalledWorkflows.hangClose = true; +const stalledDaemon = new LocalAgentDaemon({ + stateDir: join(root, "stalled-workflows"), + configRevision: CONFIG_REVISION, + manager: stalledManager, + workflows: stalledWorkflows, + shutdownTimeoutMs: 400, + idleShutdownMs: 60_000, +}); +const keepAlive = setInterval(() => {}, 50); // withTimeout timers are unref'd; a never-settling close would otherwise end the process +try { + await stalledDaemon.start(); + const stalledStart = Date.now(); + await stalledDaemon.close(); + assert.equal(stalledManager.closed, true, "agent manager must close even when workflows stall"); + assert.ok(Date.now() - stalledStart < 350, "a stalled workflow phase is capped at half the shutdown budget"); +} finally { + clearInterval(keepAlive); + await stalledDaemon.close(); +} + +const activeWorkflowStateDir = join(root, "aw"); +const activeWorkflowManager = new FakeManager(); +activeWorkflowManager.activeTurnCount = 0; +const activeWorkflows = new FakeWorkflows(); +activeWorkflows.activeRunCount = 1; +const activeWorkflowDaemon = new LocalAgentDaemon({ + stateDir: activeWorkflowStateDir, + configRevision: CONFIG_REVISION, + manager: activeWorkflowManager, + workflows: activeWorkflows, + idleShutdownMs: 20, + idleCheckIntervalMs: 5, +}); +try { + await activeWorkflowDaemon.start(); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 60)); + assert.equal(activeWorkflowManager.closed, false, "active workflows must keep the daemon alive"); + activeWorkflows.activeRunCount = 0; + await waitFor(() => activeWorkflowManager.closed); +} finally { + await activeWorkflowDaemon.close(); +} + +// Hidden in v1.1: a daemon built without a workflow manager rejects workflow requests with guidance. +const hiddenStateDir = join(root, "hidden-workflows-state"); +const hiddenManager = new FakeManager(); +hiddenManager.activeTurnCount = 0; +const hiddenDaemon = new LocalAgentDaemon({ + stateDir: hiddenStateDir, + configRevision: CONFIG_REVISION, + manager: hiddenManager, + idleShutdownMs: 60_000, +}); +const hiddenClient = new LocalAgentClient({ + stateDir: hiddenStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 2_000, + requestTimeoutMs: 2_000, + spawnDaemon: () => { void hiddenDaemon.start(); }, +}); +try { + const rejected = await hiddenClient.runWorkflow({ workspaceId: record.workspaceId, workspaceRoot: record.workspaceRoot, name: "review" }); + assert.equal(rejected.isErr(), true); + if (rejected.isErr()) { + assert.equal(rejected.error.code, "WORKFLOWS_UNAVAILABLE"); + assert.match(rejected.error.message, /DEVSPACE_EXPERIMENTAL_WORKFLOWS=1/); + } + assert.equal(unwrap(await hiddenClient.status()).activeWorkflows, 0); +} finally { + await hiddenDaemon.close(); +} + const idleStateDir = join(root, "idle-state"); const idleManager = new FakeManager(); idleManager.activeTurnCount = 0; @@ -305,10 +537,12 @@ try { const staleActiveStateDir = join(root, "sa"); const staleActiveManager = new FakeManager(); +const staleActiveWorkflows = new FakeWorkflows(); const staleActiveDaemon = new LocalAgentDaemon({ stateDir: staleActiveStateDir, configRevision: "old-provider-config", manager: staleActiveManager, + workflows: staleActiveWorkflows, idleShutdownMs: 60_000, }); let staleActiveSpawns = 0; @@ -335,6 +569,7 @@ try { assert.deepEqual(unwrap(await staleActiveClient.wait([record.id], staleScope, 0)), [ { id: record.id, status: "running" }, ]); + assert.equal(unwrap(await staleActiveClient.cancelWorkflow(workflowRun.id, staleScope)).status, "cancelled"); const blockedStart = await staleActiveClient.run({ target: "reviewer", prompt: "must use current provider config", diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index aac96e9d9..b14a6b0a6 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -37,12 +37,18 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentEventsError, + AgentStopError, AgentWaitError, + LocalAgentTurnEvents, + LocalAgentTurnEventsQuery, LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { WORKFLOWS_ENV } from "./workflow-flag.js"; +import { WorkflowError, type WorkflowCall, type WorkflowEvent, type WorkflowRun, type WorkflowRunInput } from "./workflow-types.js"; const MAX_REQUEST_BYTES = 512 * 1024; const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 30_000; @@ -61,15 +67,35 @@ export interface LocalAgentDaemonManager { timeoutMs?: number, signal?: AbortSignal, ): Promise>; + stop(agentId: string, scope: LocalAgentWorkspaceScope): Promise>; + events( + agentId: string, + scope: LocalAgentWorkspaceScope, + query?: LocalAgentTurnEventsQuery, + ): Result; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; readonly runtimeCount: number; } +export interface LocalAgentDaemonWorkflowManager { + run(input: WorkflowRunInput): Promise; + get(id: string, scope: LocalAgentWorkspaceScope): WorkflowRun; + list(scope: LocalAgentWorkspaceScope): WorkflowRun[]; + wait(id: string, scope: LocalAgentWorkspaceScope, timeoutMs?: number, signal?: AbortSignal): Promise; + calls(id: string, scope: LocalAgentWorkspaceScope): WorkflowCall[]; + call(id: string, index: number, scope: LocalAgentWorkspaceScope): WorkflowCall; + events(id: string, scope: LocalAgentWorkspaceScope, after?: number): WorkflowEvent[]; + cancel(id: string, scope: LocalAgentWorkspaceScope): Promise; + close(): Promise; + readonly activeRunCount: number; +} + export interface LocalAgentDaemonOptions { stateDir: string; manager: LocalAgentDaemonManager; + workflows?: LocalAgentDaemonWorkflowManager; configRevision: string; idleShutdownMs?: number; idleCheckIntervalMs?: number; @@ -84,6 +110,7 @@ export interface LocalAgentDaemonOptions { export class LocalAgentDaemon { readonly paths: LocalAgentDaemonPaths; private readonly manager: LocalAgentDaemonManager; + private readonly workflows?: LocalAgentDaemonWorkflowManager; private readonly configRevision: string; private readonly lock: LocalAgentDaemonLock; private readonly idleShutdownMs: number; @@ -108,6 +135,7 @@ export class LocalAgentDaemon { constructor(options: LocalAgentDaemonOptions) { this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); this.manager = options.manager; + this.workflows = options.workflows; this.configRevision = options.configRevision; this.lock = new LocalAgentDaemonLock(this.paths); this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; @@ -178,6 +206,7 @@ export class LocalAgentDaemon { endpoint: this.paths.endpoint, startedAt: this.startedAt, activeTurns: this.manager.activeTurnCount, + activeWorkflows: this.workflows?.activeRunCount ?? 0, runtimeCount: this.manager.runtimeCount, clientConnections: this.sockets.size, }; @@ -196,10 +225,15 @@ export class LocalAgentDaemon { }); for (const socket of this.sockets) socket.destroy(); this.sockets.clear(); - const [serverResult, managerResult] = await Promise.allSettled([ - withTimeout(closeServer(this.server), this.shutdownTimeoutMs, "daemon socket shutdown"), - withTimeout(this.manager.close(), this.shutdownTimeoutMs, "daemon manager shutdown"), - ]); + // One budget covers every phase (the entrypoint's force-exit timer uses the same value). + // Workflows get half so aborted runs that stall in agent stop cannot starve the agent manager. + const deadline = Date.now() + this.shutdownTimeoutMs; + const remaining = () => Math.max(0, deadline - Date.now()); + const serverResult = await settled(withTimeout(closeServer(this.server), remaining(), "daemon socket shutdown")); + const workflowResult = this.workflows + ? await settled(withTimeout(this.workflows.close(), Math.min(remaining(), this.shutdownTimeoutMs / 2), "workflow manager shutdown")) + : undefined; + const managerResult = await settled(withTimeout(this.manager.close(), remaining(), "daemon manager shutdown")); if (serverResult.status === "rejected") { writeLocalAgentDaemonLog(this.paths, "warn", "daemon_socket_close_failed", { error: errorMessage(serverResult.reason), @@ -210,6 +244,11 @@ export class LocalAgentDaemon { error: errorMessage(managerResult.reason), }); } + if (workflowResult?.status === "rejected") { + writeLocalAgentDaemonLog(this.paths, "warn", "workflow_manager_close_failed", { + error: errorMessage(workflowResult.reason), + }); + } removeLocalAgentDaemonFiles(this.paths); this.lock.release(); writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopped", {}); @@ -339,12 +378,43 @@ export class LocalAgentDaemon { request.params.timeoutMs, signal, )); + case "agent.stop": + return unwrapManagerResult(await this.manager.stop( + request.params.id, + request.params.scope, + )); + case "agent.events": + return unwrapManagerResult(this.manager.events(request.params.id, request.params.scope, { + turnId: request.params.turnId, + afterSeq: request.params.afterSeq, + })); + case "workflow.run": + return this.runWorkflowRequest(() => this.requireWorkflows().run(request.params)); + case "workflow.get": + return this.requireWorkflows().get(request.params.id, request.params.scope); + case "workflow.list": + return this.requireWorkflows().list(request.params); + case "workflow.wait": + return this.requireWorkflows().wait( + request.params.id, + request.params.scope, + request.params.timeoutMs, + signal, + ); + case "workflow.calls": + return this.requireWorkflows().calls(request.params.id, request.params.scope); + case "workflow.call": + return this.requireWorkflows().call(request.params.id, request.params.index, request.params.scope); + case "workflow.events": + return this.requireWorkflows().events(request.params.id, request.params.scope, request.params.after); + case "workflow.cancel": + return this.runWorkflowRequest(() => this.requireWorkflows().cancel(request.params.id, request.params.scope)); case "daemon.status": return this.status(); case "daemon.stop": if (request.params.ifIdle) { this.accepting = false; - if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0) { + if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0 || (this.workflows?.activeRunCount ?? 0) > 0) { this.accepting = true; throw new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", @@ -373,6 +443,23 @@ export class LocalAgentDaemon { } } + private async runWorkflowRequest(operation: () => Promise): Promise { + this.activeTurnRequests += 1; + try { + return await operation(); + } finally { + this.activeTurnRequests -= 1; + } + } + + private requireWorkflows(): LocalAgentDaemonWorkflowManager { + if (!this.workflows) throw new WorkflowError( + "WORKFLOWS_UNAVAILABLE", + `Workflows are experimental and disabled in this daemon. Set ${WORKFLOWS_ENV}=1 and restart it with \`devspace agents daemon stop\`.`, + ); + return this.workflows; + } + private writeError(socket: Socket, requestId: string, error: LocalAgentDaemonErrorPayload): void { socket.end(encodeLocalAgentDaemonResponse({ requestId, @@ -391,7 +478,8 @@ export class LocalAgentDaemon { private async maintainIdle(): Promise { await this.manager.evictIdle(this.now()); - if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 || this.sockets.size > 0) { + if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 + || (this.workflows?.activeRunCount ?? 0) > 0 || this.sockets.size > 0) { this.idleSince = undefined; return; } @@ -443,6 +531,14 @@ async function withTimeout(promise: Promise, timeoutMs: number, operation: } } +async function settled(promise: Promise): Promise> { + try { + return { status: "fulfilled", value: await promise }; + } catch (reason) { + return { status: "rejected", reason }; + } +} + function safeEqual(actual: string, expected: string): boolean { const actualBuffer = Buffer.from(actual); const expectedBuffer = Buffer.from(expected); @@ -484,6 +580,9 @@ function errorMessage(error: unknown): string { } function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { + if (error instanceof WorkflowError) { + return { code: error.code, message: error.message, retryable: error.retryable }; + } if (isLocalAgentError(error)) return toAgentErrorPayload(error); if (error instanceof LocalAgentDaemonProtocolError) { if (error.code === "PROTOCOL_MISMATCH") { diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 32c33cd77..424aa2cb4 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -5,9 +5,9 @@ import { type Result as BetterResult, } from "better-result"; import { - isLocalAgentProvider, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + type LocalAgentDriverKind, +} from "./local-agent-provider.js"; export type AgentTargetErrorCode = | "UNKNOWN_TARGET" @@ -19,7 +19,7 @@ export type AgentTargetErrorCode = export class AgentTargetError extends TaggedError("AgentTargetError")<{ code: AgentTargetErrorCode; target: string; - provider?: LocalAgentProvider; + provider?: string; operation?: string; retryable: boolean; cause?: unknown; @@ -50,7 +50,7 @@ export class AgentScopeError extends TaggedError("AgentScopeError")<{ }>() {} interface AgentProviderErrorFields extends Record { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; retryable: boolean; @@ -165,7 +165,7 @@ export interface AgentErrorPayload { code: LocalAgentError["code"]; message: string; retryable?: boolean; - provider?: LocalAgentProvider; + provider?: string; agentId?: string; workspaceId?: string; operation?: string; @@ -233,7 +233,7 @@ export function agentErrorFromPayload(payload: { target?: string; }): LocalAgentError | undefined { const retryable = payload.retryable ?? false; - const provider = payload.provider && isLocalAgentProvider(payload.provider) + const provider = payload.provider && isLocalAgentDriverKind(payload.provider) ? payload.provider : undefined; switch (payload.code) { @@ -245,7 +245,7 @@ export function agentErrorFromPayload(payload: { return new AgentTargetError({ code: payload.code, target: payload.target ?? payload.agentId ?? payload.provider ?? "unknown", - provider, + provider: payload.provider, operation: payload.operation, retryable, message: payload.message, @@ -363,7 +363,7 @@ export function agentErrorFromPayload(payload: { } export function providerErrorFromCause(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; cause: unknown; @@ -406,7 +406,7 @@ export function providerErrorFromCause(input: { } export async function captureAgentProviderResult(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; run: () => T | Promise; @@ -456,15 +456,13 @@ function unavailableCauseKind(error: unknown): "permanent" | "transient" | undef return undefined; } -function displayProvider(provider: LocalAgentProvider): string { +function displayProvider(provider: LocalAgentDriverKind): string { switch (provider) { case "codex": return "Codex"; case "claude": return "Claude"; case "opencode": return "OpenCode"; case "pi": return "Pi"; - case "cursor": return "Cursor"; - case "copilot": return "Copilot"; - case "grok": return "Grok"; + case "acp": return "ACP"; } } diff --git a/src/local-agent-events.test.ts b/src/local-agent-events.test.ts new file mode 100644 index 000000000..51d302693 --- /dev/null +++ b/src/local-agent-events.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { + providerErrorDetail, + redactSecrets, + sanitizeActivityEvent, + summarizeToolInput, +} from "./local-agent-events.js"; + +assert.equal(redactSecrets("token: ghp_abcdefghijklmnopqrstuvwxyz0123"), "token: [redacted]"); +assert.equal(redactSecrets("export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstu"), "export OPENAI_API_KEY=[redacted]"); +assert.equal(redactSecrets("deploy --password hunter2 --verbose"), "deploy --password [redacted] --verbose"); +assert.equal(redactSecrets("gh auth login --with-token 'abc def'"), "gh auth login --with-token [redacted]"); +assert.equal(redactSecrets("docker login --password-stdin"), "docker login --password-stdin"); +assert.equal(redactSecrets("pnpm test --max-tokens 4096"), "pnpm test --max-tokens 4096"); +assert.equal(redactSecrets("deploy --github-token ghx_value"), "deploy --github-token [redacted]"); +assert.equal(redactSecrets("curl -H 'Bearer abcdefghijkl'"), "curl -H 'Bearer [redacted]'"); +assert.equal(redactSecrets("git status --short"), "git status --short"); + +assert.equal(summarizeToolInput({ command: "pnpm test", timeout: 10 }), "pnpm test"); +assert.equal(summarizeToolInput({ command: ["git", "diff"] }), "git diff"); +assert.equal(summarizeToolInput({ file_path: "src/a.ts", content: "x" }), "src/a.ts"); +assert.equal(summarizeToolInput({ unknown: true }), undefined); + +const longSummary = sanitizeActivityEvent({ type: "tool_started", tool: "bash", summary: `echo ${"x".repeat(500)}\nnext` }); +assert.equal(longSummary?.type === "tool_started" && longSummary.summary?.length, 200); +assert.equal(sanitizeActivityEvent({ type: "message", text: " \n " }), undefined); + +assert.equal( + providerErrorDetail({ message: "Pi agent turn failed.", cause: new Error("429 rate limited") }), + "429 rate limited", +); +assert.equal( + providerErrorDetail({ message: "Same.", cause: new Error("Same.", { cause: "nested reason" }) }), + "nested reason", +); +assert.equal(providerErrorDetail({ message: "No cause." }), undefined); diff --git a/src/local-agent-events.ts b/src/local-agent-events.ts new file mode 100644 index 000000000..5677bebac --- /dev/null +++ b/src/local-agent-events.ts @@ -0,0 +1,141 @@ +import { randomUUID } from "node:crypto"; + +/** + * DevSpace's normalized view of what a subagent turn is doing. Drivers map + * provider-native frames into activity events; the manager owns the turn + * lifecycle events and stamps every event with the app-owned turn id. + */ +export type LocalAgentToolStatus = "completed" | "failed"; +export type LocalAgentTurnOutcome = "completed" | "failed" | "stopped"; + +export type LocalAgentActivityEvent = + | { type: "message"; text: string } + | { type: "tool_started"; tool: string; summary?: string } + | { type: "tool_completed"; tool: string; summary?: string; status: LocalAgentToolStatus }; + +export type LocalAgentTurnEventBody = + | { type: "turn_started" } + | LocalAgentActivityEvent + | { type: "turn_completed"; status: LocalAgentTurnOutcome; errorCode?: string; droppedEvents?: number }; + +export type LocalAgentTurnEvent = LocalAgentTurnEventBody & { + turnId: string; + seq: number; + at: string; +}; + +export type LocalAgentTurnEventType = LocalAgentTurnEvent["type"]; + +export const LOCAL_AGENT_TURN_EVENT_TYPES = [ + "turn_started", + "message", + "tool_started", + "tool_completed", + "turn_completed", +] as const satisfies readonly LocalAgentTurnEventType[]; + +// Bounds keep one turn's log small enough to store and print, whatever the provider emits. +export const MAX_TURN_EVENTS = 500; +export const MAX_TURN_EVENT_BYTES = 256 * 1024; +const MAX_MESSAGE_CHARS = 2_000; +const MAX_TOOL_NAME_CHARS = 80; +const MAX_SUMMARY_CHARS = 200; +const MAX_ERROR_DETAIL_CHARS = 1_000; + +export function createLocalAgentTurnId(): string { + return `trn_${randomUUID().replaceAll("-", "").slice(0, 8)}`; +} + +/** Applies redaction and size bounds to driver output before it leaves the adapter layer. */ +export function sanitizeActivityEvent(event: LocalAgentActivityEvent): LocalAgentActivityEvent | undefined { + if (event.type === "message") { + const text = boundText(redactSecrets(event.text.trim()), MAX_MESSAGE_CHARS); + return text ? { type: "message", text } : undefined; + } + const tool = boundText(redactSecrets(event.tool.trim()), MAX_TOOL_NAME_CHARS) || "tool"; + const summary = event.summary === undefined + ? undefined + : boundText(redactSecrets(singleLine(event.summary)), MAX_SUMMARY_CHARS) || undefined; + return event.type === "tool_started" + ? { type: "tool_started", tool, ...(summary ? { summary } : {}) } + : { type: "tool_completed", tool, ...(summary ? { summary } : {}), status: event.status }; +} + +/** + * Picks the most identifying argument of a tool call (a command, path, or + * query) instead of persisting the full argument object. + */ +export function summarizeToolInput(input: unknown): string | undefined { + if (typeof input === "string") return input.trim() || undefined; + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; + const record = input as Record; + for (const key of [ + "command", + "cmd", + "file_path", + "filePath", + "path", + "pattern", + "query", + "url", + "description", + ]) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value; + if (Array.isArray(value) && value.every((part) => typeof part === "string") && value.length > 0) { + return value.join(" "); + } + } + return undefined; +} + +/** The provider's own explanation of a failure, which the generic error message omits. */ +export function providerErrorDetail(error: { message: string; cause?: unknown }): string | undefined { + const seen = new Set(); + let current: unknown = error.cause; + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + const message = typeof current === "string" + ? current + : current instanceof Error || typeof (current as { message?: unknown }).message === "string" + ? String((current as { message: unknown }).message) + : undefined; + if (message?.trim() && message.trim() !== error.message) { + return boundText(redactSecrets(message.trim()), MAX_ERROR_DETAIL_CHARS); + } + current = typeof current === "object" ? (current as { cause?: unknown }).cause : undefined; + } + return undefined; +} + +const SECRET_PATTERNS: readonly RegExp[] = [ + /\b(?:sk|pk|rk)-(?:[A-Za-z0-9_-]{2,}-)*[A-Za-z0-9_-]{16,}/g, + /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}/g, + /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /\bxai-[A-Za-z0-9]{16,}/g, + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, +]; +const SECRET_ASSIGNMENT = /\b([A-Za-z0-9_]*(?:api[_-]?key|token|secret|password|passwd|authorization|credential)[A-Za-z0-9_]*)(["']?\s*[:=]\s*["']?)(?:Bearer\s+)?([^\s"',;]{4,})/gi; +const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g; +// `--password hunter2`: a secret-named flag followed by its value as the next word. +// Plural "tokens" (e.g. --max-tokens 4096) is a count, not a credential. +const SECRET_FLAG = /(--?[A-Za-z0-9_-]*(?:api[_-]?key|token(?!s)|secret|password|passwd|authorization|credential)[A-Za-z0-9_-]*\s+)(?!-)("[^"]*"|'[^']*'|\S+)/gi; + +export function redactSecrets(text: string): string { + let redacted = text; + for (const pattern of SECRET_PATTERNS) redacted = redacted.replace(pattern, "[redacted]"); + redacted = redacted.replace(BEARER, "Bearer [redacted]"); + redacted = redacted.replace(SECRET_FLAG, "$1[redacted]"); + return redacted.replace(SECRET_ASSIGNMENT, (_match, name: string, separator: string) => `${name}${separator}[redacted]`); +} + +function boundText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars - 1)}…`; +} + +function singleLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts index d4df607d9..09c851bf1 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -208,7 +208,7 @@ export function resolveGrokEffort( function grokConfigurationError(message: string): AgentProviderProtocolError { return new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", - provider: "grok", + provider: "acp", operation: "configure_session", retryable: false, message, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 0df247006..30dc5ee0b 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -5,17 +5,27 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalAgentManager } from "./local-agent-manager.js"; import { + AgentProviderCancelledError, AgentProviderExecutionError, type AgentProviderError, } from "./local-agent-errors.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; import type { LocalAgentDriver, + LocalAgentRunCallbacks, LocalAgentRunInput, LocalAgentRunResult, LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; + +const TEST_CAPABILITIES = { + sessions: { resume: true, close: false }, + turns: { interrupt: true, streaming: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: false }, +} as const; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; import type { SubagentsConfig } from "./local-agent-config.js"; @@ -38,12 +48,21 @@ const disabledProfile: LocalAgentProfile = { filePath: join(root, "disabled-reviewer.md"), disabled: true, }; +const shadowProfile: LocalAgentProfile = { + ...profile, + name: "codex-shadow", + provider: "codex-shadow", + filePath: join(root, "codex-shadow.md"), + body: "Shadow profile body.", +}; const subagents: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, + { id: "codex-shadow", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: true }, ], }; @@ -55,9 +74,30 @@ class FakeRuntime implements LocalAgentRuntime { async run( input: LocalAgentRunInput, - callbacks?: { onSessionId?: (id: string) => void | Promise }, + callbacks?: LocalAgentRunCallbacks, ): Promise> { this.inputs.push(input); + if (input.prompt.includes("stream")) { + callbacks?.onEvent?.({ type: "tool_started", tool: "bash", summary: "curl -H 'Authorization: Bearer abcdefghijklmnop' x" }); + callbacks?.onEvent?.({ type: "tool_completed", tool: "bash", summary: "curl", status: "failed" }); + callbacks?.onEvent?.({ type: "message", text: " checked it " }); + callbacks?.onEvent?.({ type: "message", text: " " }); + } + if (input.prompt.includes("flood")) { + for (let index = 0; index < 600; index += 1) { + callbacks?.onEvent?.({ type: "tool_started", tool: "read", summary: `file-${index}` }); + } + } + if (input.prompt.includes("detail-fail")) { + return Result.err(new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "codex", + operation: "run", + retryable: false, + cause: new Error("Quota exceeded for api_key=sk-test-abcdefghijklmnopqrstuvwx."), + message: "Codex agent turn failed.", + })); + } if (input.prompt.includes("early-fail")) { await callbacks?.onSessionId?.("thread_early"); return Result.err(providerFailure("provider failed after session creation")); @@ -67,6 +107,23 @@ class FakeRuntime implements LocalAgentRuntime { if (input.prompt.includes("hold")) { await new Promise((resolve) => { this.releaseHold = resolve; }); } + if (input.prompt.includes("finish-despite-stop")) { + await new Promise((resolve) => input.signal?.addEventListener("abort", () => resolve(), { once: true })); + } + if (input.prompt.includes("cancel-me")) { + await new Promise((resolve) => { + const done = () => resolve(); + if (input.signal?.aborted) done(); + else input.signal?.addEventListener("abort", done, { once: true }); + }); + return Result.err(new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider: "codex", + operation: "run", + retryable: false, + message: "codex agent turn was stopped.", + })); + } return Result.ok({ provider: this.provider, providerSessionId: "thread_test", @@ -96,14 +153,18 @@ class FakeRuntime implements LocalAgentRuntime { const runtimes = new Map(); const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, + runtimePolicy: { scope: "agent" }, + capabilities: TEST_CAPABILITIES, createRuntime: async (context) => { const runtime = new FakeRuntime(); runtimes.set(context.agentId, runtime); return Result.ok(runtime); }, }; +const workDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-work" }; +const shadowDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-shadow" }; function providerFailure(message: string): AgentProviderExecutionError { return new AgentProviderExecutionError({ @@ -115,23 +176,30 @@ function providerFailure(message: string): AgentProviderExecutionError { }); } +// Profiles added after an agent started, to exercise continuation lookups. +const lateProfiles: LocalAgentProfile[] = []; const store = new LocalAgentStore(stateDir); const stale = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); -const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" }); +const staleTurn = store.beginTurn(stale.id, { turnId: "trn_stale", prompt: "interrupted turn" }); store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, - drivers: [driver], + drivers: [driver, workDriver, shadowDriver], pool: new LocalAgentRuntimePool(), - loadProfiles: async () => [profile, disabledProfile], + loadProfiles: async () => [profile, disabledProfile, shadowProfile, ...lateProfiles], allowedRoots: [root], + authorizeManagedWorkspace: (workspaceRoot, workspaceId) => ( + workspaceId === "ws_managed" && workspaceRoot === directRoot + ), subagents, + stopTimeoutMs: 200, }); const defectStore = new LocalAgentStore(join(root, "defect-state")); @@ -156,6 +224,36 @@ await assert.rejects( ); await defectManager.close(); +let releaseQueuedProfiles!: () => void; +const queuedProfiles = new Promise((resolve) => { releaseQueuedProfiles = resolve; }); +let queuedProfileLoads = 0; +const queuedStore = new LocalAgentStore(join(root, "queued-state")); +const queuedManager = new LocalAgentManager({ + store: queuedStore, + drivers: [driver], + pool: new LocalAgentRuntimePool(), + loadProfiles: async () => { + queuedProfileLoads += 1; + if (queuedProfileLoads === 2) await queuedProfiles; + return [profile]; + }, + allowedRoots: [root], + subagents, +}); +const queued = unwrap(await queuedManager.start({ + agentId: "workflow-queued-cancel", + target: "reviewer", + prompt: "must not reach the provider", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => queuedProfileLoads === 2); +const queuedCancellation = queuedManager.stop(queued.id, scope); +releaseQueuedProfiles(); +assert.equal(unwrap(await queuedCancellation).status, "stopped"); +assert.equal(runtimes.has(queued.id), false, "queued cancellation never launches the provider runtime"); +await queuedManager.close(); + const outside = await manager.start({ target: "reviewer", prompt: "outside", @@ -165,6 +263,17 @@ const outside = await manager.start({ assert.equal(outside.isErr(), true); if (outside.isErr()) assert.equal(outside.error.code, "WORKSPACE_NOT_ALLOWED"); +const managed = unwrap(await manager.start({ + target: "reviewer", + prompt: "managed worktree", + workspaceId: "ws_managed", + workspaceRoot: directRoot, +})); +await waitFor(() => unwrap(manager.get(managed.id, { + workspaceId: "ws_managed", + workspaceRoot: directRoot, +})).status === "idle"); + const unknown = await manager.start({ target: "missing", prompt: "inspect", @@ -174,6 +283,86 @@ const unknown = await manager.start({ assert.equal(unknown.isErr(), true); if (unknown.isErr()) assert.equal(unknown.error.code, "UNKNOWN_TARGET"); +const cancellable = unwrap(await manager.start({ + target: "codex", + prompt: "cancel-me", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => getRecord(cancellable.id).status === "running"); +const stopped = unwrap(await manager.stop(cancellable.id, scope)); +assert.equal(stopped.status, "stopped"); +assert.equal(store.getLatestTurn(cancellable.id)?.status, "stopped"); +assert.equal(store.getLatestTurn(cancellable.id)?.errorCode, "PROVIDER_CANCELLED"); + +// A provider that ignores stop leaves the turn running; stop reports that instead of hanging. +const stubborn = unwrap(await manager.start({ + target: "codex", + prompt: "hold", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(stubborn.id)?.inputs.length === 1); +const stubbornStop = await manager.stop(stubborn.id, scope); +assert.equal(stubbornStop.isErr(), true); +if (stubbornStop.isErr()) { + assert.equal(stubbornStop.error.code, "AGENT_CONFLICT"); + assert.equal(stubbornStop.error.retryable, true); +} +assert.equal(getRecord(stubborn.id).status, "running"); +runtimes.get(stubborn.id)?.release(); +await waitFor(() => getRecord(stubborn.id).status === "idle"); + +// A turn that completes normally despite the stop request is not reported as stopped. +const finishing = unwrap(await manager.start({ + target: "codex", + prompt: "finish-despite-stop", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(finishing.id)?.inputs.length === 1); +const finishingStop = await manager.stop(finishing.id, scope); +assert.equal(finishingStop.isErr(), true); +if (finishingStop.isErr()) { + assert.equal(finishingStop.error.code, "AGENT_CONFLICT"); + assert.equal(finishingStop.error.retryable, false); +} + +const namedInstance = unwrap(await manager.start({ + target: "codex-work", + prompt: "inspect named instance", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +assert.equal(namedInstance.providerInstanceId, "codex-work"); +assert.equal(namedInstance.driver, "codex"); +assert.equal(namedInstance.model, "gpt-work"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); + +// A later same-name profile for another provider instance does not apply to the stored instance. +lateProfiles.push({ ...profile, name: "codex-work", provider: "claude", body: "Claude profile body." }); +unwrap(await manager.continue(namedInstance.id, "again", {}, scope)); +await waitFor(() => runtimes.get(namedInstance.id)?.inputs.length === 2); +assert.equal(runtimes.get(namedInstance.id)?.inputs[1]?.prompt, "again"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); +lateProfiles.length = 0; + +// A profile shadows a provider instance with the same name, on start and on every continuation. +const shadowed = unwrap(await manager.start({ + target: "codex-shadow", + prompt: "first", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => getRecord(shadowed.id).status === "idle"); +unwrap(await manager.continue(shadowed.id, "second", {}, scope)); +await waitFor(() => runtimes.get(shadowed.id)?.inputs.length === 2); +assert.deepEqual( + runtimes.get(shadowed.id)?.inputs.map((input) => input.prompt), + ["Shadow profile body.\n\nTask:\nfirst", "Shadow profile body.\n\nTask:\nsecond"], +); +await waitFor(() => getRecord(shadowed.id).status === "idle"); + const disabled = await manager.start({ target: "disabled-reviewer", prompt: "inspect", @@ -199,13 +388,14 @@ const disabledProvider = await manager.start({ workspaceRoot: root, }); assert.equal(disabledProvider.isErr(), true); -if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "PROVIDER_DISABLED"); +if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "UNKNOWN_TARGET"); const previouslyCreatedDisabled = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: disabledProfile.name, - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); store.update(previouslyCreatedDisabled.id, { status: "idle" }); const disabledContinuation = await manager.continue(previouslyCreatedDisabled.id, "inspect", {}, scope); @@ -236,6 +426,7 @@ const first = unwrap(await manager.start({ assert.equal(first.status, "running"); assert.equal(first.model, "gpt-default"); assert.equal(first.effort, "medium"); +assert.equal(first.writeMode, "allowed"); await waitFor(() => runtimes.get(first.id)?.inputs.length === 1); const conflict = await manager.continue(first.id, "another prompt", {}, scope); assert.equal(conflict.isErr(), true); @@ -253,6 +444,53 @@ assert.deepEqual( [{ prompt: "hold", status: "completed" }], ); +const duplicateId = "workflow-dispatch-1"; +const duplicateInput = { + agentId: duplicateId, + target: "reviewer", + prompt: "cancel-me duplicate", + workspaceId: scope.workspaceId, + workspaceRoot: root, + writeMode: "read_only" as const, +}; +const [duplicate, simultaneousRetry] = await Promise.all([ + manager.start(duplicateInput).then(unwrap), + manager.start(duplicateInput).then(unwrap), +]); +assert.equal(simultaneousRetry.id, duplicate.id); +await waitFor(() => runtimes.get(duplicate.id)?.inputs.length === 1); +const duplicateRetry = unwrap(await manager.start({ + ...duplicateInput, +})); +assert.equal(duplicateRetry.id, duplicate.id); +assert.equal(runtimes.get(duplicate.id)?.inputs.length, 1); +const mismatchedDuplicate = await manager.start({ + ...duplicateInput, + prompt: "different prompt", +}); +assert.equal(mismatchedDuplicate.isErr(), true); +if (mismatchedDuplicate.isErr()) assert.equal(mismatchedDuplicate.error.code, "AGENT_CONFLICT"); +const reconfiguredDuplicate = await manager.start({ + ...duplicateInput, + model: "gpt-other", +}); +assert.equal(reconfiguredDuplicate.isErr(), true); +if (reconfiguredDuplicate.isErr()) assert.equal(reconfiguredDuplicate.error.code, "AGENT_CONFLICT"); +const duplicateTurn = unwrap(manager.getTurn(duplicate.id, scope)); +assert.equal(duplicateTurn?.status, "running"); +const cancelledDuplicate = unwrap(await manager.stop(duplicate.id, scope)); +assert.equal(cancelledDuplicate.status, "stopped"); +assert.equal(cancelledDuplicate.errorCode, "PROVIDER_CANCELLED"); +assert.equal(getRecord(duplicate.id).status, "stopped"); +assert.equal(unwrap(await manager.stop(duplicate.id, scope)).status, "stopped"); +assert.equal(runtimes.get(first.id)?.closed, false, "cancelling one turn does not close another runtime"); + +const authorityUpgrade = await manager.continue(duplicate.id, "expand authority", { + writeMode: "allowed", +}, scope); +assert.equal(authorityUpgrade.isErr(), true); +if (authorityUpgrade.isErr()) assert.equal(authorityUpgrade.error.code, "AGENT_CONFLICT"); + const continued = unwrap(await manager.continue(first.id, "continue", { model: "gpt-run", effort: "high", @@ -261,14 +499,45 @@ assert.equal(continued.status, "running"); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).model, "gpt-run"); assert.equal(getRecord(first.id).effort, "high"); +const reduced = unwrap(await manager.continue(first.id, "reduce authority", { + writeMode: "read_only", +}, scope)); +assert.equal(reduced.writeMode, "read_only"); +await waitFor(() => getRecord(first.id).status === "idle"); +assert.equal(getRecord(first.id).writeMode, "read_only", "authority reductions persist across continuations"); +const restoreAuthority = await manager.continue(first.id, "restore authority", { + writeMode: "allowed", +}, scope); +assert.equal(restoreAuthority.isErr(), true); +if (restoreAuthority.isErr()) assert.equal(restoreAuthority.error.code, "AGENT_CONFLICT"); assert.deepEqual( store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), [ { prompt: "hold", status: "completed" }, { prompt: "continue", status: "completed" }, + { prompt: "reduce authority", status: "completed" }, ], ); +const mutableDispatchInput = { + agentId: "workflow-sticky-dispatch", + target: "reviewer", + prompt: "stable dispatch", + workspaceId: scope.workspaceId, + workspaceRoot: root, + writeMode: "allowed" as const, +}; +const mutableDispatch = unwrap(await manager.start(mutableDispatchInput)); +await waitFor(() => getRecord(mutableDispatch.id).status === "idle"); +unwrap(await manager.continue(mutableDispatch.id, "reduce stable dispatch", { + writeMode: "read_only", +}, scope)); +await waitFor(() => getRecord(mutableDispatch.id).status === "idle"); +const retriedMutableDispatch = unwrap(await manager.start(mutableDispatchInput)); +assert.equal(retriedMutableDispatch.id, mutableDispatch.id); +assert.equal(retriedMutableDispatch.writeMode, "read_only"); +assert.equal(store.listTurns(mutableDispatch.id).length, 2, "an idempotent retry never redispatches"); + const second = unwrap(await manager.start({ target: "reviewer", prompt: "second agent", @@ -277,7 +546,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); +assert.notEqual(runtimes.get(first.id), runtimes.get(second.id), "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", @@ -340,7 +609,13 @@ const timedWaitAgent = unwrap(await manager.start({ workspaceRoot: root, })); await waitFor(() => runtimes.get(timedWaitAgent.id)?.inputs.length === 1); -assert.deepEqual(unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], scope, 5)), [ +// Turn ids are random; assertions that compare whole wait results ignore them. +function withoutTurnId(results: T[]): Omit[] { + return results.map(({ turnId: _turnId, ...result }) => result); +} + +const timedWaitResults = unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], scope, 5)); +assert.deepEqual(withoutTurnId(timedWaitResults), [ { id: earlyFailure.id, status: "failed", @@ -352,6 +627,12 @@ assert.deepEqual(unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], }, { id: timedWaitAgent.id, status: "running", wait: "timeout" }, ]); +// Wait results identify the turn they describe. +assert.deepEqual( + timedWaitResults.map((result) => result.turnId), + [getRecord(earlyFailure.id).latestTurnId, getRecord(timedWaitAgent.id).latestTurnId], +); +assert.ok(timedWaitResults.every((result) => result.turnId?.startsWith("trn_"))); runtimes.get(timedWaitAgent.id)!.release(); await waitFor(() => getRecord(timedWaitAgent.id).status === "idle"); @@ -365,7 +646,7 @@ await waitFor(() => runtimes.get(cancelledWaitAgent.id)?.inputs.length === 1); const waitAbort = new AbortController(); const cancelledWait = manager.wait([cancelledWaitAgent.id], scope, undefined, waitAbort.signal); waitAbort.abort(); -assert.deepEqual(unwrap(await cancelledWait), [{ id: cancelledWaitAgent.id, status: "running" }]); +assert.deepEqual(withoutTurnId(unwrap(await cancelledWait)), [{ id: cancelledWaitAgent.id, status: "running" }]); assert.equal(getRecord(cancelledWaitAgent.id).status, "running", "cancelling a waiter must not stop its turn"); runtimes.get(cancelledWaitAgent.id)!.release(); await waitFor(() => getRecord(cancelledWaitAgent.id).status === "idle"); @@ -399,9 +680,10 @@ const directOutside = unwrap(await manager.start({ })); await waitFor(() => unwrap(manager.get(directOutside.id, { workspaceRoot: directRoot })).status === "idle"); assert.equal(directOutside.workspaceId, undefined); -assert.deepEqual(unwrap(manager.list({ workspaceRoot: directRoot })).map((record) => record.id), [ +assert.deepEqual(unwrap(manager.list({ workspaceRoot: directRoot })).map((record) => record.id).sort(), [ directOutside.id, -]); + managed.id, +].sort()); const direct = unwrap(await manager.start({ target: "reviewer", @@ -425,6 +707,87 @@ await waitFor(() => getRecord(defect.id).status === "error"); assert.equal(getRecord(defect.id).errorCode, "AGENT_INTERNAL_ERROR"); assert.notEqual(getRecord(defect.id).errorCode, "PROVIDER_EXECUTION_ERROR"); +// Each turn gets an app-owned id, and its activity is persisted as a bounded, redacted log. +const streamed = unwrap(await manager.start({ + target: "codex", + prompt: "stream", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +assert.match(streamed.latestTurnId ?? "", /^trn_[0-9a-f]{8}$/); +await manager.wait([streamed.id], scope); +const streamedEvents = unwrap(manager.events(streamed.id, scope)); +assert.equal(streamedEvents.turnId, streamed.latestTurnId); +assert.equal(streamedEvents.status, "completed"); +assert.deepEqual( + streamedEvents.events.map(({ at: _at, ...event }) => event), + [ + { type: "turn_started", turnId: streamed.latestTurnId, seq: 1 }, + { type: "tool_started", tool: "bash", summary: "curl -H 'Authorization: [redacted]' x", turnId: streamed.latestTurnId, seq: 2 }, + { type: "tool_completed", tool: "bash", summary: "curl", status: "failed", turnId: streamed.latestTurnId, seq: 3 }, + { type: "message", text: "checked it", turnId: streamed.latestTurnId, seq: 4 }, + { type: "turn_completed", status: "completed", turnId: streamed.latestTurnId, seq: 5 }, + ], +); +assert.deepEqual(unwrap(manager.events(streamed.id, scope, { afterSeq: 4 })).events.map((event) => event.seq), [5]); + +unwrap(await manager.continue(streamed.id, "second turn", {}, scope)); +await manager.wait([streamed.id], scope); +const secondTurnId = getRecord(streamed.id).latestTurnId; +assert.notEqual(secondTurnId, streamed.latestTurnId); +assert.equal(unwrap(manager.events(streamed.id, scope)).turnId, secondTurnId); +assert.equal(unwrap(manager.events(streamed.id, scope, { turnId: streamed.latestTurnId })).events.length, 5); +const foreignTurn = manager.events(stale.id, scope, { turnId: streamed.latestTurnId }); +assert.equal(foreignTurn.isErr() && foreignTurn.error.code, "AGENT_NOT_FOUND"); + +const flooded = unwrap(await manager.start({ + target: "codex", + prompt: "flood", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await manager.wait([flooded.id], scope); +const floodedEvents = unwrap(manager.events(flooded.id, scope)).events; +assert.equal(floodedEvents.length, 500); +assert.deepEqual(floodedEvents.at(-1), { + type: "turn_completed", + status: "completed", + droppedEvents: 102, + turnId: flooded.latestTurnId, + seq: 500, + at: floodedEvents.at(-1)?.at, +}); + +// A failed turn keeps the provider's own explanation, redacted, next to the generic message. +const detailed = unwrap(await manager.start({ + target: "codex", + prompt: "detail-fail", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +assert.deepEqual(withoutTurnId(unwrap(await manager.wait([detailed.id], scope))), [{ + id: detailed.id, + status: "failed", + error: { + code: "PROVIDER_EXECUTION_ERROR", + message: "Codex agent turn failed.", + retryable: false, + detail: "Quota exceeded for api_key=[redacted]", + }, +}]); +assert.equal(getRecord(detailed.id).errorDetail, "Quota exceeded for api_key=[redacted]"); +assert.deepEqual( + unwrap(manager.events(detailed.id, scope)).events.at(-1), + { + type: "turn_completed", + status: "failed", + errorCode: "PROVIDER_EXECUTION_ERROR", + turnId: detailed.latestTurnId, + seq: 2, + at: unwrap(manager.events(detailed.id, scope)).events.at(-1)?.at, + }, +); + const shuttingDown = unwrap(await manager.start({ target: "reviewer", prompt: "hold during shutdown", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 30fe1e1f4..9fdbdca33 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { AgentConflictError, + AgentProviderCancelledError, AgentScopeError, AgentStoreError, AgentTargetError, @@ -9,18 +11,29 @@ import { isProgrammerDefect, type LocalAgentError, } from "./local-agent-errors.js"; +import { + createLocalAgentTurnId, + MAX_TURN_EVENT_BYTES, + MAX_TURN_EVENTS, + providerErrorDetail, + sanitizeActivityEvent, + type LocalAgentTurnEvent, + type LocalAgentTurnEventBody, +} from "./local-agent-events.js"; import { type LocalAgentProfile, - type LocalAgentProvider, - isLocalAgentProvider, } from "./local-agent-profiles.js"; +import { localAgentCancelledError } from "./local-agent-cancellation.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import { resolveLocalAgentTarget, } from "./local-agent-targets.js"; import { + LocalAgentDispatchConflictError, type LocalAgentRecord, type LocalAgentStore, type LocalAgentTurnRecord, + type LocalAgentTurnStatus, type LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import { @@ -34,10 +47,12 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { assertAllowedPath } from "./roots.js"; import { isSubagentProviderEnabled, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; export interface StartLocalAgentInput { + agentId?: string; target: string; prompt: string; workspaceRoot: string; @@ -57,6 +72,9 @@ export interface LocalAgentManagerLogger { (level: "info" | "warn" | "error", event: string, fields: Record): void; } +// Kept below the client's default request deadline so a slow stop is reported as a conflict, not a daemon timeout. +const DEFAULT_STOP_TIMEOUT_MS = 20_000; + export interface LocalAgentManagerOptions { store: LocalAgentStore; drivers: readonly LocalAgentDriver[]; @@ -64,8 +82,10 @@ export interface LocalAgentManagerOptions { loadProfiles: (workspaceRoot: string) => Promise; agentDir?: string; allowedRoots?: readonly string[]; + authorizeManagedWorkspace?: (workspaceRoot: string, workspaceId: string) => boolean; logger?: LocalAgentManagerLogger; subagents: SubagentsConfig; + stopTimeoutMs?: number; } export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflictError | AgentStoreError; @@ -73,16 +93,40 @@ export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; export type AgentListError = AgentScopeError | AgentStoreError; export type AgentWaitError = AgentLookupError; +export type AgentStopError = AgentLookupError | AgentConflictError; +export type AgentEventsError = AgentLookupError; + +export interface LocalAgentFailure { + code: string; + message: string; + retryable: boolean; + detail?: string; +} -export type LocalAgentWaitResult = +export type LocalAgentWaitResult = { turnId?: string } & ( | { id: string; status: "running"; wait?: "timeout" } | { id: string; status: "completed"; response?: string } - | { id: string; status: "failed"; error: { code: string; message: string; retryable: boolean } } - | { id: string; status: "stopped"; error?: { code: string; message: string; retryable: boolean } }; + | { id: string; status: "failed"; error: LocalAgentFailure } + | { id: string; status: "stopped"; error?: LocalAgentFailure } +); + +export interface LocalAgentTurnEventsQuery { + /** Defaults to the agent's latest turn. */ + turnId?: string; + afterSeq?: number; +} + +export interface LocalAgentTurnEvents { + agentId: string; + turnId: string; + status: LocalAgentTurnStatus; + events: LocalAgentTurnEvent[]; +} interface ActiveLocalAgentTurn { - turnId: number; + turnRowId: number; completion: Promise; + abortController: AbortController; } /** @@ -92,26 +136,30 @@ interface ActiveLocalAgentTurn { */ export class LocalAgentManager { private readonly store: LocalAgentStore; - private readonly drivers = new Map(); + private readonly drivers = new Map(); private readonly pool: LocalAgentRuntimePool; private readonly loadProfiles: (workspaceRoot: string) => Promise; private readonly agentDir?: string; private readonly allowedRoots?: readonly string[]; + private readonly authorizeManagedWorkspace?: (workspaceRoot: string, workspaceId: string) => boolean; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; + private readonly stopTimeoutMs: number; private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; constructor(options: LocalAgentManagerOptions) { this.store = options.store; - for (const driver of options.drivers) this.drivers.set(driver.provider, driver); + for (const driver of options.drivers) this.drivers.set(driver.providerInstanceId, driver); this.pool = options.pool; this.loadProfiles = options.loadProfiles; this.agentDir = options.agentDir; this.allowedRoots = options.allowedRoots; + this.authorizeManagedWorkspace = options.authorizeManagedWorkspace; this.logger = options.logger; this.subagents = options.subagents; + this.stopTimeoutMs = options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; } reconcileActiveRuns(message?: string): BetterResult { @@ -153,16 +201,29 @@ export class LocalAgentManager { })); } yield* manager.providerEnabledResult(target.provider, target.name, "start"); - yield* manager.driverResult(target.provider, "start"); - const record = yield* manager.store.createResult({ + const driver = yield* manager.driverResult(target.provider, "start"); + const createdResult = manager.store.createResult({ + id: input.agentId, workspaceId: input.workspaceId, workspaceRoot, profileName: target.name, - provider: target.provider, + providerInstanceId: target.provider, + driver: driver.provider, model: target.model, effort: target.effort, + writeMode: input.writeMode ?? "allowed", + dispatchSignature: dispatchSignature(input, workspaceRoot, target.model, target.effort), }); - return manager.begin(record, input.prompt, { + if ( + createdResult.isErr() + && createdResult.error.cause instanceof LocalAgentDispatchConflictError + ) { + return Result.err(dispatchConflict(input.agentId ?? "unknown")); + } + const created = yield* createdResult; + // An idempotent retry of a workflow dispatch returns the existing agent. + if (!created.created) return Result.ok(created.record); + return manager.begin(created.record, input.prompt, { model: target.model, effort: target.effort, writeMode: input.writeMode, @@ -182,10 +243,11 @@ export class LocalAgentManager { const record = yield* manager.store.getByIdResult(agentId); if (!record) return Result.err(agentNotFound(agentId)); yield* manager.agentWorkspaceResult(record, scope, "continue"); + yield* manager.writeModeResult(record, overrides.writeMode); const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); yield* manager.profileForRecordResult(record, profiles); - yield* manager.providerEnabledResult(record.provider, record.profileName, "continue"); - yield* manager.driverResult(record.provider, "continue", agentId); + yield* manager.providerEnabledResult(record.providerInstanceId, record.profileName, "continue"); + yield* manager.driverResult(record.providerInstanceId, "continue", agentId, record.driver); return manager.begin(record, prompt, overrides, scope.workspaceId); }); } @@ -212,6 +274,15 @@ export class LocalAgentManager { )); } + getTurn( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): BetterResult { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + return this.store.getLatestTurnResult(agentId); + } + async wait( agentIds: readonly string[], scope: LocalAgentWorkspaceScope, @@ -231,7 +302,7 @@ export class LocalAgentManager { for (const capture of captures) { if (capture.turn?.status !== "running") continue; const active = this.activeTurns.get(capture.agent.id); - if (active?.turnId !== capture.turn.id) { + if (active?.turnRowId !== capture.turn.id) { return Result.err(new AgentStoreError( "wait", new Error(`Turn ${capture.turn.id} is not active.`), @@ -259,6 +330,88 @@ export class LocalAgentManager { return Result.ok(results); } + events( + agentId: string, + scope: LocalAgentWorkspaceScope, + query: LocalAgentTurnEventsQuery = {}, + ): BetterResult { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + const lookup = query.turnId + ? this.store.getTurnByTurnIdResult(query.turnId) + : this.store.getLatestTurnResult(agentId); + if (lookup.isErr()) return lookup; + const turn = lookup.value; + if (!turn || turn.agentId !== agentId) { + return Result.err(new AgentTargetError({ + code: "AGENT_NOT_FOUND", + target: agentId, + retryable: false, + message: query.turnId + ? `Unknown turn ${query.turnId} for subagent ${agentId}.` + : `Subagent ${agentId} has no turns.`, + })); + } + return this.store.listTurnEventsResult(turn, query.afterSeq).map((events) => ({ + agentId, + turnId: turn.turnId, + status: turn.status, + events, + })); + } + + async stop( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const record = this.get(agentId, scope); + if (record.isErr()) return record; + if (record.value.status === "stopped") return Result.ok(record.value); + const active = this.activeTurns.get(agentId); + if (!active) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: false, + message: `Agent ${agentId} does not have a running turn.`, + })); + } + const driver = this.drivers.get(record.value.providerInstanceId); + if (!driver?.capabilities.turns.interrupt) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: false, + message: `Agent ${agentId} provider does not support stopping an active turn.`, + })); + } + active.abortController.abort(); + const settled = await this.settledWithin(active.completion, this.stopTimeoutMs); + const refreshed = this.get(agentId, scope); + if (refreshed.isErr() || refreshed.value.status === "stopped") return refreshed; + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: !settled, + message: settled + ? `Agent ${agentId} finished before it could be stopped.` + : `Agent ${agentId} did not stop within ${this.stopTimeoutMs}ms; its turn is still running.`, + })); + } + + private async settledWithin(completion: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); + try { + return await Promise.race([completion.then(() => true as const, () => true as const), timeout]); + } finally { + clearTimeout(timer); + } + } + async close(): Promise { if (this.closePromise) return this.closePromise; this.accepting = false; @@ -307,38 +460,63 @@ export class LocalAgentManager { } const begun = this.store.beginTurnResult(record.id, { + turnId: createLocalAgentTurnId(), prompt, model: overrides.model ?? record.model, effort: overrides.effort ?? record.effort, + writeMode: overrides.writeMode ?? record.writeMode, }); if (begun.isErr()) return begun; + const events = new TurnEventLog(this.store, begun.value.turn, (fields) => ( + this.log("warn", "agent_turn_event_failed", { agentId: record.id, ...fields }) + )); + events.record({ type: "turn_started" }); // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. + const abortController = new AbortController(); const turn = Promise.resolve().then(() => ( - this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) + this.runTurn( + begun.value.agent, + begun.value.turn, + events, + prompt, + overrides, + workspaceId, + abortController.signal, + ) )); - this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); + this.activeTurns.set(record.id, { + turnRowId: begun.value.turn.id, + completion: turn, + abortController, + }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } private async runTurn( record: LocalAgentRecord, - turnId: number, + turn: LocalAgentTurnRecord, + events: TurnEventLog, prompt: string, overrides: RunOverrides, workspaceId?: string, + signal?: AbortSignal, ): Promise { const startedAt = Date.now(); + const turnId = turn.id; + const persistRunError = (error: LocalAgentError) => this.persistRunError(record, turnId, events, error, startedAt); this.log("info", "agent_run_started", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, + turnId: turn.turnId, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); try { const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run"); if (authorized.isErr()) { - this.persistRunError(record, turnId, authorized.error, startedAt); + persistRunError(authorized.error); return; } const workspaceRoot = authorized.value; @@ -347,26 +525,27 @@ export class LocalAgentManager { : { ...record, workspaceRoot }; const profiles = await this.loadProfilesResult(workspaceRoot, record.profileName); if (profiles.isErr()) { - this.persistRunError(record, turnId, profiles.error, startedAt); + persistRunError(profiles.error); return; } const profile = this.profileForRecordResult(record, profiles.value); if (profile.isErr()) { - this.persistRunError(record, turnId, profile.error, startedAt); + persistRunError(profile.error); return; } const input = this.buildRunInputResult(authorizedRecord, profile.value, prompt, overrides); if (input.isErr()) { - this.persistRunError(record, turnId, input.error, startedAt); + persistRunError(input.error); return; } - const driver = this.driverResult(record.provider, "run", record.id); + const driver = this.driverResult(record.providerInstanceId, "run", record.id, record.driver); if (driver.isErr()) { - this.persistRunError(record, turnId, driver.error, startedAt); + persistRunError(driver.error); return; } const context: LocalAgentRuntimeContext = { agentId: record.id, + providerInstanceId: record.providerInstanceId, provider: driver.value.provider, workspaceRoot, providerSessionId: record.providerSessionId, @@ -383,16 +562,23 @@ export class LocalAgentManager { const updated = this.store.updateResult(record.id, { providerSessionId }); if (updated.isErr()) throw updated.error; }, + onEvent: (event) => events.recordActivity(event), }; - const result = await this.pool.run(driver.value, context, input.value, callbacks); + // A stop that lands while the turn is still queued must not launch a provider runtime. + if (signal?.aborted) { + persistRunError(localAgentCancelledError(driver.value.provider, "run")); + return; + } + const result = await this.pool.run(driver.value, context, { ...input.value, signal }, callbacks); if (result.isErr()) { - this.persistRunError(record, turnId, result.error, startedAt); + persistRunError(result.error); return; } const runResult = result.value; const current = this.store.getByIdResult(record.id); if (current.isErr()) throw current.error; if (!current.value) return; + events.record({ type: "turn_completed", status: "completed" }); const updated = this.store.finishTurnResult(record.id, turnId, { providerSessionId: runResult.providerSessionId ?? current.value.providerSessionId, status: "completed", @@ -400,16 +586,18 @@ export class LocalAgentManager { }); if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { - provider: updated.value.provider, + providerInstanceId: updated.value.providerInstanceId, + driver: updated.value.driver, agentId: updated.value.id, providerSessionIdPrefix: updated.value.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), }); } catch (error) { if (isLocalAgentError(error)) { - this.persistRunError(record, turnId, error, startedAt); + persistRunError(error); return; } + events.record({ type: "turn_completed", status: "failed", errorCode: "AGENT_INTERNAL_ERROR" }); const persisted = this.store.finishTurnResult(record.id, turnId, { status: "failed", error: "Unexpected internal subagent failure.", @@ -417,7 +605,8 @@ export class LocalAgentManager { errorRetryable: false, }); this.log("error", "agent_run_failed", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -434,17 +623,23 @@ export class LocalAgentManager { private persistRunError( record: LocalAgentRecord, turnId: number, + events: TurnEventLog, error: LocalAgentError, startedAt: number, ): void { + const stopped = AgentProviderCancelledError.is(error); + const status = stopped ? "stopped" : "failed"; + events.record({ type: "turn_completed", status, errorCode: error.code }); const persisted = this.store.finishTurnResult(record.id, turnId, { - status: "failed", + status, error: error.message, errorCode: error.code, errorRetryable: error.retryable, + errorDetail: stopped ? undefined : providerErrorDetail(error), }); - this.log("error", "agent_run_failed", { - provider: record.provider, + this.log(stopped ? "info" : "error", stopped ? "agent_run_stopped" : "agent_run_failed", { + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -461,12 +656,12 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, ): BetterResult { - const isRawProvider = record.profileName === record.provider; + const isRawProvider = record.profileName === record.providerInstanceId; if (!profile && !isRawProvider) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -477,7 +672,7 @@ export class LocalAgentManager { prompt: fullPrompt, workspaceRoot: record.workspaceRoot, providerSessionId: record.providerSessionId, - writeMode: overrides.writeMode ?? "allowed", + writeMode: overrides.writeMode ?? record.writeMode, model: record.model ?? profile?.model, effort: record.effort ?? profile?.effort, modelOverrideRequested: overrides.model !== undefined, @@ -489,13 +684,17 @@ export class LocalAgentManager { record: LocalAgentRecord, profiles: readonly LocalAgentProfile[], ): BetterResult { - if (record.profileName === record.provider) return Result.ok(undefined); - const profile = profiles.find((candidate) => candidate.name === record.profileName); + // Profiles win over provider instances of the same name, matching start-time target resolution. + // A same-name profile for another provider instance would not match the runtime that executes the turn. + const profile = profiles.find( + (candidate) => candidate.name === record.profileName && candidate.provider === record.providerInstanceId, + ); + if (!profile && record.profileName === record.providerInstanceId) return Result.ok(undefined); if (!profile) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -513,47 +712,59 @@ export class LocalAgentManager { } private driverResult( - provider: string, + providerInstanceId: string, operation: string, agentId?: string, + expectedDriver?: LocalAgentDriverKind, ): BetterResult { - if (!isLocalAgentProvider(provider)) { + const driver = this.drivers.get(providerInstanceId); + if (!driver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `No local agent driver is configured for provider instance: ${providerInstanceId}.`, })); } - const driver = this.drivers.get(provider); - if (!driver) { + if (expectedDriver && driver.provider !== expectedDriver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: agentId ?? provider, - provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `Provider instance ${providerInstanceId} now uses ${driver.provider}; this agent was created with ${expectedDriver}.`, })); } return Result.ok(driver); } private providerEnabledResult( - provider: string, + providerInstanceId: string, target: string, operation: string, ): BetterResult { - if (!isLocalAgentProvider(provider)) return Result.ok(undefined); - if (isSubagentProviderEnabled(this.subagents, provider)) return Result.ok(undefined); + const configured = subagentProviderConfig(this.subagents, providerInstanceId); + if (!configured) { + return Result.err(new AgentTargetError({ + code: "PROVIDER_NOT_CONFIGURED", + target, + provider: providerInstanceId, + operation, + retryable: false, + message: `Subagent provider instance is not configured: ${providerInstanceId}.`, + })); + } + if (isSubagentProviderEnabled(this.subagents, providerInstanceId)) return Result.ok(undefined); return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", target, - provider, + provider: providerInstanceId, operation, retryable: false, - message: `Subagent provider is disabled: ${provider}.`, + message: `Subagent provider is disabled: ${providerInstanceId}.`, })); } @@ -581,6 +792,14 @@ export class LocalAgentManager { try { return Result.ok(assertAllowedPath(normalized, [...this.allowedRoots])); } catch (cause) { + // Workflow-managed workspaces live outside the allowed roots; the owner vouches for them. + if (workspaceId) { + try { + if (this.authorizeManagedWorkspace?.(normalized, workspaceId)) return Result.ok(normalized); + } catch { + // Preserve the original containment failure below. + } + } return Result.err(new AgentScopeError({ code: "WORKSPACE_NOT_ALLOWED", operation, @@ -591,6 +810,22 @@ export class LocalAgentManager { } } + private writeModeResult( + record: LocalAgentRecord, + requested: LocalAgentWriteMode | undefined, + ): BetterResult { + if (!requested || writeModeRank(requested) <= writeModeRank(record.writeMode)) { + return Result.ok(undefined); + } + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId: record.id, + operation: "continue", + retryable: false, + message: `Subagent ${record.id} cannot expand write authority from ${record.writeMode} to ${requested}.`, + })); + } + private agentWorkspaceResult( record: LocalAgentRecord, scope: LocalAgentWorkspaceScope, @@ -643,6 +878,49 @@ export function createLocalAgentManager(options: LocalAgentManagerOptions): Loca return new LocalAgentManager(options); } +/** + * Appends one turn's events to the store within fixed count and size bounds. + * The last slot is reserved so a follower always sees turn_completed. + */ +class TurnEventLog { + private seq = 0; + private bytes = 0; + private dropped = 0; + private completed = false; + + constructor( + private readonly store: LocalAgentStore, + private readonly turn: LocalAgentTurnRecord, + private readonly warn: (fields: Record) => void, + ) {} + + recordActivity(event: Parameters>[0]): void { + try { + const sanitized = sanitizeActivityEvent(event); + if (sanitized) this.record(sanitized); + } catch (error) { + // Visibility must never fail the turn it observes. + this.warn({ turnId: this.turn.turnId, error: errorMessage(error) }); + } + } + + record(event: LocalAgentTurnEventBody): void { + if (this.completed) return; + const terminal = event.type === "turn_completed"; + this.completed = terminal; + const body = terminal && this.dropped > 0 ? { ...event, droppedEvents: this.dropped } : event; + const size = JSON.stringify(body).length; + if (!terminal && (this.seq >= MAX_TURN_EVENTS - 1 || this.bytes + size > MAX_TURN_EVENT_BYTES)) { + this.dropped += 1; + return; + } + this.seq += 1; + this.bytes += size; + const appended = this.store.appendTurnEventResult(this.turn, this.seq, body, new Date().toISOString()); + if (appended.isErr()) this.warn({ turnId: this.turn.turnId, error: appended.error.message }); + } +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -665,6 +943,42 @@ function agentNotFound(agentId: string): AgentTargetError { }); } +function dispatchConflict(agentId: string): AgentConflictError { + return new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "start", + retryable: false, + message: `Subagent id ${agentId} is already owned by a different dispatch.`, + }); +} + +function writeModeRank(writeMode: LocalAgentWriteMode): number { + switch (writeMode) { + case "read_only": return 0; + case "allowed": return 1; + case "full_access": return 2; + } +} + +function dispatchSignature( + input: StartLocalAgentInput, + workspaceRoot: string, + model: string | undefined, + effort: string | undefined, +): string | undefined { + if (!input.agentId) return undefined; + return createHash("sha256").update(JSON.stringify([ + input.workspaceId ?? null, + workspaceRoot, + input.target, + input.prompt, + model ?? null, + effort ?? null, + input.writeMode ?? "allowed", + ])).digest("hex"); +} + function unique(values: readonly string[]): string[] { return [...new Set(values)]; } @@ -702,6 +1016,10 @@ async function waitForTurns( } function waitResultFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalAgentWaitResult { + return { ...waitBodyFromTurn(turn, timedOut), turnId: turn.turnId }; +} + +function waitBodyFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalAgentWaitResult { switch (turn.status) { case "running": return { id: turn.agentId, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; @@ -723,6 +1041,11 @@ function waitResultFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): Loca } function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { + const body = waitBodyFromAgent(agent, timedOut); + return agent.latestTurnId ? { ...body, turnId: agent.latestTurnId } : body; +} + +function waitBodyFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { switch (agent.status) { case "starting": case "running": @@ -741,6 +1064,7 @@ function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalA code: agent.errorCode ?? "AGENT_FAILED", message: agent.error ?? "Subagent failed without an error message.", retryable: agent.errorRetryable ?? false, + ...(agent.errorDetail ? { detail: agent.errorDetail } : {}), }, }; case "stopped": @@ -762,10 +1086,11 @@ function hasTurnFailure(turn: LocalAgentTurnRecord): boolean { return turn.error !== undefined || turn.errorCode !== undefined || turn.errorRetryable !== undefined; } -function turnFailure(turn: LocalAgentTurnRecord): { code: string; message: string; retryable: boolean } { +function turnFailure(turn: LocalAgentTurnRecord): LocalAgentFailure { return { code: turn.errorCode ?? "AGENT_FAILED", message: turn.error ?? "Subagent failed without an error message.", retryable: turn.errorRetryable ?? false, + ...(turn.errorDetail ? { detail: turn.errorDetail } : {}), }; } diff --git a/src/local-agent-opencode-server.ts b/src/local-agent-opencode-server.ts new file mode 100644 index 000000000..d3de5c440 --- /dev/null +++ b/src/local-agent-opencode-server.ts @@ -0,0 +1,116 @@ +import { createRequire } from "node:module"; +import { createServer as createNetServer } from "node:net"; +import { terminateProcessTree } from "./process-platform.js"; + +const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; +const OPENCODE_SERVER_START_ATTEMPTS = 3; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export interface OpencodeServerLike { + close(): void; +} + +export interface StartedOpencodeServer extends OpencodeServerLike { + url: string; +} + +export async function startOpencodeServer(env: NodeJS.ProcessEnv): Promise { + for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { + const port = await allocateOpencodePort(); + try { + return await launchOpencodeServer(env, port); + } catch (error) { + if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; + } + } + throw new Error("OpenCode server failed to start."); +} + +async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promise { + const detached = process.platform !== "win32"; + const child = spawn("opencode", [ + "serve", + `--hostname=${OPENCODE_SERVER_HOSTNAME}`, + `--port=${port}`, + ], { + detached, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + terminateProcessTree(child, "SIGTERM", detached); + }; + const url = await new Promise((resolve, reject) => { + let output = ""; + let ready = false; + const timer = setTimeout(() => { + if (ready) return; + close(); + reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); + }, OPENCODE_SERVER_START_TIMEOUT_MS); + timer.unref(); + const inspect = (chunk: Buffer | string) => { + if (ready) return; + output += chunk.toString(); + for (const line of output.split("\n")) { + const match = line.match(/(?:opencode )?server listening on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) continue; + ready = true; + clearTimeout(timer); + resolve(match[1]); + return; + } + }; + child.stdout?.on("data", inspect); + child.stderr?.on("data", inspect); + child.once("error", (error) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(error); + }); + child.once("exit", (code) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); + }); + }); + return { url, close }; +} + +async function allocateOpencodePort(): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate an OpenCode server port.")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + +async function isOpencodePortInUse(port: number): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(true); + else reject(error); + }); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { + server.close((error) => error ? reject(error) : resolve(false)); + }); + }); +} diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts new file mode 100644 index 000000000..5485462aa --- /dev/null +++ b/src/local-agent-opencode-v2.test.ts @@ -0,0 +1,317 @@ +import assert from "node:assert/strict"; +import { + extractOpenCodeV2FinalResponse, + OpencodeV2Runtime, + opencodeV2Permissions, + opencodeV2ServerEnvironment, + parseOpenCodeV2Model, + type OpencodeV2ClientLike, +} from "./local-agent-opencode-v2.js"; +import type { SessionMessageAssistant } from "@opencode/client"; +import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; + +const createInputs: unknown[] = []; +const updateInputs: unknown[] = []; +const switchModelInputs: unknown[] = []; +const promptInputs: unknown[] = []; +const waitInputs: unknown[] = []; +const messageInputs: unknown[] = []; +let closeCalls = 0; + +const assistant: SessionMessageAssistant = { + id: "msg_assistant", + time: { created: 1, completed: 2 }, + type: "assistant", + agent: "build", + model: { id: "sonnet", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "hello" }, + { type: "text", text: " world" }, + ], + finish: "stop", +}; + +const client = { + server: { + async info() { return { version: "2.0.20", pid: 1, urls: [], paths: { tmp: "/tmp" } }; }, + }, + session: { + async create(input: unknown) { + createInputs.push(input); + return { + id: "ses_v2", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async update(input: unknown) { updateInputs.push(input); }, + async get() { + return { + id: "ses_v2", + projectID: "project", + model: { id: "sonnet", providerID: "anthropic" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async switchModel(input: unknown) { switchModelInputs.push(input); }, + async prompt(input: unknown) { + promptInputs.push(input); + return { id: "inbox", sessionID: "ses_v2", time: { created: 1 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + async wait(input: unknown) { waitInputs.push(input); }, + }, + message: { + async list(input: unknown) { + messageInputs.push(input); + return { data: [assistant], cursor: { previous: null, next: null } }; + }, + }, + model: { + async default() { + return { + location: { directory: "/tmp/project" }, + data: { + id: "default-model", + modelID: "default-model", + providerID: "openrouter", + name: "Default", + capabilities: {}, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1, output: 1 }, + }, + }; + }, + }, +} as unknown as OpencodeV2ClientLike; + +const runtime = new OpencodeV2Runtime(client, { + close: () => { closeCalls += 1; }, +}); +let sessionId: string | undefined; +const first = await runtime.run({ + prompt: "first", + workspaceRoot: "/tmp/project", + writeMode: "read_only", + model: "anthropic/sonnet", + effort: "high", +}, { + onSessionId: (id) => { sessionId = id; }, +}); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.equal(sessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.providerSessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.finalResponse, "hello world"); +assert.deepEqual(createInputs[0], { + location: { directory: "/tmp/project" }, + permissions: opencodeV2Permissions("read_only"), + model: { providerID: "anthropic", id: "sonnet", variant: "high" }, +}); +assert.equal(updateInputs.length, 0, "new sessions are configured at creation"); +assert.equal(switchModelInputs.length, 0, "new sessions select their model at creation"); +assert.deepEqual(promptInputs[0], { sessionID: "ses_v2", text: "first" }); +assert.deepEqual(waitInputs[0], { sessionID: "ses_v2" }); +assert.deepEqual(messageInputs[0], { sessionID: "ses_v2", limit: 1, order: "desc", type: "assistant" }); + +const continued = await runtime.run({ + prompt: "continued", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", + writeMode: "allowed", + effort: "low", +}); +assert.equal(continued.isOk(), true); +if (continued.isErr()) throw continued.error; +assert.deepEqual(updateInputs[0], { + sessionID: "ses_v2", + permissions: opencodeV2Permissions("allowed"), +}); +assert.deepEqual(switchModelInputs[0], { + sessionID: "ses_v2", + model: { id: "sonnet", providerID: "anthropic", variant: "low" }, +}); + +const wrongGeneration = await runtime.run({ + prompt: "legacy continuation", + workspaceRoot: "/tmp/project", + providerSessionId: "ses_v1_legacy", +}); +assert.equal(wrongGeneration.isErr(), true); +if (wrongGeneration.isErr()) { + assert.equal(wrongGeneration.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(wrongGeneration.error.message, /different OpenCode protocol generation/); +} + +assert.deepEqual(parseOpenCodeV2Model("anthropic/sonnet", "high"), { + providerID: "anthropic", + id: "sonnet", + variant: "high", +}); +assert.deepEqual(parseOpenCodeV2Model("custom-model"), { + providerID: "opencode", + id: "custom-model", +}); +assert.equal(extractOpenCodeV2FinalResponse(assistant), "hello world"); + +assert.deepEqual(opencodeV2Permissions("full_access"), [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2Permissions("read_only").slice(-3), [ + { action: "external_directory", resource: "*", effect: "deny" }, + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2ServerEnvironment({ + PATH: "/bin", + OPENCODE_PASSWORD: "ambient-v2", + OPENCODE_SERVER_PASSWORD: "ambient-v1", +}, "generated"), { + PATH: "/bin", + OPENCODE_PASSWORD: "generated", +}); + +await runtime.close(); +await runtime.close(); +assert.equal(closeCalls, 1); + +let v1FactoryCalls = 0; +let v2FactoryCalls = 0; +const routedDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + v1FactoryCalls += 1; + throw new Error("v1 factory must not be selected for OpenCode 2"); + }, + v2Factory: async () => { + v2FactoryCalls += 1; + return { client, server: { close: () => undefined } }; + }, + probeRuntime: async () => ({ generation: "v2", version: "2.0.20" }), +}); +const routedPool = new LocalAgentRuntimePool(); +const routed = await routedPool.run(routedDriver, { + agentId: "agt_v2", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +}, { + prompt: "route through v2", + workspaceRoot: "/tmp/project", +}); +assert.equal(routed.isOk(), true); +assert.equal(v1FactoryCalls, 0); +assert.equal(v2FactoryCalls, 1); +await routedPool.close(); + +// A runtime created after OpenCode is upgraded must follow the installed executable, not a stale probe. +let installedGeneration: "v1" | "v2" = "v1"; +const calls = { v1: 0, v2: 0 }; +const upgradingDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + calls.v1 += 1; + throw new Error("stop after routing"); + }, + v2Factory: async () => { + calls.v2 += 1; + throw new Error("stop after routing"); + }, + probeRuntime: async () => ({ generation: installedGeneration, version: "x.y.z" }), +}); +const upgradeContext = { + agentId: "agt_upgrade", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +} as const; +await upgradingDriver.createRuntime(upgradeContext); +installedGeneration = "v2"; +await upgradingDriver.createRuntime(upgradeContext); +assert.deepEqual(calls, { v1: 1, v2: 1 }); + +// A turn that fails before any assistant output surfaces the provider's error, not a protocol error. +const failedIdle = { + id: "msg_idle", + time: { created: 3 }, + type: "idle", + outcome: "failed", + error: { type: "provider.auth", message: "xAI request failed (400): invalid_grant" }, +}; +const failedClient = { + ...client, + message: { + async list(input: { type?: string }) { + return { data: input.type === "assistant" ? [] : [failedIdle], cursor: { previous: null, next: null } }; + }, + }, +} as unknown as OpencodeV2ClientLike; +const failedRuntime = new OpencodeV2Runtime(failedClient, { close: () => undefined }); +const failedRun = await failedRuntime.run({ prompt: "auth fails", workspaceRoot: "/tmp/project" }); +assert.equal(failedRun.isErr(), true); +if (failedRun.isErr()) { + assert.equal(failedRun.error.code, "PROVIDER_EXECUTION_ERROR"); + assert.match(failedRun.error.message, /invalid_grant/); +} +await failedRuntime.close(); + +// A continued turn that fails before output must not return the previous turn's response. +const staleClient = { + ...client, + session: { + ...client.session, + async prompt() { + return { id: "inbox", sessionID: "ses_v2", time: { created: 5 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + }, + message: { + async list(input: { type?: string }) { + return { data: input.type === "assistant" ? [assistant] : [failedIdle], cursor: { previous: null, next: null } }; + }, + }, +} as unknown as OpencodeV2ClientLike; +const staleRuntime = new OpencodeV2Runtime(staleClient, { close: () => undefined }); +const staleRun = await staleRuntime.run({ + prompt: "continued but failing", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", +}); +assert.equal(staleRun.isErr(), true); +if (staleRun.isErr()) assert.match(staleRun.error.message, /invalid_grant/); +await staleRuntime.close(); + +// Stop ends the turn only once OpenCode confirms the interrupt; a failed interrupt leaves it running. +const interruptRun = async (interrupt: () => Promise) => { + const interruptClient = { + ...client, + session: { + ...client.session, + interrupt, + async wait(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, + } as unknown as OpencodeV2ClientLike; + const interruptRuntime = new OpencodeV2Runtime(interruptClient, { close: () => undefined }, 100); + const stop = new AbortController(); + const run = interruptRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); + setTimeout(() => stop.abort(), 10); + const result = await run; + await interruptRuntime.close(); + return result.isErr() ? result.error.code : "ok"; +}; +assert.equal(await interruptRun(async () => ({ interrupted: true })), "PROVIDER_CANCELLED"); +assert.equal(await interruptRun(async () => { throw new Error("interrupt failed"); }), "PROVIDER_PROTOCOL_ERROR"); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts new file mode 100644 index 000000000..2f5ce3da4 --- /dev/null +++ b/src/local-agent-opencode-v2.ts @@ -0,0 +1,381 @@ +import { randomBytes } from "node:crypto"; +import { + OpenCode, + type ModelRef, + type OpenCodeClient, + type PermissionRule, + type SessionMessageAssistant, +} from "@opencode/client"; +import { + AgentProviderExecutionError, + AgentProviderProtocolError, + AgentProviderUnavailableError, + captureAgentProviderResult, +} from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import type { + LocalAgentRunCallbacks, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + encodeOpenCodeV2SessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; + +export type OpencodeV2ClientLike = Pick; + +export type OpencodeV2Factory = ( + context?: LocalAgentRuntimeContext, + env?: NodeJS.ProcessEnv, +) => Promise<{ + client: OpencodeV2ClientLike; + server: OpencodeServerLike; +}>; + +export class OpencodeV2Runtime implements LocalAgentRuntime { + readonly provider = "opencode" as const; + private alive = true; + private closed = false; + private readonly promptControllers = new Set(); + + constructor( + private readonly client: OpencodeV2ClientLike, + private readonly server: OpencodeServerLike, + private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, + ) {} + + async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "run", + run: async (): Promise => { + if (!this.alive) { + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + message: "OpenCode runtime is not running.", + }); + } + try { + await this.client.server.info(); + const continuing = input.providerSessionId !== undefined; + const nativeSessionId = input.providerSessionId + ? requireOpenCodeV2NativeSessionId(input.providerSessionId) + : await this.createSession(input); + const providerSessionId = encodeOpenCodeV2SessionId(nativeSessionId); + await callbacks?.onSessionId?.(providerSessionId); + if (continuing) await this.configureSession(nativeSessionId, input); + const { promptedAt, messages } = await this.prompt(nativeSessionId, input); + // On a continued session the latest assistant message may predate this + // prompt when the new turn failed before producing output. + const fresh = messages.data.filter((message) => message.time.created >= promptedAt); + if (fresh.length === 0) await this.assertTurnNotFailed(nativeSessionId); + const assistant = requireLatestAssistant(fresh); + assertOpenCodeV2AssistantSucceeded(assistant); + return { + provider: this.provider, + providerSessionId, + finalResponse: requireOpenCodeV2FinalResponse(assistant), + items: fresh, + }; + } catch (error) { + if (isOpenCodeTransportFailure(error)) { + this.alive = false; + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + cause: error, + message: "OpenCode provider is unavailable.", + }); + } + throw error; + } + }, + }); + } + + async releaseSession(_providerSessionId: string): Promise { + // OpenCode keeps durable sessions independently of this process. + } + + isAlive(): boolean { + return this.alive && !this.closed; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.alive = false; + for (const controller of this.promptControllers) controller.abort(); + this.promptControllers.clear(); + this.server.close(); + } + + /** + * A turn that fails before any assistant output (for example provider auth) + * is recorded as a failed `idle` message, which the assistant-only listing hides. + */ + private async assertTurnNotFailed(sessionId: string): Promise { + const recent = await this.client.message.list({ sessionID: sessionId, limit: 5, order: "desc" }); + const idle = recent.data.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "idle" + )) as { outcome?: string; error?: { type?: string; message?: string } } | undefined; + if (idle?.outcome !== "failed") return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: false, + cause: idle.error, + message: idle.error?.message || "OpenCode turn failed before producing a response.", + }); + } + + private async createSession(input: LocalAgentRunInput): Promise { + const model = await resolveOpenCodeV2Model(this.client, input); + const session = await this.client.session.create({ + location: { directory: input.workspaceRoot }, + permissions: opencodeV2Permissions(input.writeMode), + ...(model ? { model } : {}), + }); + if (!session.id) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "create_session", + retryable: false, + message: "OpenCode did not return a session id.", + }); + } + return session.id; + } + + private async configureSession(sessionId: string, input: LocalAgentRunInput): Promise { + await this.client.session.update({ + sessionID: sessionId, + permissions: opencodeV2Permissions(input.writeMode), + }); + if (!input.model && !input.effort) return; + const current = await this.client.session.get({ sessionID: sessionId }); + const model = await resolveOpenCodeV2Model(this.client, input, current.model); + if (!model) return; + await this.client.session.switchModel({ sessionID: sessionId, model }); + } + + private async prompt(sessionId: string, input: LocalAgentRunInput) { + const controller = new AbortController(); + this.promptControllers.add(controller); + let timedOut = false; + // End the local request only once OpenCode confirms the interrupt (`interrupted: false` + // means the session was already idle). A failed or stalled interrupt keeps the turn + // running, and the manager reports the stop as a conflict. + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + await this.client.session.interrupt({ sessionID: sessionId }); + controller.abort(); + }); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.promptTimeoutMs); + try { + const prompted = await this.client.session.prompt({ + sessionID: sessionId, + text: input.prompt, + }, { signal: controller.signal }); + await this.client.session.wait({ sessionID: sessionId }, { signal: controller.signal }); + const messages = await this.client.message.list({ + sessionID: sessionId, + limit: 1, + order: "desc", + type: "assistant", + }, { signal: controller.signal }); + return { promptedAt: prompted.time.created, messages }; + } catch (error) { + // A provider timeout after a stop request is still a timeout: the interrupt was never confirmed. + if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); + if (!timedOut) throw error; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "prompt", + retryable: true, + cause: error, + message: "OpenCode did not finish the prompt before the provider timeout.", + }); + } finally { + clearTimeout(timer); + removeAbort(); + this.promptControllers.delete(controller); + } + } +} + +export async function defaultOpencodeV2Factory( + _context?: LocalAgentRuntimeContext, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ client: OpencodeV2ClientLike; server: OpencodeServerLike }> { + const password = randomBytes(32).toString("base64url"); + const server = await startOpencodeServer(opencodeV2ServerEnvironment(env, password)); + const authorization = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; + return { + client: OpenCode.make({ + baseUrl: server.url, + headers: { authorization }, + }), + server, + }; +} + +export function opencodeV2ServerEnvironment( + env: NodeJS.ProcessEnv, + password: string, +): NodeJS.ProcessEnv { + const { + OPENCODE_PASSWORD: _opencodePassword, + OPENCODE_SERVER_PASSWORD: _legacyPassword, + ...rest + } = env; + return { ...rest, OPENCODE_PASSWORD: password }; +} + +export function opencodeV2Permissions( + writeMode: LocalAgentRunInput["writeMode"], +): PermissionRule[] { + const rules: PermissionRule[] = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, + ]; + if (writeMode !== "full_access") { + rules.push({ action: "external_directory", resource: "*", effect: "deny" }); + } + if (writeMode === "read_only") { + rules.push( + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, + ); + } + return rules; +} + +async function resolveOpenCodeV2Model( + client: OpencodeV2ClientLike, + input: LocalAgentRunInput, + current?: ModelRef, +): Promise { + if (input.model) return parseOpenCodeV2Model(input.model, input.effort); + if (!input.effort) return undefined; + if (current) return { ...current, variant: input.effort }; + const fallback = await client.model.default({ + location: { directory: input.workspaceRoot }, + }); + if (!fallback.data) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "select_model", + retryable: false, + message: "OpenCode has no default model to apply the requested effort variant to.", + }); + } + return { + id: fallback.data.id, + providerID: fallback.data.providerID, + variant: input.effort, + }; +} + +export function parseOpenCodeV2Model(model: string, effort?: string): ModelRef { + const separator = model.indexOf("/"); + return { + providerID: separator === -1 ? "opencode" : model.slice(0, separator), + id: separator === -1 ? model : model.slice(separator + 1), + ...(effort ? { variant: effort } : {}), + }; +} + +export function extractOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + return message.content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("") + .trim(); +} + +function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + const response = extractOpenCodeV2FinalResponse(message); + if (!response) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return a final assistant response.", + }); + } + return response; +} + +function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { + const assistant = messages.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "assistant" + )); + if (!assistant) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return an assistant message after the prompt completed.", + }); + } + return assistant as SessionMessageAssistant; +} + +function assertOpenCodeV2AssistantSucceeded(message: SessionMessageAssistant): void { + if (!message.error) return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: message.error.status === 429 || (message.error.status ?? 0) >= 500, + cause: message.error, + message: message.error.message || `OpenCode returned ${message.error.type}.`, + }); +} + +function isOpenCodeTransportFailure(error: unknown): boolean { + const code = transportErrorCode(error); + return code === "ECONNREFUSED" + || code === "ECONNRESET" + || code === "EPIPE" + || code === "ENETDOWN" + || code === "ENETUNREACH" + || code === "ETIMEDOUT"; +} + +function transportErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string") return code; + const cause = (error as Error & { cause?: unknown }).cause; + return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" + ? (cause as NodeJS.ErrnoException).code + : undefined; +} diff --git a/src/local-agent-opencode-version.test.ts b/src/local-agent-opencode-version.test.ts new file mode 100644 index 000000000..8ef59810d --- /dev/null +++ b/src/local-agent-opencode-version.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { + classifyOpenCodeCliVersion, + encodeOpenCodeV2SessionId, + requireOpenCodeV1NativeSessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +assert.deepEqual(classifyOpenCodeCliVersion("1.18.32\n"), { + generation: "v1", + version: "1.18.32", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.0.20\n"), { + generation: "v2", + version: "2.0.20", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.1.0-beta.2\n"), { + generation: "v2", + version: "2.1.0-beta.2", +}); +assert.equal(classifyOpenCodeCliVersion("opencode unknown"), undefined); + +assert.equal(encodeOpenCodeV2SessionId("ses_v2"), "opencode:v2:ses_v2"); +assert.equal(requireOpenCodeV2NativeSessionId("opencode:v2:ses_v2"), "ses_v2"); +assert.equal(requireOpenCodeV1NativeSessionId("ses_v1"), "ses_v1"); +assert.throws( + () => requireOpenCodeV2NativeSessionId("ses_v1"), + /different OpenCode protocol generation/, +); +assert.throws( + () => requireOpenCodeV1NativeSessionId("opencode:v2:ses_v2"), + /different OpenCode protocol generation/, +); diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts new file mode 100644 index 000000000..d4e8e452d --- /dev/null +++ b/src/local-agent-opencode-version.ts @@ -0,0 +1,131 @@ +import { createRequire } from "node:module"; +import { + AgentProviderProtocolError, + AgentProviderUnavailableError, +} from "./local-agent-errors.js"; + +const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 4_000; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export type OpenCodeGeneration = "v1" | "v2"; + +export interface ProbedOpenCodeRuntime { + generation: OpenCodeGeneration; + version: string; +} + +export type OpenCodeRuntimeProbeFunction = () => Promise; + +const OPENCODE_V2_SESSION_PREFIX = "opencode:v2:"; + +export function classifyOpenCodeCliVersion(output: string): ProbedOpenCodeRuntime | undefined { + const match = output.trim().match(/(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][^\s]+)?)(?:$|\s)/); + if (!match?.[1] || !match[2]) return undefined; + const major = Number(match[2]); + const version = match[1]; + return { + generation: major >= 2 ? "v2" : "v1", + version, + }; +} + +export function encodeOpenCodeV2SessionId(nativeSessionId: string): string { + return `${OPENCODE_V2_SESSION_PREFIX}${nativeSessionId}`; +} + +export function requireOpenCodeV2NativeSessionId(providerSessionId: string): string { + if (providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) { + const nativeSessionId = providerSessionId.slice(OPENCODE_V2_SESSION_PREFIX.length); + if (nativeSessionId) return nativeSessionId; + } + throw incompatibleOpenCodeSession("v2", providerSessionId); +} + +export function requireOpenCodeV1NativeSessionId(providerSessionId: string): string { + if (!providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) return providerSessionId; + throw incompatibleOpenCodeSession("v1", providerSessionId); +} + +function incompatibleOpenCodeSession( + generation: OpenCodeGeneration, + providerSessionId: string, +): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "resume_session", + retryable: false, + message: `OpenCode ${generation} cannot resume session ${providerSessionId}; the session belongs to a different OpenCode protocol generation. Start a new agent after changing OpenCode major versions.`, + }); +} + +export async function probeOpenCodeBinary( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const output = await runOpenCodeVersion(env); + const result = classifyOpenCodeCliVersion(output); + if (result) return result; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "probe_version", + retryable: false, + message: "Unable to determine the installed OpenCode version from `opencode --version`.", + }); +} + +function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn("opencode", ["--version"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT_MS}ms.`, + })); + }, OPENCODE_VERSION_PROBE_TIMEOUT_MS); + timer.unref(); + child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.once("error", (cause) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + cause, + message: (cause as NodeJS.ErrnoException).code === "ENOENT" + ? "OpenCode executable is unavailable." + : `OpenCode version probe failed to start: ${cause.message}`, + })); + }); + // `close` waits for stdout/stderr to drain; `exit` can fire before the version is read. + child.once("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) resolve(output); + else reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe exited with code ${code}${output.trim() ? `: ${output.trim()}` : "."}`, + })); + }); + }); +} diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 69c358004..648d041d2 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -13,6 +13,8 @@ import { } from "./local-agent-opencode.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +const probeV1 = async () => ({ generation: "v1", version: "1.17.13" }) as const; + let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; @@ -53,11 +55,16 @@ const factory: OpencodeFactory = async (_context, env) => { server: { close: () => { closeCalls += 1; } }, }; }; -const driver = new OpencodeLocalAgentDriver(factory, { HARNESS_ENV: "opencode" }); +const driver = new OpencodeLocalAgentDriver({ + factory, + env: { HARNESS_ENV: "opencode" }, + probeRuntime: probeV1, +}); const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -68,6 +75,7 @@ const first = await pool.run(driver, { }); const second = await pool.run(driver, { agentId: "agt_two", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -129,19 +137,23 @@ if (process.platform !== "win32") { "", ].join("\n")); await chmod(command, 0o700); - const envDriver = new OpencodeLocalAgentDriver(undefined, { - PATH: commandRoot, - HARNESS_ENV: "opencode-child", - MARKER: marker, - ARGS_MARKER: argsMarker, - COLLISION_MARKER: collisionMarker, - HOLDER_READY: holderReady, - HOLDER_PROCESS: holderProcess, - HOLDER_LAUNCHER: holderLauncher, - NODE_EXECUTABLE: process.execPath, + const envDriver = new OpencodeLocalAgentDriver({ + env: { + PATH: commandRoot, + HARNESS_ENV: "opencode-child", + MARKER: marker, + ARGS_MARKER: argsMarker, + COLLISION_MARKER: collisionMarker, + HOLDER_READY: holderReady, + HOLDER_PROCESS: holderProcess, + HOLDER_LAUNCHER: holderLauncher, + NODE_EXECUTABLE: process.execPath, + }, + probeRuntime: probeV1, }); const created = await envDriver.createRuntime({ agentId: "agt_env", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }); @@ -179,6 +191,7 @@ assert.deepEqual(promptInputs[0], { let callbackSessionId: string | undefined; await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -224,6 +237,34 @@ if (timedOutPrompt.isErr()) { } await timeoutRuntime.close(); +// Stop ends the turn only once OpenCode confirms the abort; a stalled abort leaves it running. +const abortClient = (abort: () => Promise) => ({ + global: { + async health() { return { data: { healthy: true } }; }, + }, + session: { + async create() { return { data: { id: "session_abort" } }; }, + abort, + async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, +}) as unknown as OpencodeClientLike; +const stopRun = async (abort: () => Promise) => { + const stopRuntime = new OpencodeRuntime(abortClient(abort), { close: () => undefined }, 100); + const stop = new AbortController(); + const run = stopRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); + setTimeout(() => stop.abort(), 10); + const result = await run; + await stopRuntime.close(); + return result.isErr() ? result.error.code : "ok"; +}; +assert.equal(await stopRun(async () => ({ data: true })), "PROVIDER_CANCELLED"); +assert.equal(await stopRun(async () => ({ error: { name: "UnknownError" } })), "PROVIDER_PROTOCOL_ERROR"); +assert.equal(await stopRun(() => new Promise(() => undefined)), "PROVIDER_PROTOCOL_ERROR"); + assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); assert.deepEqual(opencodePermissionFor("allowed"), { @@ -277,12 +318,16 @@ const applicationErrorClient = { }, } as unknown as OpencodeClientLike; const applicationErrorPool = new LocalAgentRuntimePool(); -const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ - client: applicationErrorClient, - server: { close: () => undefined }, -})); +const applicationErrorDriver = new OpencodeLocalAgentDriver({ + factory: async () => ({ + client: applicationErrorClient, + server: { close: () => undefined }, + }), + probeRuntime: probeV1, +}); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "bad input", workspaceRoot: "/tmp/project" }); @@ -295,6 +340,7 @@ if (applicationFailure.isErr()) { assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "valid input", workspaceRoot: "/tmp/project" }); @@ -304,14 +350,18 @@ assert.equal(recoveredApplicationTurn.value.finalResponse, "ok"); await applicationErrorPool.close(); let recoveringFactoryCalls = 0; -const recoveringDriver = new OpencodeLocalAgentDriver(async () => { - recoveringFactoryCalls += 1; - healthAvailable = true; - return { client, server: { close: () => undefined } }; +const recoveringDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + recoveringFactoryCalls += 1; + healthAvailable = true; + return { client, server: { close: () => undefined } }; + }, + probeRuntime: probeV1, }); const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -321,6 +371,7 @@ await recoveringPool.run(recoveringDriver, { healthAvailable = false; const deadRuntime = await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -335,6 +386,7 @@ if (deadRuntime.isErr()) { assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately"); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index d9980aba2..90e8574d9 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,5 +1,3 @@ -import { createRequire } from "node:module"; -import { createServer as createNetServer } from "node:net"; import type { OpencodeClient, PermissionConfig, @@ -10,6 +8,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -18,14 +17,19 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { terminateProcessTree } from "./process-platform.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + defaultOpencodeV2Factory, + OpencodeV2Runtime, + type OpencodeV2Factory, +} from "./local-agent-opencode-v2.js"; +import { + probeOpenCodeBinary, + requireOpenCodeV1NativeSessionId, + type OpenCodeRuntimeProbeFunction, +} from "./local-agent-opencode-version.js"; -const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; -const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; -const OPENCODE_SERVER_START_ATTEMPTS = 3; const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; -const require = createRequire(import.meta.url); -const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; interface OpencodeModelRef { providerID: string; @@ -34,10 +38,6 @@ interface OpencodeModelRef { export type OpencodeClientLike = Pick; -export interface OpencodeServerLike { - close(): void; -} - export type OpencodeFactory = ( context?: LocalAgentRuntimeContext, env?: NodeJS.ProcessEnv, @@ -74,7 +74,9 @@ export class OpencodeRuntime implements LocalAgentRuntime { } try { await assertOpencodeHealthy(this.client); - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input); + const sessionId = input.providerSessionId + ? requireOpenCodeV1NativeSessionId(input.providerSessionId) + : await createOpencodeSession(this.client, input); await callbacks?.onSessionId?.(sessionId); const promptResult = await this.prompt(sessionId, input); assertOpenCodePromptSucceeded(promptResult); @@ -124,6 +126,15 @@ export class OpencodeRuntime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + // End the local request only once OpenCode confirms the abort. A failed or + // stalled abort keeps the turn running, and the manager reports the stop as a conflict. + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + const aborted = await this.client.session.abort({ + sessionID: sessionId, + directory: input.workspaceRoot, + }, { throwOnError: false }); + if (!aborted.error) controller.abort(); + }); const timer = setTimeout(() => { timedOut = true; controller.abort(); @@ -131,6 +142,8 @@ export class OpencodeRuntime implements LocalAgentRuntime { try { return await promptOpencodeSession(this.client, sessionId, input, controller.signal); } catch (error) { + // A provider timeout after a stop request is still a timeout: the abort was never confirmed. + if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -142,6 +155,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { }); } finally { clearTimeout(timer); + removeAbort(); this.promptControllers.delete(controller); } } @@ -149,15 +163,30 @@ export class OpencodeRuntime implements LocalAgentRuntime { export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; - readonly idleTimeoutMs = 5 * 60_000; - - constructor( - private readonly factory: OpencodeFactory = defaultOpencodeFactory, - private readonly env: NodeJS.ProcessEnv = process.env, - ) {} - - runtimeKey(_context: LocalAgentRuntimeContext): string { - return "opencode:default"; + readonly providerInstanceId = "opencode"; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true, streaming: false }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; + private readonly factory: OpencodeFactory; + private readonly v2Factory: OpencodeV2Factory; + private readonly env: NodeJS.ProcessEnv; + private readonly probeRuntime: OpenCodeRuntimeProbeFunction; + + constructor(options: { + factory?: OpencodeFactory; + v2Factory?: OpencodeV2Factory; + env?: NodeJS.ProcessEnv; + probeRuntime?: OpenCodeRuntimeProbeFunction; + } = {}) { + this.factory = options.factory ?? defaultOpencodeFactory; + this.v2Factory = options.v2Factory ?? defaultOpencodeV2Factory; + this.env = options.env ?? process.env; + this.probeRuntime = options.probeRuntime ?? (() => probeOpenCodeBinary(this.env)); } async createRuntime(context: LocalAgentRuntimeContext) { @@ -166,6 +195,12 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { + // Probe on every runtime creation so an upgraded executable is routed to the matching protocol generation. + const runtime = await this.probeRuntime(); + if (runtime.generation === "v2") { + const { client, server } = await this.v2Factory(context, this.env); + return new OpencodeV2Runtime(client, server); + } const { client, server } = await this.factory(context, this.env); return new OpencodeRuntime(client, server); }, @@ -185,124 +220,16 @@ async function defaultOpencodeFactory( devspace_full_access: opencodeAgentConfig("full_access"), }, }; - const server = await startOpencodeServer(env, config); + const server = await startOpencodeServer({ + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }); return { client: createOpencodeClient({ baseUrl: server.url }), server, }; } -async function startOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, -): Promise { - for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { - const port = await allocateOpencodePort(); - try { - return await launchOpencodeServer(env, config, port); - } catch (error) { - if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; - } - } - throw new Error("OpenCode server failed to start."); -} - -async function launchOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, - port: number, -): Promise { - const detached = process.platform !== "win32"; - const child = spawn("opencode", [ - "serve", - `--hostname=${OPENCODE_SERVER_HOSTNAME}`, - `--port=${port}`, - ], { - detached, - env: { - ...env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(config), - }, - stdio: ["ignore", "pipe", "pipe"], - }); - let closed = false; - const close = () => { - if (closed) return; - closed = true; - terminateProcessTree(child, "SIGTERM", detached); - }; - const url = await new Promise((resolve, reject) => { - let output = ""; - let ready = false; - const timer = setTimeout(() => { - if (ready) return; - close(); - reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); - }, OPENCODE_SERVER_START_TIMEOUT_MS); - timer.unref(); - child.stdout?.on("data", (chunk: Buffer | string) => { - if (ready) return; - output += chunk.toString(); - for (const line of output.split("\n")) { - if (!line.startsWith("opencode server listening")) continue; - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - if (!match?.[1]) continue; - ready = true; - clearTimeout(timer); - resolve(match[1]); - return; - } - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - if (!ready) output += chunk.toString(); - }); - child.once("error", (error) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(error); - }); - child.once("exit", (code) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); - }); - }); - return { url, close }; -} - -async function allocateOpencodePort(): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to allocate an OpenCode server port.")); - return; - } - server.close((error) => error ? reject(error) : resolve(address.port)); - }); - }); -} - -async function isOpencodePortInUse(port: number): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") resolve(true); - else reject(error); - }); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { - server.close((error) => error ? reject(error) : resolve(false)); - }); - }); -} - export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { mode: "primary"; permission: PermissionConfig; diff --git a/src/local-agent-pi-rpc.test.ts b/src/local-agent-pi-rpc.test.ts new file mode 100644 index 000000000..4bba0628d --- /dev/null +++ b/src/local-agent-pi-rpc.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { PiRpcConnection } from "./local-agent-pi-rpc.js"; + +const script = String.raw` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line); + process.stdout.write("not-json\n"); + process.stdout.write(JSON.stringify({ type: "agent_start" }) + "\n"); + process.stdout.write(JSON.stringify({ + type: "response", + id: request.id, + command: request.type, + success: true, + data: { echoed: request.value }, + }) + "\n"); + } +}); +`; + +const connection = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +const response = await connection.request({ type: "echo", value: "hello" }); +assert.deepEqual(response, { echoed: "hello" }); +assert.deepEqual(await connection.nextEvent(1_000), { type: "agent_start" }); +connection.close(); + +// A Pi that writes a lot to stderr must not block on a full pipe, and a crash reports what it said. +const noisyScript = String.raw` +process.stderr.write("x".repeat(256 * 1024)); +process.stderr.write("fatal: broken extension\n", () => process.exit(3)); +`; +const noisy = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", noisyScript], + cwd: process.cwd(), + env: process.env, +}); +await assert.rejects( + () => noisy.nextEvent(5_000), + /exited with code 3[\s\S]*fatal: broken extension/, +); +assert.equal(noisy.isAlive(), false); + +// Events nobody consumed (for example the tail of a cancelled turn) can be dropped. +const clearable = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +await clearable.request({ type: "echo", value: "again" }); +clearable.clearEvents(); +assert.equal(await clearable.nextEvent(100), undefined); +clearable.close(); diff --git a/src/local-agent-pi-rpc.ts b/src/local-agent-pi-rpc.ts new file mode 100644 index 000000000..135ad1048 --- /dev/null +++ b/src/local-agent-pi-rpc.ts @@ -0,0 +1,214 @@ +import { createRequire } from "node:module"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { terminateProcessTree } from "./process-platform.js"; + +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const MAX_RECORD_CHARS = 8 * 1024 * 1024; +const MAX_STDERR_CHARS = 8 * 1024; + +export type PiRpcRecord = Record; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +export class PiRpcConnection { + private readonly pending = new Map(); + private readonly queuedEvents: PiRpcRecord[] = []; + private readonly eventWaiters: Array<{ + resolve(value: PiRpcRecord): void; + reject(error: Error): void; + timer?: NodeJS.Timeout; + }> = []; + private nextRequestId = 0; + private buffer = ""; + private closed = false; + private failure?: Error; + private stderrTail = ""; + + private constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.consume(chunk)); + // Drain stderr so a chatty Pi or extension cannot fill the pipe and block; keep the tail for diagnostics. + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + this.stderrTail = (this.stderrTail + chunk).slice(-MAX_STDERR_CHARS); + }); + // A write racing with process exit emits EPIPE here; fail this connection instead of the daemon. + child.stdin.on("error", (error) => this.fail(error)); + child.once("error", (error) => this.fail(error)); + // "close" (not "exit") so stderr is fully drained before it is reported. + child.once("close", (code) => { + if (this.closed) return; + const stderr = this.stderrTail.trim(); + this.fail(new Error(`Pi RPC process exited with code ${code}.${stderr ? `\n${stderr}` : ""}`)); + }); + } + + static spawn(input: { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + }): PiRpcConnection { + const detached = process.platform !== "win32"; + const child = spawn(input.command, input.args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + detached, + windowsHide: true, + }); + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Pi RPC process did not expose stdio pipes."); + } + return new PiRpcConnection(child as ChildProcessWithoutNullStreams); + } + + async request(record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { + this.assertOpen(); + const id = `devspace-${++this.nextRequestId}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${String(record.type ?? "request")} timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + timer.unref(); + this.pending.set(id, { resolve, reject, timer }); + this.write({ ...record, id }).catch((error) => { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + }); + }); + } + + async send(record: PiRpcRecord): Promise { + this.assertOpen(); + await this.write(record); + } + + async nextEvent(timeoutMs?: number): Promise { + this.assertOpen(); + const queued = this.queuedEvents.shift(); + if (queued) return queued; + return new Promise((resolve, reject) => { + const waiter: (typeof this.eventWaiters)[number] = { + resolve: (value) => { + if (waiter.timer) clearTimeout(waiter.timer); + resolve(value); + }, + reject: (error) => { + if (waiter.timer) clearTimeout(waiter.timer); + reject(error); + }, + }; + if (timeoutMs !== undefined) { + waiter.timer = setTimeout(() => { + const index = this.eventWaiters.indexOf(waiter); + if (index >= 0) this.eventWaiters.splice(index, 1); + resolve(undefined); + }, timeoutMs); + waiter.timer.unref(); + } + this.eventWaiters.push(waiter); + }); + } + + /** Drops events that were not consumed, e.g. the tail of a cancelled turn. */ + clearEvents(): void { + this.queuedEvents.length = 0; + } + + isAlive(): boolean { + return !this.closed && !this.failure && this.child.exitCode === null; + } + + close(): void { + if (this.closed) return; + this.closed = true; + const detached = process.platform !== "win32"; + terminateProcessTree(this.child, "SIGTERM", detached); + this.fail(new Error("Pi RPC process closed.")); + } + + private async write(record: PiRpcRecord): Promise { + const line = `${JSON.stringify(record)}\n`; + await new Promise((resolve, reject) => { + this.child.stdin.write(line, (error) => error ? reject(error) : resolve()); + }); + } + + private consume(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > MAX_RECORD_CHARS && !this.buffer.includes("\n")) { + this.buffer = ""; + return; + } + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; + let line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + this.route(parsed as PiRpcRecord); + } + } + + private route(record: PiRpcRecord): void { + if (record.type === "response" && typeof record.id === "string") { + const pending = this.pending.get(record.id); + if (pending) { + this.pending.delete(record.id); + clearTimeout(pending.timer); + if (record.success === true) pending.resolve(record.data); + else pending.reject(new Error(String(record.error ?? `Pi RPC ${String(record.command)} failed.`))); + return; + } + } + const waiter = this.eventWaiters.shift(); + if (waiter) waiter.resolve(record); + else this.queuedEvents.push(record); + } + + private assertOpen(): void { + if (this.failure) throw this.failure; + if (this.closed) throw new Error("Pi RPC process is closed."); + } + + private fail(error: Error): void { + if (!this.failure) this.failure = error; + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + for (const waiter of this.eventWaiters.splice(0)) waiter.reject(error); + } +} + +export function piRecordString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +export function parsePiModelSlug(model: string): { provider: string; modelId: string } | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { provider: model.slice(0, separator), modelId: model.slice(separator + 1) }; +} diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index df111666c..8f569ccec 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -1,72 +1,122 @@ import assert from "node:assert/strict"; -import type { AgentSessionEvent, AgentSessionEventListener } from "@earendil-works/pi-coding-agent"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { PiLocalAgentDriver, - type PiSessionFactory, - type PiSessionLike, + type PiRpcFactory, + type PiRpcRuntimeConnection, } from "./local-agent-pi.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; -class FakePiSession implements PiSessionLike { - readonly sessionId = "pi_session_1"; - readonly messages: any[] = []; - readonly modelRegistry = { find: () => ({ id: "model" }) } as unknown as PiSessionLike["modelRegistry"]; - private readonly listeners = new Set(); - disposeCount = 0; - model?: unknown; - effort?: unknown; - activeTools: string[] = []; - toolHistory: string[][] = []; - - async prompt(text: string): Promise { - const message = { - role: "assistant", - content: [{ type: "text", text: `response:${text}` }], - }; - this.messages.push(message); - for (const listener of this.listeners) listener({ type: "agent_end" } as AgentSessionEvent); - } +const sessionDirectory = mkdtempSync(join(tmpdir(), "devspace-pi-test-")); +let sessionCount = 0; +// Pi session references are real files; resuming validates that. +function sessionFile(name = `session-${++sessionCount}`): string { + const path = join(sessionDirectory, `${name}.jsonl`); + writeFileSync(path, ""); + return path; +} + +class FakePiRpcConnection implements PiRpcRuntimeConnection { + readonly requests: Array> = []; + readonly events: Array> = []; + sessionFile = sessionFile("one"); + lastPrompt = ""; + alive = true; + cleanupCount = 0; - subscribe(listener: AgentSessionEventListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); + async request(record: Record): Promise { + this.requests.push(record); + switch (record.type) { + case "new_session": + this.sessionFile = sessionFile(); + return { cancelled: false }; + case "switch_session": + this.sessionFile = String(record.sessionPath); + return { cancelled: false }; + case "get_state": + return { + sessionFile: this.sessionFile, + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }; + case "get_available_models": + return { models: [{ provider: "provider", id: "model" }] }; + case "set_model": + case "set_thinking_level": + case "abort": + return undefined; + case "prompt": + this.lastPrompt = String(record.message); + if (this.lastPrompt === "use tools") { + this.events.push( + { type: "tool_execution_start", toolCallId: "call_1", toolName: "bash", args: { command: "ls" } }, + { type: "tool_execution_end", toolCallId: "call_1", toolName: "bash", result: {}, isError: false }, + { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Two files." }] } }, + ); + } + this.events.push( + { type: "agent_start" }, + { type: "agent_end" }, + { type: "agent_settled" }, + ); + return undefined; + case "get_last_assistant_text": + return { text: `response:${this.lastPrompt}` }; + default: + throw new Error(`unexpected request: ${String(record.type)}`); + } } - async setModel(model: any): Promise { - this.model = model; + async send(_record: Record): Promise {} + + async nextEvent(timeoutMs = 0): Promise | undefined> { + const event = this.events.shift(); + if (event) return event; + // Like the real connection, an empty queue waits instead of spinning on microtasks. + await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(timeoutMs, 5))); + return undefined; } - setActiveToolsByName(toolNames: string[]): void { - this.activeTools = [...toolNames]; - this.toolHistory.push([...toolNames]); + clearEvents(): void { + this.events.length = 0; } - setThinkingLevel(level: any): void { - this.effort = level; + isAlive(): boolean { + return this.alive; } - dispose(): void { - this.disposeCount += 1; + close(): void { + this.alive = false; } } const contexts: LocalAgentRuntimeContext[] = []; -const sessions: FakePiSession[] = []; +const connections: FakePiRpcConnection[] = []; let factoryEnv: NodeJS.ProcessEnv | undefined; -const factory: PiSessionFactory = async (context, _input, env) => { +let cleanupCount = 0; +const factory: PiRpcFactory = async (context, env) => { contexts.push(context); factoryEnv = env; - const session = new FakePiSession(); - sessions.push(session); - return session; + const connection = new FakePiRpcConnection(); + connections.push(connection); + return { + connection, + cleanup: async () => { cleanupCount += 1; }, + }; }; + const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { agentId: "agt_pi", + providerInstanceId: "pi", provider: "pi", workspaceRoot: "/tmp/project", + writeMode: "read_only", }; const sessionIds: string[] = []; @@ -80,75 +130,175 @@ const first = await pool.run(driver, context, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); assert.equal(factoryEnv?.HARNESS_ENV, "pi"); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.match(first.value.providerSessionId ?? "", /session-\d+\.jsonl$/); +assert.equal(first.value.finalResponse, "response:first"); +assert.deepEqual(sessionIds, [first.value.providerSessionId]); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_model")[0], + { type: "set_model", provider: "provider", modelId: "model" }, +); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_thinking_level")[0], + { type: "set_thinking_level", level: "high" }, +); +assert.ok(first.value.items.some((event: any) => event.type === "agent_end")); +assert.ok(first.value.items.some((event: any) => event.type === "agent_settled")); + const second = await pool.run(driver, context, { prompt: "second", workspaceRoot: "/tmp/project", - writeMode: "allowed", -}); -await pool.run(driver, context, { - prompt: "third", - workspaceRoot: "/tmp/project", - writeMode: "full_access", -}); -await pool.run(driver, context, { - prompt: "fourth", - workspaceRoot: "/tmp/project", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "read_only", }); -assert.equal(contexts.length, 1, "one warm Pi session serves successive turns"); -assert.equal(first.isOk(), true); assert.equal(second.isOk(), true); -if (first.isErr()) throw first.error; -if (second.isErr()) throw second.error; -assert.equal(first.value.providerSessionId, "pi_session_1"); -assert.equal(second.value.finalResponse, "response:second"); -assert.deepEqual(sessions[0]?.model, { id: "model" }); -assert.equal(sessions[0]?.effort, "high"); -assert.deepEqual(sessionIds, ["pi_session_1"]); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls"]); -assert.deepEqual(sessions[0]?.toolHistory, [ - ["read", "grep", "find", "ls"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls"], -]); +assert.equal(contexts.length, 1, "same agent and write mode reuse one Pi RPC process"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { - prompt: "fifth", +await pool.run(driver, { ...context, writeMode: "allowed" }, { + prompt: "allowed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "allowed", }); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 2, "changing Pi write mode creates a process with the matching fixed permission policy"); +assert.ok(connections[1]?.requests.some((request) => request.type === "switch_session")); await pool.evictIdle(Date.now() + 10 * 60_000); -assert.equal(sessions[0]?.disposeCount, 1, "idle eviction disposes the in-process session"); +assert.equal(cleanupCount, 2, "idle eviction closes external Pi processes and sandbox wrappers"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { +await pool.run(driver, { ...context, providerSessionId: first.value.providerSessionId ?? undefined }, { prompt: "resumed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, + writeMode: "read_only", }); -assert.equal(contexts.length, 2, "cold continuation creates a new AgentSession"); -assert.equal(contexts[1]?.providerSessionId, "pi_session_1"); -assert.deepEqual(sessions[1]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 3, "cold continuation starts a new Pi RPC process"); +assert.ok(connections[2]?.requests.some((request) => request.type === "switch_session")); await pool.close(); -const missingModelSession = new FakePiSession(); -Object.defineProperty(missingModelSession, "modelRegistry", { value: { find: () => undefined } }); -const missingModelDriver = new PiLocalAgentDriver(async () => missingModelSession); -const missingModelRuntime = await missingModelDriver.createRuntime(context); -assert.equal(missingModelRuntime.isOk(), true); -if (missingModelRuntime.isErr()) throw missingModelRuntime.error; -const missingModel = await missingModelRuntime.value.run({ +const missingModel = new FakePiRpcConnection(); +missingModel.request = async function(record: Record): Promise { + this.requests.push(record); + if (record.type === "get_available_models") return { models: [] }; + if (record.type === "new_session") return { cancelled: false }; + if (record.type === "get_state") return { sessionFile: sessionFile("missing") }; + throw new Error(`unexpected request: ${String(record.type)}`); +}; +const missingModelDriver = new PiLocalAgentDriver(async () => ({ connection: missingModel })); +const missingRuntime = await missingModelDriver.createRuntime(context); +assert.equal(missingRuntime.isOk(), true); +if (missingRuntime.isErr()) throw missingRuntime.error; +const missingResult = await missingRuntime.value.run({ prompt: "inspect", workspaceRoot: "/tmp/project", - model: "provider/missing-model", + model: "missing-model", }); -assert.equal(missingModel.isErr(), true); -if (missingModel.isErr()) { - assert.equal(missingModel.error.code, "PROVIDER_PROTOCOL_ERROR"); - assert.equal(missingModel.error.retryable, false); - assert.match(missingModel.error.message, /provider\/missing-model/); +assert.equal(missingResult.isErr(), true); +if (missingResult.isErr()) { + assert.equal(missingResult.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(missingResult.error.message, /missing-model/); } -await missingModelRuntime.value.close(); +await missingRuntime.value.close(); + +// A bare session id from before Pi ran over RPC must fail instead of opening an empty session. +const legacyConnection = new FakePiRpcConnection(); +const legacyDriver = new PiLocalAgentDriver(async () => ({ connection: legacyConnection })); +const legacyRuntime = await legacyDriver.createRuntime(context); +if (legacyRuntime.isErr()) throw legacyRuntime.error; +const legacyResult = await legacyRuntime.value.run({ + prompt: "continue", + workspaceRoot: "/tmp/project", + providerSessionId: "019f-legacy-session-id", +}); +assert.equal(legacyResult.isErr(), true); +if (legacyResult.isErr()) assert.match(legacyResult.error.message, /Start a new agent/); +assert.equal(legacyConnection.requests.some((request) => request.type === "switch_session"), false); +await legacyRuntime.value.close(); + +// An exact model id wins over provider/model parsing (gateway providers use ids containing "/"). +const gatewayConnection = new FakePiRpcConnection(); +gatewayConnection.request = async function(record: Record): Promise { + if (record.type === "get_available_models") { + this.requests.push(record); + return { models: [{ provider: "openrouter", id: "openai/gpt-4o" }] }; + } + return FakePiRpcConnection.prototype.request.call(this, record); +}; +const gatewayRuntime = await new PiLocalAgentDriver(async () => ({ connection: gatewayConnection })).createRuntime(context); +if (gatewayRuntime.isErr()) throw gatewayRuntime.error; +const gatewayResult = await gatewayRuntime.value.run({ + prompt: "inspect", + workspaceRoot: "/tmp/project", + model: "openai/gpt-4o", +}); +assert.equal(gatewayResult.isOk(), true); +assert.deepEqual( + gatewayConnection.requests.find((request) => request.type === "set_model"), + { type: "set_model", provider: "openrouter", modelId: "openai/gpt-4o" }, +); +await gatewayRuntime.value.close(); + +// Stop: Pi is aborted, a Pi that never settles is discarded, and stale events never reach the next turn. +const stalledConnection = new FakePiRpcConnection(); +stalledConnection.request = async function(record: Record): Promise { + if (record.type === "prompt") { + this.requests.push(record); + this.events.push({ type: "agent_start" }); + return undefined; + } + return FakePiRpcConnection.prototype.request.call(this, record); +}; +const stalledRuntime = await new PiLocalAgentDriver(async () => ({ connection: stalledConnection })).createRuntime(context); +if (stalledRuntime.isErr()) throw stalledRuntime.error; +const stop = new AbortController(); +setTimeout(() => stop.abort(), 20); +const stalledResult = await stalledRuntime.value.run({ + prompt: "stop me", + workspaceRoot: "/tmp/project", + signal: stop.signal, +}); +assert.equal(stalledResult.isErr(), true); +if (stalledResult.isErr()) assert.equal(stalledResult.error.code, "PROVIDER_CANCELLED"); +assert.ok(stalledConnection.requests.some((request) => request.type === "abort")); +assert.equal(stalledRuntime.value.isAlive(), false, "a Pi that does not settle after abort is discarded"); + +const preAbortConnection = new FakePiRpcConnection(); +const preAbortRuntime = await new PiLocalAgentDriver(async () => ({ connection: preAbortConnection })).createRuntime(context); +if (preAbortRuntime.isErr()) throw preAbortRuntime.error; +const alreadyStopped = new AbortController(); +alreadyStopped.abort(); +const preAbortResult = await preAbortRuntime.value.run({ + prompt: "never starts", + workspaceRoot: "/tmp/project", + signal: alreadyStopped.signal, +}); +assert.equal(preAbortResult.isErr(), true); +assert.equal(preAbortConnection.requests.some((request) => request.type === "prompt"), false); +await preAbortRuntime.value.close(); + +const staleConnection = new FakePiRpcConnection(); +staleConnection.events.push({ type: "agent_start" }, { type: "agent_settled" }); +const staleRuntime = await new PiLocalAgentDriver(async () => ({ connection: staleConnection })).createRuntime(context); +if (staleRuntime.isErr()) throw staleRuntime.error; +const staleResult = await staleRuntime.value.run({ prompt: "fresh", workspaceRoot: "/tmp/project" }); +if (staleResult.isErr()) throw staleResult.error; +assert.ok(staleResult.value.items.some((event: any) => event.type === "agent_end"), "leftover events from an earlier turn do not settle this one"); +await staleRuntime.value.close(); + +// Pi RPC tool and message frames map onto DevSpace activity events. +const streamingConnection = new FakePiRpcConnection(); +const streamingRuntime = await new PiLocalAgentDriver(async () => ({ connection: streamingConnection })).createRuntime(context); +if (streamingRuntime.isErr()) throw streamingRuntime.error; +const piEvents: unknown[] = []; +const streamed = await streamingRuntime.value.run({ prompt: "use tools", workspaceRoot: "/tmp/project" }, { + onEvent: (event) => { piEvents.push(event); }, +}); +if (streamed.isErr()) throw streamed.error; +assert.equal(driver.capabilities.turns.streaming, true); +assert.deepEqual(piEvents, [ + { type: "tool_started", tool: "bash", summary: "ls" }, + { type: "tool_completed", tool: "bash", summary: "ls", status: "completed" }, + { type: "message", text: "Two files." }, +]); +await streamingRuntime.value.close(); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index bfb85cb3a..ca478dd94 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -1,11 +1,24 @@ -import { join } from "node:path"; -import type { AgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { AgentProviderExecutionError, AgentProviderProtocolError, AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { localAgentCancelledError } from "./local-agent-cancellation.js"; +import { summarizeToolInput, type LocalAgentActivityEvent } from "./local-agent-events.js"; +import { resolveExecutableCommand } from "./local-agent-command.js"; +import { + PiRpcConnection, + parsePiModelSlug, + piRecordString, + type PiRpcRecord, +} from "./local-agent-pi-rpc.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -14,55 +27,38 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { - createPiSandboxExtension, - createPiSandboxModeRef, - registerPiSandboxSession, - releasePiSandboxSession, - updatePiSandboxSession, -} from "./local-agent-pi-sandbox.js"; +const require = createRequire(import.meta.url); const PI_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const; -const PI_WORKSPACE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash"] as const; -const PI_FULL_ACCESS_TOOLS = [...PI_WORKSPACE_TOOLS] as const; +const PI_ALLOWED_TOOLS = [...PI_READ_ONLY_TOOLS, "edit", "write", "bash"] as const; +const PI_TURN_TIMEOUT_MS = 5 * 60_000; +const PI_STATE_POLL_MS = 200; +const PI_ABORT_SETTLE_MS = 2_000; const MAX_PI_EVENTS = 10_000; -export type PiSessionLike = Pick< - AgentSession, - | "sessionId" - | "messages" - | "modelRegistry" - | "prompt" - | "subscribe" - | "setActiveToolsByName" - | "setModel" - | "setThinkingLevel" - | "dispose" ->; +export interface PiRpcRuntimeConnection { + request(record: PiRpcRecord, timeoutMs?: number): Promise; + send(record: PiRpcRecord): Promise; + nextEvent(timeoutMs?: number): Promise; + clearEvents(): void; + isAlive(): boolean; + close(): void; +} -export type PiSessionFactory = ( +export type PiRpcFactory = ( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env?: NodeJS.ProcessEnv, -) => Promise; + env: NodeJS.ProcessEnv, +) => Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }>; -export class PiSessionRuntime implements LocalAgentRuntime { +export class PiRpcRuntime implements LocalAgentRuntime { readonly provider = "pi" as const; - private readonly unsubscribe: () => void; - private alive = true; + private sessionFile?: string; private closed = false; - private collectingEvents = false; - private events: unknown[] = []; constructor( - private readonly session: PiSessionLike, - ) { - this.unsubscribe = session.subscribe((event) => { - if (!this.collectingEvents) return; - if (this.events.length >= MAX_PI_EVENTS) this.events.shift(); - this.events.push(event); - }); - } + private readonly connection: PiRpcRuntimeConnection, + private readonly cleanup?: () => Promise, + ) {} async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { return captureAgentProviderResult({ @@ -75,276 +71,346 @@ export class PiSessionRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: true, - message: "Pi runtime is not running.", + message: "Pi RPC process is not running.", }); } - await callbacks?.onSessionId?.(this.session.sessionId); + + const providerSessionId = await this.ensureSession(input.providerSessionId); + await callbacks?.onSessionId?.(providerSessionId); await this.applyOverrides(input); - this.events = []; - const messageStart = this.session.messages.length; - this.collectingEvents = true; + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); + + const events: PiRpcRecord[] = []; + // Events left over from an earlier turn must not settle this one. + this.connection.clearEvents(); try { - await this.session.prompt(input.prompt); - } finally { - this.collectingEvents = false; - } - const currentMessages = this.session.messages.slice(messageStart); - const finalResponse = extractPiFinalResponse({ messages: currentMessages }); - if (!finalResponse) { - const providerError = extractPiProviderError(this.events) || extractPiProviderError(currentMessages); - if (providerError) { - throw new AgentProviderExecutionError({ - code: "PROVIDER_EXECUTION_ERROR", - provider: this.provider, + await this.connection.request({ type: "prompt", message: input.prompt }); + await this.waitForSettlement(events, input.signal, callbacks?.onEvent); + if (input.signal?.aborted) { + await this.abortTurn(); + throw localAgentCancelledError("pi", "run"); + } + const response = await this.connection.request({ type: "get_last_assistant_text" }); + const finalResponse = piRecordString(response, "text")?.trim(); + if (!finalResponse) { + const providerError = extractPiProviderError(events); + if (providerError) { + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "pi", + operation: "run", + retryable: false, + cause: new Error(providerError), + message: "Pi agent turn failed.", + }); + } + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", operation: "run", retryable: false, - cause: new Error(providerError), - message: "Pi agent turn failed.", + message: "Pi did not return a final assistant response.", }); } - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", + return { provider: this.provider, - operation: "run", - retryable: false, - message: "Pi did not return a final assistant response.", - }); + providerSessionId, + finalResponse, + items: events, + }; + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); + throw cause; } - return { - provider: this.provider, - providerSessionId: this.session.sessionId, - finalResponse, - items: [...this.events, ...currentMessages], - }; }, }); } async releaseSession(_providerSessionId: string): Promise { - // The runtime is already scoped to one logical Pi session. + // Pi owns durable session files independently of this process. } isAlive(): boolean { - return this.alive && !this.closed; + return !this.closed && this.connection.isAlive(); } async close(): Promise { if (this.closed) return; this.closed = true; - this.alive = false; - this.unsubscribe(); - try { - await releasePiSandboxSession(this.session); - } finally { - this.session.dispose(); + this.connection.close(); + await this.cleanup?.(); + } + + private async ensureSession(providerSessionId?: string): Promise { + if (providerSessionId && this.sessionFile !== providerSessionId) { + // Agents created before Pi ran over RPC stored a bare session id; switch_session would + // silently open an empty session for it instead of failing. + if (!isAbsolute(providerSessionId) || !existsSync(providerSessionId)) { + throw piProtocolError( + "resume_session", + `Pi session ${providerSessionId} is not a session file DevSpace can resume. Start a new agent.`, + ); + } + const result = await this.connection.request({ type: "switch_session", sessionPath: providerSessionId }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("resume_session", "A Pi extension cancelled the session switch."); + } + this.sessionFile = undefined; + } else if (!providerSessionId && !this.sessionFile) { + const result = await this.connection.request({ type: "new_session" }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("create_session", "A Pi extension cancelled new session creation."); + } + } + if (this.sessionFile) return this.sessionFile; + const state = await this.connection.request({ type: "get_state" }); + const sessionFile = piRecordString(state, "sessionFile"); + if (!sessionFile) { + throw piProtocolError("resolve_session", "Pi get_state returned no persisted session file."); } + this.sessionFile = sessionFile; + return sessionFile; } private async applyOverrides(input: LocalAgentRunInput): Promise { - await updatePiSandboxSession(this.session, input.workspaceRoot, input.writeMode ?? "allowed"); - this.session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); if (input.model) { - const model = resolvePiModel(this.session.modelRegistry, input.model); - if (!model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - await this.session.setModel(model as never); + const model = await this.resolveModel(input.model); + await this.connection.request({ type: "set_model", provider: model.provider, modelId: model.modelId }); } if (input.effort) { - this.session.setThinkingLevel(input.effort as never); + await this.connection.request({ type: "set_thinking_level", level: input.effort }); + } + } + + private async resolveModel(model: string): Promise<{ provider: string; modelId: string }> { + // Gateway providers have model ids that contain "/", so an exact id wins over provider/model. + const available = await this.connection.request({ type: "get_available_models" }); + const models = recordArray(available, "models"); + const matches = models.filter((entry) => piRecordString(entry, "id") === model); + if (matches.length === 1) { + const provider = piRecordString(matches[0], "provider"); + const modelId = piRecordString(matches[0], "id"); + if (provider && modelId) return { provider, modelId }; + } + const parsed = parsePiModelSlug(model); + if (parsed) return parsed; + throw piProtocolError("configure_model", `Pi model not found or ambiguous: ${model}.`); + } + + /** + * Asks Pi to stop and waits for it to settle. A Pi that does not settle is + * discarded so its late events cannot be mistaken for the next turn's. + */ + private async abortTurn(): Promise { + await this.connection.request({ type: "abort" }, PI_ABORT_SETTLE_MS).catch(() => undefined); + const deadline = Date.now() + PI_ABORT_SETTLE_MS; + try { + while (Date.now() < deadline) { + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event?.type === "agent_settled") { + this.connection.clearEvents(); + return; + } + } + } catch { + // The connection already failed; closing below is idempotent. + } + this.connection.close(); + } + + private async waitForSettlement( + events: PiRpcRecord[], + signal?: AbortSignal, + onEvent?: (event: LocalAgentActivityEvent) => void, + ): Promise { + const deadline = Date.now() + PI_TURN_TIMEOUT_MS; + const toolSummaries = new Map(); + let sawAgentActivity = false; + for (;;) { + if (signal?.aborted) return; + if (Date.now() >= deadline) { + await this.abortTurn(); + throw piProtocolError("prompt", "Pi did not settle before the provider timeout.", true); + } + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event) { + if (events.length >= MAX_PI_EVENTS) events.shift(); + events.push(event); + if (onEvent) reportPiActivity(event, toolSummaries, onEvent); + if (event.type === "agent_start" || event.type === "message_start" || event.type === "tool_execution_start") { + sawAgentActivity = true; + } + if (event.type === "extension_ui_request") { + await cancelPiUiRequest(this.connection, event); + continue; + } + if (event.type === "agent_settled") return; + continue; + } + const state = await this.connection.request({ type: "get_state" }, 2_000); + const streaming = recordBoolean(state, "isStreaming"); + const compacting = recordBoolean(state, "isCompacting"); + const pending = recordNumber(state, "pendingMessageCount") ?? 0; + if (!streaming && !compacting && pending === 0 && !sawAgentActivity) return; } } } export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; - readonly idleTimeoutMs = 3 * 60_000; + readonly providerInstanceId = "pi"; + readonly runtimePolicy = { + scope: "agent", + authority: "write_mode", + idleTimeoutMs: 3 * 60_000, + } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true, streaming: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "client-boundary" }, + mcp: { supported: false }, + } as const; constructor( - private readonly factory: PiSessionFactory = defaultPiSessionFactory, - private readonly env: NodeJS.ProcessEnv = {}, + private readonly factory: PiRpcFactory = defaultPiRpcFactory, + private readonly env: NodeJS.ProcessEnv = process.env, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - return `pi:${context.agentId}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { - const input: LocalAgentRunInput = { - prompt: "", - workspaceRoot: context.workspaceRoot, - providerSessionId: context.providerSessionId, - writeMode: context.writeMode, - model: context.model, - effort: context.effort, - }; - const session = await this.factory(context, input, this.env); - return new PiSessionRuntime(session); + const { connection, cleanup } = await this.factory(context, this.env); + return new PiRpcRuntime(connection, cleanup); }, }); } } -async function defaultPiSessionFactory( +async function defaultPiRpcFactory( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env: NodeJS.ProcessEnv = {}, -): Promise { - const { - AuthStorage, - ModelRegistry, - SessionManager, - DefaultResourceLoader, - createAgentSession, - getAgentDir, - } = await import("@earendil-works/pi-coding-agent"); - // DevSpace's agentDir is the compatibility directory used for instructions; - // Pi keeps its own native auth, model, and session state under getAgentDir(). - const agentDir = getAgentDir(); - const authStorage = AuthStorage.create(join(agentDir, "auth.json")); - const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); - applyPiProviderEnvironment(modelRegistry, env); - const sessionManager = await resolveSessionManager(SessionManager, input.workspaceRoot, input.providerSessionId); - const model = input.model ? resolvePiModel(modelRegistry, input.model) : undefined; - if (input.model && !model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - agentId: context.agentId, - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - const modeRef = createPiSandboxModeRef(input.writeMode ?? "allowed"); - const resourceLoader = new DefaultResourceLoader({ - cwd: input.workspaceRoot, - agentDir, - extensionFactories: [createPiSandboxExtension(input.workspaceRoot, modeRef, env)], - }); - let session: PiSessionLike | undefined; + env: NodeJS.ProcessEnv, +): Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }> { + const launch = resolvePiCommand(env); + const writeMode = context.writeMode ?? "allowed"; + const sandbox = writeMode === "full_access" + ? undefined + : await materializePiSandboxExtension(context.workspaceRoot, writeMode); + const args = [ + ...launch.args, + "--mode", + "rpc", + "--approve", + ...(writeMode === "full_access" ? [] : ["--extension", sandbox!.path]), + ...(writeMode === "read_only" + ? ["--tools", PI_READ_ONLY_TOOLS.join(",")] + : writeMode === "allowed" + ? ["--tools", PI_ALLOWED_TOOLS.join(",")] + : []), + ]; try { - const result = await createAgentSession({ - cwd: input.workspaceRoot, - agentDir, - authStorage, - modelRegistry, - sessionManager: sessionManager as never, - resourceLoader, - ...(model ? { model: model as never } : {}), - ...(input.effort ? { thinkingLevel: input.effort as never } : {}), - // Keep the full built-in registry available so warm turns can narrow or - // broaden active tools without recreating the session. - tools: [...PI_FULL_ACCESS_TOOLS], - }); - session = result.session; - await registerPiSandboxSession(session, input.workspaceRoot, modeRef, input.writeMode ?? "allowed"); - session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); - return session; + return { + connection: PiRpcConnection.spawn({ + command: launch.command, + args, + cwd: context.workspaceRoot, + env, + }), + ...(sandbox ? { cleanup: sandbox.cleanup } : {}), + }; } catch (error) { - if (session) { - try { - await releasePiSandboxSession(session); - } finally { - session.dispose(); - } - } + await sandbox?.cleanup(); throw error; } } -function applyPiProviderEnvironment( - modelRegistry: ModelRegistry, - env: NodeJS.ProcessEnv, -): void { - const getApiKeyAndHeaders = modelRegistry.getApiKeyAndHeaders.bind(modelRegistry); - const providerEnv = Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); - modelRegistry.getApiKeyAndHeaders = async (model) => { - const auth = await getApiKeyAndHeaders(model); - if (!auth.ok) return auth; - return { - ...auth, - env: { - ...auth.env, - ...providerEnv, - }, - }; - }; -} - -export function piToolsForWriteMode(writeMode: LocalAgentRunInput["writeMode"]): readonly string[] { - switch (writeMode) { - case "read_only": return PI_READ_ONLY_TOOLS; - case "full_access": return PI_FULL_ACCESS_TOOLS; - case "allowed": - case undefined: - return PI_WORKSPACE_TOOLS; +export function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { + const configured = env.PI_COMMAND?.trim(); + if (configured) { + const command = resolveExecutableCommand(configured, env); + if (!command) throw new Error(`Pi executable not found: ${configured}`); + return { command, args: [] }; } + const system = resolveExecutableCommand("pi", env); + if (system) return { command: system, args: [] }; + const packageJson = require.resolve("@earendil-works/pi-coding-agent/package.json"); + const cli = join(dirname(packageJson), "dist", "cli.js"); + return { command: process.execPath, args: [cli] }; } -interface PiSessionManagerApi { - create(cwd: string): unknown; - open(path: string): unknown; - list(cwd: string): Promise>; +async function materializePiSandboxExtension( + workspaceRoot: string, + writeMode: "read_only" | "allowed", +): Promise<{ path: string; cleanup: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "devspace-pi-")); + const extensionPath = join(directory, "devspace-sandbox.ts"); + const jsModule = new URL("./local-agent-pi-sandbox.js", import.meta.url); + const tsModule = new URL("./local-agent-pi-sandbox.ts", import.meta.url); + const moduleUrl = existsSync(fileURLToPath(jsModule)) ? jsModule : tsModule; + const source = [ + `import { createPiSandboxExtension, createPiSandboxModeRef } from ${JSON.stringify(moduleUrl.href)};`, + `export default createPiSandboxExtension(${JSON.stringify(resolve(workspaceRoot))}, createPiSandboxModeRef(${JSON.stringify(writeMode)}));`, + "", + ].join("\n"); + await writeFile(extensionPath, source, "utf8"); + return { + path: extensionPath, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; } -async function resolveSessionManager( - SessionManager: PiSessionManagerApi, - workspaceRoot: string, - providerSessionId: string | undefined, -): Promise { - if (!providerSessionId) return SessionManager.create(workspaceRoot); - const sessions = await SessionManager.list(workspaceRoot); - const match = sessions.find((session) => session.id === providerSessionId); - if (!match) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "session", - retryable: false, - message: `Pi session not found: ${providerSessionId}.`, - }); +export function reportPiActivity( + event: PiRpcRecord, + toolSummaries: Map, + onEvent: (event: LocalAgentActivityEvent) => void, +): void { + const tool = piRecordString(event, "toolName"); + const callId = piRecordString(event, "toolCallId"); + if (event.type === "tool_execution_start" && tool) { + const summary = summarizeToolInput(event.args); + if (callId) toolSummaries.set(callId, summary); + onEvent({ type: "tool_started", tool, summary }); + } else if (event.type === "tool_execution_end" && tool) { + const summary = callId ? toolSummaries.get(callId) : undefined; + if (callId) toolSummaries.delete(callId); + onEvent({ type: "tool_completed", tool, summary, status: event.isError === true ? "failed" : "completed" }); + } else if (event.type === "message_end") { + const text = extractPiFinalResponse(event.message); + if (text) onEvent({ type: "message", text }); } - return SessionManager.open(match.path); } -function resolvePiModel(registry: { find(provider: string, modelId: string): unknown; getAll?: () => unknown[] }, reference: string): unknown { - const separator = reference.indexOf("/"); - if (separator !== -1) { - return registry.find(reference.slice(0, separator), reference.slice(separator + 1)); - } - const all = registry.getAll?.() ?? []; - return all.find((model) => asRecord(model)?.id === reference); +async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRpcRecord): Promise { + if (typeof event.id !== "string") return; + if (event.method === "notify" || event.method === "setStatus" || event.method === "setWidget" || event.method === "setTitle") return; + await connection.send({ type: "extension_ui_response", id: event.id, cancelled: true }); } export function extractPiFinalResponse(value: unknown): string { - const root = unwrapProviderPayload(value); - const messages = Array.isArray(root) ? root : readArray(root, "messages"); - if (!messages) return ""; + if (!value || typeof value !== "object") return ""; + const root = value as Record; + const data = root.data && typeof root.data === "object" && !Array.isArray(root.data) + ? root.data as Record + : root; + const messages = Array.isArray(data.messages) ? data.messages : [data]; for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = asRecord(messages[index]); - if (!message || message.role !== "assistant") continue; - const content = message.content; + const message = messages[index]; + if (!message || typeof message !== "object") continue; + if ((message as { role?: unknown }).role !== "assistant") continue; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) continue; const text = content - .map((part) => { - const record = asRecord(part); - return record?.type === "text" && typeof record.text === "string" ? record.text : ""; - }) - .filter(Boolean) + .filter((block): block is { type: string; text: string } => ( + Boolean(block) + && typeof block === "object" + && (block as { type?: unknown }).type === "text" + && typeof (block as { text?: unknown }).text === "string" + )) + .map((block) => block.text) .join("\n\n") .trim(); if (text) return text; @@ -352,35 +418,54 @@ export function extractPiFinalResponse(value: unknown): string { return ""; } -export function extractPiProviderError(value: unknown): string { - const root = unwrapProviderPayload(value); - if (Array.isArray(root)) { - for (let index = root.length - 1; index >= 0; index -= 1) { - const error = extractPiProviderError(root[index]); - if (error) return error; +export function extractPiProviderError(value: unknown): string | undefined { + const records = Array.isArray(value) ? [...value] : [value]; + if (value && typeof value === "object" && !Array.isArray(value)) { + const nested = (value as Record).messages; + if (Array.isArray(nested)) records.push(...nested); + } + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (!record || typeof record !== "object") continue; + const event = record as Record; + if (event.type === "extension_error" && typeof event.error === "string") return event.error; + if (event.type === "message_end") { + const message = event.message; + if (message && typeof message === "object") { + const error = (message as Record).errorMessage; + if (typeof error === "string" && error) return error; + } + } + if (event.stopReason === "error" && typeof event.errorMessage === "string" && event.errorMessage) { + return event.errorMessage; } - return ""; } - const messages = readArray(root, "messages"); - if (messages) return extractPiProviderError(messages); - const record = asRecord(asRecord(root)?.message ?? root); - if (!record) return ""; - const error = record.errorMessage ?? record.error; - return typeof error === "string" ? error.trim() : ""; + return undefined; +} + +function recordBoolean(value: unknown, key: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return (value as Record)[key] === true; } -function unwrapProviderPayload(value: unknown): unknown { - const record = asRecord(value); - return record ? record.data ?? record.result ?? value : value; +function recordNumber(value: unknown, key: string): number | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = (value as Record)[key]; + return typeof result === "number" && Number.isFinite(result) ? result : undefined; } -function readArray(value: unknown, key: string): unknown[] | undefined { - const result = asRecord(value)?.[key]; - return Array.isArray(result) ? result : undefined; +function recordArray(value: unknown, key: string): unknown[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const result = (value as Record)[key]; + return Array.isArray(result) ? result : []; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; +function piProtocolError(operation: string, message: string, retryable = false): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", + operation, + retryable, + message, + }); } diff --git a/src/local-agent-presentation.test.ts b/src/local-agent-presentation.test.ts index ea1a4d6e6..4bf933d97 100644 --- a/src/local-agent-presentation.test.ts +++ b/src/local-agent-presentation.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import type { LocalAgentCatalog } from "./local-agent-catalog.js"; import { + formatAgentObservation, + formatAgentTurnEvent, presentAgentObservation, presentAgentReceipt, presentAgentSummary, @@ -13,9 +15,11 @@ const record: LocalAgentRecord = { workspaceId: "ws_private", workspaceRoot: "/private/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", + writeMode: "allowed", providerSessionId: "provider_private", status: "running", latestResponse: "previous response", @@ -62,12 +66,36 @@ assert.deepEqual(failed, { }, }); +// The turn id and the provider's own error cause are visible in fragments. +const failedWithCause = presentAgentObservation({ + ...record, + status: "error", + latestTurnId: "trn_12345678", + error: "Claude agent turn failed.", + errorCode: "PROVIDER_EXECUTION_ERROR", + errorRetryable: false, + errorDetail: "Claude returned an error result: rate ", +}); +assert.equal( + formatAgentObservation(failedWithCause), + 'Claude agent turn failed.Claude returned an error result: rate <limited>', +); +assert.equal( + formatAgentObservation(presentAgentObservation({ ...record, latestTurnId: "trn_12345678" })), + '', +); +assert.equal( + formatAgentTurnEvent({ type: "tool_completed", tool: "edit", status: "failed", turnId: "trn_1", seq: 3, at: "now" }), + '', +); + const catalog: LocalAgentCatalog = { enabled: true, providers: [ - { id: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, + { id: "codex", driver: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, { id: "claude", + driver: "claude", enabled: true, available: false, usable: false, diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index f5ff569ec..2599325e1 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -1,4 +1,5 @@ import type { LocalAgentCatalog } from "./local-agent-catalog.js"; +import type { LocalAgentTurnEvent } from "./local-agent-events.js"; import type { LocalAgentRecord, LocalAgentStatus } from "./local-agent-store.js"; export type AgentCommandStatus = "running" | "completed" | "failed" | "stopped"; @@ -26,6 +27,7 @@ export interface AgentTargetCatalogOutput { export interface AgentReceiptOutput { id: string; status: AgentCommandStatus; + turnId?: string; } export interface AgentSummaryOutput extends AgentReceiptOutput { @@ -36,6 +38,8 @@ export interface AgentFailureOutput { code: string; message: string; retryable: boolean; + /** The provider's own explanation, redacted and bounded. */ + detail?: string; } export interface AgentCommandErrorOutput { @@ -45,11 +49,12 @@ export interface AgentCommandErrorOutput { agentId?: string; } -export type AgentObservationOutput = +export type AgentObservationOutput = { turnId?: string } & ( | { id: string; status: "running"; wait?: "timeout" } | { id: string; status: "completed"; response?: string } | { id: string; status: "failed"; error: AgentFailureOutput } - | { id: string; status: "stopped"; error?: AgentFailureOutput }; + | { id: string; status: "stopped"; error?: AgentFailureOutput } +); export function presentAgentTargetCatalog(catalog: LocalAgentCatalog): AgentTargetCatalogOutput { return { @@ -75,7 +80,11 @@ export function presentAgentTargetCatalog(catalog: LocalAgentCatalog): AgentTarg } export function presentAgentReceipt(record: LocalAgentRecord): AgentReceiptOutput { - return { id: record.id, status: presentAgentStatus(record.status) }; + return { + id: record.id, + status: presentAgentStatus(record.status), + ...(record.latestTurnId ? { turnId: record.latestTurnId } : {}), + }; } export function presentAgentSummary(record: LocalAgentRecord): AgentSummaryOutput { @@ -100,7 +109,7 @@ export function presentAgentObservation(record: LocalAgentRecord): AgentObservat ...(hasAgentFailure(record) ? { error: presentAgentFailure(record) } : {}), }; case "running": - return { id: receipt.id, status: "running" }; + return { ...receipt, status: "running" }; } } @@ -115,31 +124,69 @@ export function formatAgentTargetCatalog(catalog: AgentTargetCatalogOutput): str } export function formatAgentReceipt(receipt: AgentReceiptOutput): string { - return ``; + return ``; } export function formatAgentSummary(summary: AgentSummaryOutput): string { - return ``; + return ``; } export function formatAgentObservation(observation: AgentObservationOutput): string { if (observation.status === "running" && observation.wait) { - return ``; + return ``; } if (observation.status === "completed" && observation.response !== undefined) { - return `${escapeXmlText(observation.response)}`; + return `${escapeXmlText(observation.response)}`; } if ((observation.status === "failed" || observation.status === "stopped") && observation.error) { - return `${escapeXmlText(observation.error.message)}`; + const { code, retryable, message, detail } = observation.error; + const cause = detail ? `${escapeXmlText(detail)}` : ""; + return `${escapeXmlText(message)}${cause}`; } return formatAgentReceipt(observation); } +export function formatAgentTurnEvent(event: LocalAgentTurnEvent): string { + const base = { seq: String(event.seq), type: event.type, turn: event.turnId }; + switch (event.type) { + case "turn_started": + return ``; + case "message": + return `${escapeXmlText(event.text)}`; + case "tool_started": + case "tool_completed": { + const attributes = xmlAttributes({ + ...base, + tool: event.tool, + status: event.type === "tool_completed" ? event.status : undefined, + }); + return event.summary + ? `${escapeXmlText(event.summary)}` + : ``; + } + case "turn_completed": + return ``; + } +} + export function formatAgentCommandError(error: AgentCommandErrorOutput): string { const agentId = error.agentId ? ` agent-id="${escapeXmlAttribute(error.agentId)}"` : ""; return `${escapeXmlText(error.message)}`; } +function agentAttributes( + agent: { id: string; status: string; turnId?: string }, + extra: Record = {}, +): string { + return xmlAttributes({ id: agent.id, status: agent.status, turn: agent.turnId, ...extra }); +} + function xmlAttributes(values: Record): string { return Object.entries(values) .filter((entry): entry is [string, string] => entry[1] !== undefined) @@ -186,5 +233,6 @@ function presentAgentFailure(record: LocalAgentRecord): AgentFailureOutput { code: record.errorCode ?? "AGENT_FAILED", message: record.error ?? "Subagent failed without an error message.", retryable: record.errorRetryable ?? false, + ...(record.errorDetail ? { detail: record.errorDetail } : {}), }; } diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 8a7d57db1..f22258b13 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -76,16 +76,17 @@ try { [ "---", "name: custom", - "description: Unsupported custom agent.", - "provider: custom", + "description: Named provider instance.", + "provider: codex-work", "---", "", "Custom body.", "", ].join("\n"), ); - const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); - assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); + const profilesWithNamedInstance = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.deepEqual(profilesWithNamedInstance.map((profile) => profile.name), ["custom", "reviewer"]); + assert.equal(profilesWithNamedInstance[0]?.provider, "codex-work"); const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [workspaceRoot] }, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index ad99a225c..2c55f824a 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -3,23 +3,12 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; - -export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot" | "grok"; - -export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ - "codex", - "claude", - "opencode", - "pi", - "cursor", - "copilot", - "grok", -]; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; export interface LocalAgentProfile { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; filePath: string; @@ -30,7 +19,7 @@ export interface LocalAgentProfile { export interface LocalAgentProfileSummary { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; } @@ -41,7 +30,6 @@ interface ParsedFrontmatter { } const FRONTMATTER_DELIMITER = "---"; -const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -154,21 +142,12 @@ function profileFromFrontmatter( }; } -function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { +function readProvider(frontmatter: Record, filePath: string): LocalAgentProviderInstanceId { const provider = readString(frontmatter, "provider"); if (!provider) { throw new Error(`Subagent profile is missing provider: ${filePath}`); } - if (!PROVIDERS.has(provider as LocalAgentProvider)) { - throw new Error( - `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, or grok: ${filePath}`, - ); - } - return provider as LocalAgentProvider; -} - -export function isLocalAgentProvider(value: string): value is LocalAgentProvider { - return PROVIDERS.has(value as LocalAgentProvider); + return provider; } function readString(frontmatter: Record, key: string): string | undefined { diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts new file mode 100644 index 000000000..60c70ffbb --- /dev/null +++ b/src/local-agent-provider-registry.ts @@ -0,0 +1,68 @@ +import type { + SubagentProviderConfig, + SubagentsConfig, +} from "./local-agent-config.js"; +import { + localAgentProviderEnvironment, + localAgentProviderEnvironmentOverrides, +} from "./local-agent-config.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; +import type { LocalAgentDriver } from "./local-agent-runtime.js"; + +export interface LocalAgentDriverFactoryContext { + instance: SubagentProviderConfig; + env: NodeJS.ProcessEnv; + envOverrides: Record; +} + +export type LocalAgentDriverFactory = ( + context: LocalAgentDriverFactoryContext, +) => LocalAgentDriver; + +export class LocalAgentProviderRegistry { + private readonly factories = new Map(); + + register(kind: LocalAgentDriverKind, factory: LocalAgentDriverFactory): this { + if (this.factories.has(kind)) throw new Error(`Local agent driver already registered: ${kind}`); + this.factories.set(kind, factory); + return this; + } + + create( + instance: SubagentProviderConfig, + subagents: SubagentsConfig | undefined, + inheritedEnv: NodeJS.ProcessEnv, + ): LocalAgentDriver { + const factory = this.factories.get(instance.driver); + if (!factory) throw new Error(`No local agent driver registered for: ${instance.driver}`); + const env = subagents + ? localAgentProviderEnvironment(subagents, instance.id, inheritedEnv) + : inheritedEnv; + const envOverrides = subagents + ? localAgentProviderEnvironmentOverrides(subagents, instance.id) + : {}; + return new ProviderInstanceDriver(instance.id, factory({ instance, env, envOverrides })); + } +} + +class ProviderInstanceDriver implements LocalAgentDriver { + readonly provider: LocalAgentDriverKind; + readonly runtimePolicy: LocalAgentDriver["runtimePolicy"]; + readonly capabilities: LocalAgentDriver["capabilities"]; + + constructor( + readonly providerInstanceId: LocalAgentProviderInstanceId, + private readonly driver: LocalAgentDriver, + ) { + this.provider = driver.provider; + this.runtimePolicy = driver.runtimePolicy; + this.capabilities = driver.capabilities; + } + + createRuntime(context: Parameters[0]) { + return this.driver.createRuntime(context); + } +} diff --git a/src/local-agent-provider.ts b/src/local-agent-provider.ts new file mode 100644 index 000000000..671ad11c6 --- /dev/null +++ b/src/local-agent-provider.ts @@ -0,0 +1,38 @@ +export const LOCAL_AGENT_DRIVER_KINDS = [ + "codex", + "claude", + "opencode", + "pi", + "acp", +] as const; + +export const LEGACY_ACP_DRIVER_KINDS = ["cursor", "copilot", "grok"] as const; +export const LOCAL_AGENT_DEFAULT_PROVIDER_IDS = [ + "codex", + "claude", + "opencode", + "pi", + ...LEGACY_ACP_DRIVER_KINDS, +] as const; + +export type LocalAgentDriverKind = typeof LOCAL_AGENT_DRIVER_KINDS[number]; + +/** User-configured routing identity. Multiple instances may share one driver. */ +export type LocalAgentProviderInstanceId = string; + +const DRIVER_KINDS = new Set(LOCAL_AGENT_DRIVER_KINDS); +const LEGACY_ACP_DRIVERS = new Set(LEGACY_ACP_DRIVER_KINDS); + +export function isLocalAgentDriverKind(value: string): value is LocalAgentDriverKind { + return DRIVER_KINDS.has(value); +} + +export function isLegacyAcpDriverKind(value: string): value is typeof LEGACY_ACP_DRIVER_KINDS[number] { + return LEGACY_ACP_DRIVERS.has(value); +} + +export function defaultDriverForProviderId(value: string): LocalAgentDriverKind | undefined { + if (isLocalAgentDriverKind(value)) return value; + if (isLegacyAcpDriverKind(value)) return "acp"; + return undefined; +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index a03150240..0624e1fdc 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -12,7 +12,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60_000; @@ -129,6 +129,7 @@ export class LocalAgentRuntimePool { if (reservationError) throw reservationError; await inputCallbacks?.onSessionId?.(providerSessionId); }, + ...(inputCallbacks?.onEvent ? { onEvent: inputCallbacks.onEvent } : {}), }; const startedAt = this.now(); try { @@ -209,7 +210,7 @@ export class LocalAgentRuntimePool { private async discardRuntime( entry: RuntimeEntry, - provider: LocalAgentProvider, + provider: LocalAgentDriverKind, reason: string, ): Promise { try { @@ -255,7 +256,7 @@ export class LocalAgentRuntimePool { driver: LocalAgentDriver, context: LocalAgentRuntimeContext, ): Promise> { - const key = driver.runtimeKey(context); + const key = runtimePoolKey(driver, context); while (true) { const existing = this.entries.get(key); if (existing && !existing.closing) { @@ -311,8 +312,8 @@ export class LocalAgentRuntimePool { entry = { key, driver, - idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, - sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, + idleTimeoutMs: driver.runtimePolicy.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + sessionIdleTimeoutMs: driver.runtimePolicy.sessionIdleTimeoutMs ?? this.sessionIdleTimeoutMs, createPromise, activeRuns: 0, lastUsedAt: this.now(), @@ -496,6 +497,35 @@ export class LocalAgentRuntimePool { } } +function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeContext): string { + const policy = driver.runtimePolicy; + const parts: Array = [ + context.providerInstanceId, + policy.scope, + ]; + switch (policy.scope) { + case "instance": + break; + case "workspace": + parts.push(context.workspaceRoot); + break; + case "agent": + parts.push(context.agentId); + break; + } + switch (policy.authority ?? "none") { + case "none": + break; + case "write_mode": + parts.push(context.writeMode ?? "allowed"); + break; + case "full_access_boundary": + parts.push(context.writeMode === "full_access" ? "full_access" : "restricted"); + break; + } + return JSON.stringify(parts); +} + function poolClosedError( driver: LocalAgentDriver, context: LocalAgentRuntimeContext, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index da2364005..1c158f1f3 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -15,8 +15,17 @@ import type { LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +const TEST_CAPABILITIES = { + sessions: { resume: true, close: false }, + turns: { interrupt: true, streaming: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: false }, +} as const; + const context: LocalAgentRuntimeContext = { agentId: "agt_test", + providerInstanceId: "codex", provider: "codex", workspaceRoot: "/tmp/project", }; @@ -92,9 +101,10 @@ class FakeRuntime implements LocalAgentRuntime { const runtime = new FakeRuntime(); let createCount = 0; const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shared", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => { createCount += 1; await Promise.resolve(); @@ -132,9 +142,10 @@ const sessionPool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const sessionDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "session-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(sessionRuntime), }; await sessionPool.run(sessionDriver, context, input); @@ -160,9 +171,10 @@ const shutdownReleasePool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const shutdownReleaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shutdown-release-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(shutdownReleaseRuntime), }; await shutdownReleasePool.run(shutdownReleaseDriver, context, input); @@ -209,8 +221,10 @@ class CleanupFailureRuntime extends FakeRuntime { const cleanupPool = new LocalAgentRuntimePool(); const cleanupRuntime = new CleanupFailureRuntime(); const cleanupDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "cleanup-runtime", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(cleanupRuntime), }; const cleanupFailure = await cleanupPool.run(cleanupDriver, context, input); @@ -225,8 +239,10 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider let attempts = 0; const recoveryPool = new LocalAgentRuntimePool(); const recoveryDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "dead-runtime-recovery", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), }; @@ -249,8 +265,10 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const completedTurnRuntime = new ClosingAfterTurnRuntime(); completedTurnPool = new LocalAgentRuntimePool(); const completedTurnDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "completed-turn-during-close", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(completedTurnRuntime), }; @@ -265,8 +283,10 @@ const creating = new Promise const raceRuntime = new FakeRuntime(); const racePool = new LocalAgentRuntimePool(); const raceDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-race", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: () => creating, }; const pendingRun = racePool.run(raceDriver, context, input); @@ -296,8 +316,10 @@ if (afterClose.isErr()) { const recoveryRuntime = new FakeRuntime(); const creationPool = new LocalAgentRuntimePool(); const creationDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-failure", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, async createRuntime() { createAttempts += 1; if (createAttempts === 1) { @@ -349,9 +371,10 @@ if (afterClose.isErr()) { sessionIdleTimeoutMs: 10, }); const releaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "release-failure", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(releaseRuntime), }; @@ -371,6 +394,61 @@ if (afterClose.isErr()) { await releasePool.close(); } +{ + let creates = 0; + const policyPool = new LocalAgentRuntimePool(); + const policyDriver = (providerInstanceId: string, runtimePolicy: LocalAgentDriver["runtimePolicy"]): LocalAgentDriver => ({ + providerInstanceId, + provider: "codex", + runtimePolicy, + capabilities: TEST_CAPABILITIES, + createRuntime: async () => { + creates += 1; + return Result.ok(new FakeRuntime()); + }, + }); + + const instanceDriver = policyDriver("codex-work", { scope: "instance" }); + await policyPool.run(instanceDriver, { ...context, providerInstanceId: "codex-work" }, input); + await policyPool.run(instanceDriver, { + ...context, + agentId: "agt_other", + providerInstanceId: "codex-work", + }, input); + assert.equal(creates, 1, "instance-scoped runtimes are shared across agents in one provider instance"); + + const personalDriver = policyDriver("codex-personal", { scope: "instance" }); + await policyPool.run(personalDriver, { + ...context, + providerInstanceId: "codex-personal", + }, input); + assert.equal(creates, 2, "provider instances never share a runtime even when they use the same driver"); + + const claudeDriver = policyDriver("claude-work", { + scope: "agent", + authority: "full_access_boundary", + }); + const claudeContext = { + ...context, + agentId: "agt_claude", + providerInstanceId: "claude-work", + }; + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "read_only" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "allowed" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "full_access" }, input); + assert.equal(creates, 4, "full-access authority is isolated while restricted modes share one agent runtime"); + + const acpDriver = policyDriver("cursor", { scope: "workspace", authority: "write_mode" }); + const acpContext = { ...context, providerInstanceId: "cursor", workspaceRoot: "/tmp/a" }; + await policyPool.run(acpDriver, { ...acpContext, writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, agentId: "agt_acp_2", writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, writeMode: "read_only" }, input); + await policyPool.run(acpDriver, { ...acpContext, workspaceRoot: "/tmp/b", writeMode: "allowed" }, input); + assert.equal(creates, 7, "workspace and write mode are explicit ACP runtime isolation dimensions"); + + await policyPool.close(); +} + function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; return result.value; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ecc40f545..73c758d30 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,6 +1,10 @@ import type { Result } from "better-result"; import type { AgentProviderError } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentActivityEvent } from "./local-agent-events.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -13,10 +17,11 @@ export interface LocalAgentRunInput { effort?: string; modelOverrideRequested?: boolean; effortOverrideRequested?: boolean; + signal?: AbortSignal; } export interface LocalAgentRunResult { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; providerSessionId: string | null; finalResponse: string; items: unknown[]; @@ -29,11 +34,17 @@ export interface LocalAgentRunCallbacks { * could otherwise fail and lose that identity. */ onSessionId?: (providerSessionId: string) => void | Promise; + /** + * Receives normalized activity while the turn runs. It never throws, so + * drivers may call it inline from their provider event loop. + */ + onEvent?: (event: LocalAgentActivityEvent) => void; } export interface LocalAgentRuntimeContext { agentId: string; - provider: LocalAgentProvider; + providerInstanceId: LocalAgentProviderInstanceId; + provider: LocalAgentDriverKind; workspaceRoot: string; providerSessionId?: string; writeMode?: LocalAgentWriteMode; @@ -42,13 +53,32 @@ export interface LocalAgentRuntimeContext { agentDir?: string; } +export type LocalAgentRuntimeScope = "instance" | "workspace" | "agent"; +export type LocalAgentRuntimeAuthority = "none" | "write_mode" | "full_access_boundary"; + +export interface LocalAgentRuntimePolicy { + scope: LocalAgentRuntimeScope; + authority?: LocalAgentRuntimeAuthority; + idleTimeoutMs?: number; + sessionIdleTimeoutMs?: number; +} + +export interface LocalAgentCapabilities { + sessions: { resume: boolean; close: boolean }; + /** `streaming` means the driver reports messages and tool activity while a turn runs. */ + turns: { interrupt: boolean; streaming: boolean }; + configuration: { modelOverride: boolean; effortOverride: boolean }; + permissions: { enforcement: "native" | "client-boundary" | "unsupported" }; + mcp: { supported: boolean }; +} + /** * A runtime is deliberately disposable. Nothing from this interface is * persisted; the provider session ID in LocalAgentStore is the continuation * identity used when a later runtime is created. */ export interface LocalAgentRuntime { - readonly provider: LocalAgentProvider; + readonly provider: LocalAgentDriverKind; run( input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks, @@ -59,8 +89,9 @@ export interface LocalAgentRuntime { } export interface LocalAgentDriver { - readonly provider: LocalAgentProvider; - runtimeKey(context: LocalAgentRuntimeContext): string; + readonly providerInstanceId: LocalAgentProviderInstanceId; + readonly provider: LocalAgentDriverKind; + readonly runtimePolicy: LocalAgentRuntimePolicy; + readonly capabilities: LocalAgentCapabilities; createRuntime(context: LocalAgentRuntimeContext): Promise>; - readonly idleTimeoutMs?: number; } diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index e29bd319b..c2e83bd04 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; +import crypto from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mock } from "node:test"; +import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import Database from "better-sqlite3"; @@ -16,17 +19,75 @@ try { workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex-work", + driver: "codex", model: "gpt-5.4", effort: "high", }); assert.match(created.id, /^agt_[a-f0-9]{8}$/); assert.equal(created.status, "starting"); + assert.equal(created.writeMode, "allowed"); assert.equal(store.getById(created.id)?.effort, "high"); assert.equal(store.getById(created.id)?.profileName, "reviewer"); + assert.equal(store.getById(created.id)?.providerInstanceId, "codex-work"); + assert.equal(store.getById(created.id)?.driver, "codex"); assert.equal(store.getById(created.id.slice(0, 7)), undefined); + // A generated id that collides with an existing agent must be redrawn, never aliased. + const uuids = [ + `${created.id.slice(4)}-0000-4000-8000-000000000000`, + "bbbbbbbb-0000-4000-8000-000000000000", + ]; + const uuidMock = mock.method(crypto, "randomUUID", () => uuids.shift() as `${string}-${string}-${string}-${string}-${string}`); + syncBuiltinESMExports(); + try { + const redrawn = store.create({ + workspaceId: "ws_collision", + workspaceRoot: join(root, "collision-project"), + profileName: "reviewer", + providerInstanceId: "codex-work", + driver: "codex", + model: "gpt-5.4", + effort: "high", + }); + assert.equal(redrawn.id, "agt_bbbbbbbb"); + assert.notEqual(redrawn.id, created.id); + } finally { + uuidMock.mock.restore(); + syncBuiltinESMExports(); + } + + const ownedDispatch = store.create({ + id: "workflow-dispatch-store", + workspaceId: "ws_dispatch", + workspaceRoot: join(root, "dispatch-project"), + profileName: "reviewer", + providerInstanceId: "codex", + driver: "codex", + writeMode: "read_only", + }); + assert.equal(ownedDispatch.id, "workflow-dispatch-store"); + assert.equal(ownedDispatch.writeMode, "read_only"); + assert.equal(store.create({ + id: ownedDispatch.id, + workspaceId: "ws_dispatch", + workspaceRoot: join(root, "dispatch-project"), + profileName: "reviewer", + providerInstanceId: "codex", + driver: "codex", + writeMode: "read_only", + }).id, ownedDispatch.id); + assert.throws(() => store.create({ + id: ownedDispatch.id, + workspaceId: "ws_dispatch", + workspaceRoot: join(root, "other"), + profileName: "reviewer", + providerInstanceId: "codex", + driver: "codex", + writeMode: "read_only", + }), /different dispatch/); + const updated = store.update(created.id, { status: "error", latestResponse: "done", @@ -57,6 +118,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); const begun = store.beginTurn(created.id, { + turnId: "trn_test1", prompt: "Review the current changes.", model: updated.model, effort: updated.effort, @@ -68,6 +130,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.equal(begun.turn.completedAt, undefined); assert.throws( () => store.beginTurn(created.id, { + turnId: "trn_test2", prompt: "Start overlapping work.", model: begun.agent.model, effort: begun.agent.effort, @@ -91,6 +154,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.ok(completedTurn?.completedAt); const failing = store.beginTurn(created.id, { + turnId: "trn_test3", prompt: "Retry the review.", model: completed.model, effort: completed.effort, @@ -130,7 +194,8 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "explorer", - provider: "claude", + providerInstanceId: "claude", + driver: "claude", }); assert.deepEqual( @@ -180,7 +245,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot "agt_legacy", join(root, "legacy-project"), "reviewer", - "codex", + "cursor", "high", "error", "old error", @@ -194,6 +259,13 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot const legacyRecord = upgradedStore.getById("agt_legacy"); assert.equal(legacyRecord?.error, "old error"); assert.equal(legacyRecord?.effort, "high"); + assert.equal(legacyRecord?.providerInstanceId, "cursor"); + assert.equal(legacyRecord?.driver, "acp"); + const inspect = new Database(databasePath(legacyStateDir), { readonly: true }); + const driverColumn = (inspect.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; notnull: number }>) + .find((column) => column.name === "driver"); + inspect.close(); + assert.equal(driverColumn?.notnull, 1, "migrated driver column rejects NULL like a fresh schema"); assert.equal(legacyRecord?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); const upgradedRecord = upgradedStore.update("agt_legacy", { @@ -207,11 +279,14 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.equal(reloadedRecord?.errorCode, "DAEMON_TIMEOUT"); assert.equal(reloadedRecord?.errorRetryable, true); const legacyTurn = upgradedStore.beginTurn("agt_legacy", { + turnId: "trn_test4", prompt: "Continue after upgrade.", model: reloadedRecord?.model, effort: reloadedRecord?.effort, }); assert.equal(legacyTurn.turn.status, "running"); + assert.equal(legacyTurn.turn.turnId, "trn_test4"); + assert.equal(upgradedStore.getById("agt_legacy")?.latestTurnId, "trn_test4"); } finally { for (const store of stores) { store.close(); diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 3aab64da6..d27cbd5fe 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -3,6 +3,9 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; +import type { LocalAgentTurnEvent, LocalAgentTurnEventBody } from "./local-agent-events.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; @@ -12,30 +15,48 @@ export interface LocalAgentRecord { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; + writeMode: LocalAgentWriteMode; providerSessionId?: string; status: LocalAgentStatus; latestResponse?: string; error?: string; errorCode?: string; errorRetryable?: boolean; + errorDetail?: string; + latestTurnId?: string; createdAt: string; updatedAt: string; } export interface CreateLocalAgentRecordInput { + id?: string; workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; + writeMode?: LocalAgentWriteMode; + dispatchSignature?: string; } +export interface CreatedLocalAgentRecord { + record: LocalAgentRecord; + created: boolean; +} + +export class LocalAgentDispatchConflictError extends Error {} + export interface LocalAgentTurnRecord { + /** Store row identity; never leaves the daemon. */ id: number; + /** App-owned turn identity shown to hosts and attached to turn events. */ + turnId: string; agentId: string; prompt: string; status: LocalAgentTurnStatus; @@ -43,20 +64,23 @@ export interface LocalAgentTurnRecord { error?: string; errorCode?: string; errorRetryable?: boolean; + errorDetail?: string; createdAt: string; completedAt?: string; } export interface BeginLocalAgentTurnInput { + turnId: string; prompt: string; model?: string; effort?: string; + writeMode?: LocalAgentWriteMode; } export type FinishLocalAgentTurnInput = | { status: "completed"; response?: string; providerSessionId?: string } - | { status: "failed"; error: string; errorCode: string; errorRetryable: boolean } - | { status: "stopped"; error?: string; errorCode?: string; errorRetryable?: boolean }; + | { status: "failed"; error: string; errorCode: string; errorRetryable: boolean; errorDetail?: string } + | { status: "stopped"; error?: string; errorCode?: string; errorRetryable?: boolean; errorDetail?: string }; export interface BegunLocalAgentTurn { agent: LocalAgentRecord; @@ -78,21 +102,27 @@ interface LocalAgentRow { workspace_id: string | null; workspace_root: string; profile_name: string; - provider: string; + provider_instance_id: string; + driver: LocalAgentDriverKind; model: string | null; effort: string | null; + write_mode: string; + dispatch_signature: string | null; provider_session_id: string | null; status: string; latest_response: string | null; error: string | null; error_code: string | null; error_retryable: string | null; + error_detail: string | null; + latest_turn_id: string | null; created_at: string; updated_at: string; } interface LocalAgentTurnRow { id: number; + turn_id: string; agent_id: string; prompt: string; status: string; @@ -100,10 +130,21 @@ interface LocalAgentTurnRow { error: string | null; error_code: string | null; error_retryable: string | null; + error_detail: string | null; created_at: string; completed_at: string | null; } +interface LocalAgentTurnEventRow { + seq: number; + type: string; + data: string; + created_at: string; +} + +// Older turns keep their history row but drop their event log. +const TURNS_WITH_EVENTS_PER_AGENT = 20; + export class LocalAgentStore { private readonly database: DatabaseHandle; @@ -151,64 +192,98 @@ export class LocalAgentStore { } create(input: CreateLocalAgentRecordInput): LocalAgentRecord { - const now = new Date().toISOString(); - const record: LocalAgentRecord = { - id: `agt_${randomUUID().replaceAll("-", "").slice(0, 8)}`, - workspaceId: input.workspaceId, - workspaceRoot: resolve(input.workspaceRoot), - profileName: input.profileName, - provider: input.provider, - model: input.model, - effort: input.effort, - status: "starting", - createdAt: now, - updatedAt: now, - }; + return this.createDispatch(input).record; + } - this.database.sqlite - .prepare( - `insert into local_agent_sessions ( - id, - workspace_id, - workspace_root, - profile_name, - provider, - model, - effort, - status, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - record.id, - record.workspaceId ?? null, - record.workspaceRoot, - record.profileName, - record.provider, - record.model ?? null, - record.effort ?? null, - record.status, - record.createdAt, - record.updatedAt, - ); + private createDispatch(input: CreateLocalAgentRecordInput): CreatedLocalAgentRecord { + return this.database.sqlite.transaction(() => { + const generate = () => `agt_${randomUUID().replaceAll("-", "").slice(0, 8)}`; + let id = input.id ?? generate(); + validateAgentId(id); + let existing = this.getRowById(id); + // Only caller-supplied ids are idempotent; a generated id that collides must never alias another agent. + if (!input.id) { + while (existing) { + id = generate(); + existing = this.getRowById(id); + } + } + if (existing) { + const record = rowToLocalAgentRecord(existing); + validateDispatchIdentity(record, input, existing.dispatch_signature); + return { record, created: false }; + } + const now = new Date().toISOString(); + const record: LocalAgentRecord = { + id, + workspaceId: input.workspaceId, + workspaceRoot: resolve(input.workspaceRoot), + profileName: input.profileName, + providerInstanceId: input.providerInstanceId, + driver: input.driver, + model: input.model, + effort: input.effort, + writeMode: input.writeMode ?? "allowed", + status: "starting", + createdAt: now, + updatedAt: now, + }; + + this.database.sqlite + .prepare( + `insert into local_agent_sessions ( + id, + workspace_id, + workspace_root, + profile_name, + provider_instance_id, + driver, + model, + effort, + write_mode, + dispatch_signature, + status, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.workspaceId ?? null, + record.workspaceRoot, + record.profileName, + record.providerInstanceId, + record.driver, + record.model ?? null, + record.effort ?? null, + record.writeMode, + input.dispatchSignature ?? null, + record.status, + record.createdAt, + record.updatedAt, + ); - return record; + return { record, created: true }; + }).immediate(); } - createResult(input: CreateLocalAgentRecordInput): BetterResult { - return storeResult("create", () => this.create(input)); + createResult(input: CreateLocalAgentRecordInput): BetterResult { + return storeResult("create", () => this.createDispatch(input)); } getById(id: string): LocalAgentRecord | undefined { - const exact = this.database.sqlite + const exact = this.getRowById(id); + return exact ? rowToLocalAgentRecord(exact) : undefined; + } + + private getRowById(id: string): LocalAgentRow | undefined { + return this.database.sqlite .prepare( `select * from local_agent_sessions where id = ? limit 1`, ) .get(id) as LocalAgentRow | undefined; - return exact ? rowToLocalAgentRecord(exact) : undefined; } getByIdResult(id: string): BetterResult { @@ -239,15 +314,19 @@ export class LocalAgentStore { workspace_id = ?, workspace_root = ?, profile_name = ?, - provider = ?, + provider_instance_id = ?, + driver = ?, model = ?, effort = ?, + write_mode = ?, provider_session_id = ?, status = ?, latest_response = ?, error = ?, error_code = ?, error_retryable = ?, + error_detail = ?, + latest_turn_id = ?, updated_at = ? where id = ?`, ) @@ -255,15 +334,19 @@ export class LocalAgentStore { updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, - updated.provider, + updated.providerInstanceId, + updated.driver, updated.model ?? null, updated.effort ?? null, + updated.writeMode, updated.providerSessionId ?? null, updated.status, updated.latestResponse ?? null, updated.error ?? null, updated.errorCode ?? null, updated.errorRetryable === undefined ? null : String(updated.errorRetryable), + updated.errorDetail ?? null, + updated.latestTurnId ?? null, updated.updatedAt, updated.id, ); @@ -289,21 +372,34 @@ export class LocalAgentStore { status: "running", model: input.model, effort: input.effort, + writeMode: input.writeMode ?? current.writeMode, latestResponse: undefined, error: undefined, errorCode: undefined, errorRetryable: undefined, + errorDetail: undefined, + latestTurnId: input.turnId, }); const result = this.database.sqlite .prepare( `insert into local_agent_turns ( + turn_id, agent_id, prompt, status, created_at - ) values (?, ?, 'running', ?)`, + ) values (?, ?, ?, 'running', ?)`, ) - .run(agentId, input.prompt, agent.updatedAt); + .run(input.turnId, agentId, input.prompt, agent.updatedAt); + this.database.sqlite + .prepare( + `delete from local_agent_turn_events + where turn_row_id in ( + select id from local_agent_turns where agent_id = ? + order by id desc limit -1 offset ? + )`, + ) + .run(agentId, TURNS_WITH_EVENTS_PER_AGENT); const turn = this.getTurnById(Number(result.lastInsertRowid)); if (!turn) throw new Error(`Unable to load the new turn for subagent ${agentId}.`); return { agent, turn }; @@ -342,6 +438,7 @@ export class LocalAgentStore { error = ?, error_code = ?, error_retryable = ?, + error_detail = ?, completed_at = ? where id = ? and agent_id = ?`, ) @@ -353,6 +450,7 @@ export class LocalAgentStore { completion.status === "completed" || completion.errorRetryable === undefined ? null : String(completion.errorRetryable), + completion.status === "completed" ? null : completion.errorDetail ?? null, completedAt, turnId, agentId, @@ -366,6 +464,7 @@ export class LocalAgentStore { error: undefined, errorCode: undefined, errorRetryable: undefined, + errorDetail: undefined, }); } return this.update(agentId, { @@ -374,6 +473,7 @@ export class LocalAgentStore { error: completion.error, errorCode: completion.errorCode, errorRetryable: completion.errorRetryable, + errorDetail: completion.errorDetail, }); }).immediate(); } @@ -399,6 +499,60 @@ export class LocalAgentStore { return storeResult("get_turn", () => this.getTurnById(turnId)); } + getTurnByTurnId(turnId: string): LocalAgentTurnRecord | undefined { + const row = this.database.sqlite + .prepare("select * from local_agent_turns where turn_id = ? limit 1") + .get(turnId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; + } + + getTurnByTurnIdResult( + turnId: string, + ): BetterResult { + return storeResult("get_turn", () => this.getTurnByTurnId(turnId)); + } + + appendTurnEvent(turn: LocalAgentTurnRecord, seq: number, event: LocalAgentTurnEventBody, at: string): void { + this.database.sqlite + .prepare( + `insert into local_agent_turn_events (turn_row_id, seq, type, data, created_at) + values (?, ?, ?, ?, ?)`, + ) + .run(turn.id, seq, event.type, JSON.stringify(event), at); + } + + appendTurnEventResult( + turn: LocalAgentTurnRecord, + seq: number, + event: LocalAgentTurnEventBody, + at: string, + ): BetterResult { + return storeResult("append_turn_event", () => this.appendTurnEvent(turn, seq, event, at)); + } + + listTurnEvents(turn: LocalAgentTurnRecord, afterSeq = 0): LocalAgentTurnEvent[] { + const rows = this.database.sqlite + .prepare( + `select seq, type, data, created_at from local_agent_turn_events + where turn_row_id = ? and seq > ? + order by seq asc`, + ) + .all(turn.id, afterSeq) as LocalAgentTurnEventRow[]; + return rows.map((row) => ({ + ...(JSON.parse(row.data) as LocalAgentTurnEventBody), + turnId: turn.turnId, + seq: row.seq, + at: row.created_at, + })); + } + + listTurnEventsResult( + turn: LocalAgentTurnRecord, + afterSeq?: number, + ): BetterResult { + return storeResult("list_turn_events", () => this.listTurnEvents(turn, afterSeq)); + } + getLatestTurn(agentId: string): LocalAgentTurnRecord | undefined { const row = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id desc limit 1") @@ -463,15 +617,19 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { workspaceId: row.workspace_id ?? undefined, workspaceRoot: row.workspace_root, profileName: row.profile_name, - provider: row.provider, + providerInstanceId: row.provider_instance_id, + driver: row.driver, model: row.model ?? undefined, effort: row.effort ?? undefined, + writeMode: readWriteMode(row.write_mode), providerSessionId: row.provider_session_id ?? undefined, status: readStatus(row.status), latestResponse: row.latest_response ?? undefined, error: row.error ?? undefined, errorCode: row.error_code ?? undefined, errorRetryable: readOptionalBoolean(row.error_retryable), + errorDetail: row.error_detail ?? undefined, + latestTurnId: row.latest_turn_id ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -480,6 +638,7 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { function rowToLocalAgentTurnRecord(row: LocalAgentTurnRow): LocalAgentTurnRecord { return { id: row.id, + turnId: row.turn_id, agentId: row.agent_id, prompt: row.prompt, status: readTurnStatus(row.status), @@ -487,6 +646,7 @@ function rowToLocalAgentTurnRecord(row: LocalAgentTurnRow): LocalAgentTurnRecord error: row.error ?? undefined, errorCode: row.error_code ?? undefined, errorRetryable: readOptionalBoolean(row.error_retryable), + errorDetail: row.error_detail ?? undefined, createdAt: row.created_at, completedAt: row.completed_at ?? undefined, }; @@ -526,3 +686,50 @@ function readStatus(status: string): LocalAgentStatus { } return "error"; } + +function readWriteMode(value: string): LocalAgentWriteMode { + if (value === "read_only" || value === "allowed" || value === "full_access") return value; + throw new Error(`Invalid stored local agent write mode: ${value}`); +} + +function validateAgentId(id: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) { + throw new Error("Subagent id must be 1-128 characters using letters, numbers, '.', '_', ':' or '-'."); + } +} + +function validateDispatchIdentity( + existing: LocalAgentRecord, + input: CreateLocalAgentRecordInput, + storedSignature: string | null, +): void { + const expected = { + workspaceId: input.workspaceId, + workspaceRoot: resolve(input.workspaceRoot), + profileName: input.profileName, + providerInstanceId: input.providerInstanceId, + driver: input.driver, + }; + for (const key of Object.keys(expected) as Array) { + if (existing[key] !== expected[key]) { + throw new LocalAgentDispatchConflictError( + `Subagent id ${existing.id} is already owned by a different dispatch.`, + ); + } + } + if (storedSignature !== (input.dispatchSignature ?? null)) { + throw new LocalAgentDispatchConflictError( + `Subagent id ${existing.id} is already owned by a different dispatch.`, + ); + } + if (storedSignature !== null) return; + if ( + existing.model !== input.model + || existing.effort !== input.effort + || existing.writeMode !== (input.writeMode ?? "allowed") + ) { + throw new LocalAgentDispatchConflictError( + `Subagent id ${existing.id} is already owned by a different dispatch.`, + ); + } +} diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index fde0c000e..ff7a197cb 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -106,7 +106,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, undefined, undefined, providers); assert.equal(target?.kind, "provider"); assert.equal(target?.name, "opencode"); assert.equal(target?.provider, "opencode"); @@ -115,7 +116,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep"); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep", providers); assert.equal(target?.kind, "provider"); assert.equal(target?.model, "kimi-k2"); assert.equal(target?.effort, "deep"); @@ -124,6 +126,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { { const providerDefaults = [{ id: "codex", + driver: "codex", enabled: true, model: "gpt-default", effort: "medium", @@ -139,6 +142,19 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { assert.equal(overridden?.effort, "xhigh"); } +{ + const providerInstances = [{ + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", + }] as const; + const target = resolveLocalAgentTarget("codex-work", profiles, undefined, undefined, providerInstances); + assert.equal(target?.kind, "provider"); + assert.equal(target?.provider, "codex-work"); + assert.equal(target?.model, "gpt-work"); +} + { const target = resolveLocalAgentTarget("claude", profiles); assert.equal(target?.kind, "profile"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 367b04761..d3794810c 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,9 +1,5 @@ -import { - isLocalAgentProvider, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; import type { SubagentProviderConfig } from "./local-agent-config.js"; export interface ParsedLocalAgentRunArgs { @@ -24,15 +20,15 @@ export type LocalAgentTarget = | { kind: "profile"; name: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; profile: LocalAgentProfile; } | { kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; + name: LocalAgentProviderInstanceId; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; }; @@ -143,8 +139,8 @@ export function resolveLocalAgentTarget( }; } - if (isLocalAgentProvider(target)) { - const providerConfig = providerConfigs.find((entry) => entry.id === target); + const providerConfig = providerConfigs.find((entry) => entry.id === target); + if (providerConfig) { return { kind: "provider", name: target, diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 90a8ade25..8422ab495 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -52,6 +52,11 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, + { version: 9, name: "local-agent-provider-instances" }, + { version: 10, name: "local-agent-generic-acp-driver" }, + { version: 11, name: "local-agent-turn-events" }, + { version: 12, name: "local-agent-authority" }, + { version: 13, name: "dynamic-workflows" }, ]); } finally { database.close(); diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index 9a5d02e8c..48049943e 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -22,8 +22,8 @@ assert.deepEqual( enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: true }, ], }, ); @@ -34,30 +34,34 @@ const configured = { providers: [ { id: "codex" as const, + driver: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude" as const, enabled: true, model: "sonnet" }, + { id: "claude" as const, driver: "claude" as const, enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex" as const, enabled: true, model: "gpt-work" }, ], }; assert.deepEqual( - updateOnboardingSubagentsConfig(configured, ["claude"]), + updateOnboardingSubagentsConfig(configured, ["claude", "codex-work"]), { enabled: true, instructions: "preload", providers: [ { id: "codex", + driver: "codex", enabled: false, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: true, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, ], }, ); diff --git a/src/onboarding.ts b/src/onboarding.ts index 193dd57dd..fb2f0c2ec 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,8 +1,10 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + defaultDriverForProviderId, + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, + isLocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export const SUBAGENT_SKILL_INSTALL_COMMAND = "npx skills add Waishnav/devspace --skill subagents --global"; @@ -31,21 +33,29 @@ export function usesCodingAgents(usage: OnboardingUsage): boolean { export function updateOnboardingSubagentsConfig( current: SubagentsConfig, - selectedProviders: readonly LocalAgentProvider[], + selectedProviders: readonly LocalAgentProviderInstanceId[], ): SubagentsConfig { const selected = new Set(selectedProviders); + const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); + const customInstances = current.providers + .filter((provider) => !defaultIds.has(provider.id)) + .map((provider) => ({ ...provider, enabled: selected.has(provider.id) })); return { enabled: true, instructions: current.instructions, - providers: LOCAL_AGENT_PROVIDERS + providers: [ + ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS .filter((id) => selected.has(id) || current.providers.some((provider) => provider.id === id)) .map((id) => { const existing = current.providers.find((provider) => provider.id === id); return { ...existing, id, + driver: existing?.driver ?? defaultDriverForProviderId(id)!, enabled: selected.has(id), }; }), + ...customInstances, + ], }; } diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..d93515a61 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -12,6 +12,7 @@ import { loadConfig, type ServerConfig, type ToolMode } from "./config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; import type { SubagentsConfig } from "./local-agent-config.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer, createServer } from "./server.js"; @@ -415,8 +416,8 @@ test("open_workspace omits providers disabled by configuration", async (t) => { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: false }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: false }, ], }, }); @@ -445,7 +446,7 @@ test("open_workspace preloads subagent instructions when configured", async (t) subagents: { enabled: true, instructions: "preload", - providers: [{ id: "codex", enabled: true }], + providers: [{ id: "codex", driver: "codex", enabled: true }], }, }); @@ -775,10 +776,12 @@ async function fixture( subagents: options.subagents ?? { enabled: true, instructions: "on-demand", - providers: initialProviderAvailability.map((provider) => ({ - id: provider.name, - enabled: true, - })), + providers: initialProviderAvailability.map((provider) => { + if (!isLocalAgentDriverKind(provider.name)) { + throw new Error(`Test provider instance requires explicit config: ${provider.name}`); + } + return { id: provider.name, driver: provider.name, enabled: true }; + }), }, } : modeConfig; diff --git a/src/skills.test.ts b/src/skills.test.ts index 357c417db..aa3ea07c0 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import assert from "node:assert/strict"; @@ -224,6 +225,24 @@ try { await readFile(join(devspaceSkills, "subagents", "SKILL.md"), "utf8"), /# DevSpace subagents/, ); + // Workflows stay hidden unless the experimental environment flag is set. + assert.equal(experimentalSkills.some((skill) => skill.name === "workflows"), false); + assert.equal(existsSync(join(devspaceSkills, "workflows")), false); + process.env.DEVSPACE_EXPERIMENTAL_WORKFLOWS = "1"; + try { + const flagged = loadWorkspaceSkills(experimentalConfig, projectRoot).skills; + assert.equal( + flagged.find((skill) => skill.name === "workflows")?.filePath, + join(devspaceSkills, "workflows", "SKILL.md"), + ); + } finally { + delete process.env.DEVSPACE_EXPERIMENTAL_WORKFLOWS; + } + assert.equal( + loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some((skill) => skill.name === "workflows"), + false, + "a managed copy synced while the flag was on is not advertised once it is off", + ); const duplicateConfig = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, diff --git a/src/skills.ts b/src/skills.ts index 8f67d8e07..7c9c9a62b 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -18,6 +18,7 @@ import { } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { expandHomePath, isPathInsideRoot } from "./roots.js"; +import { workflowsEnabled } from "./workflow-flag.js"; export interface LoadedSkills { skills: Skill[]; @@ -30,19 +31,22 @@ export interface SkillReadResolution { } const SUBAGENTS_SKILL_NAME = "subagents"; -const SUBAGENTS_SKILL = join(SUBAGENTS_SKILL_NAME, "SKILL.md"); +const WORKFLOWS_SKILL_NAME = "workflows"; +type ManagedSkillName = typeof SUBAGENTS_SKILL_NAME | typeof WORKFLOWS_SKILL_NAME; -function bundledSkillsDir(): string { - return fileURLToPath(new URL("../skills", import.meta.url)); +// The workflows skill is part of the hidden DEVSPACE_EXPERIMENTAL_WORKFLOWS feature. +function managedSkillNames(): ManagedSkillName[] { + return workflowsEnabled() ? [SUBAGENTS_SKILL_NAME, WORKFLOWS_SKILL_NAME] : [SUBAGENTS_SKILL_NAME]; } -function bundledSubagentsSkillPath(): string { - return join(bundledSkillsDir(), SUBAGENTS_SKILL); +function bundledSkillsDir(): string { + return fileURLToPath(new URL("../skills", import.meta.url)); } -function syncManagedSubagentsSkill(config: ServerConfig): string { - const sourcePath = bundledSubagentsSkillPath(); - const targetPath = join(config.devspaceSkillsDir, SUBAGENTS_SKILL); +function syncManagedSkill(config: ServerConfig, name: ManagedSkillName): string { + const skillPath = join(name, "SKILL.md"); + const sourcePath = join(bundledSkillsDir(), skillPath); + const targetPath = join(config.devspaceSkillsDir, skillPath); const source = readFileSync(sourcePath, "utf8"); if (existsSync(targetPath)) { @@ -51,7 +55,7 @@ function syncManagedSubagentsSkill(config: ServerConfig): string { return targetPath; } if (stat.isDirectory()) { - throw new Error(`Managed subagents skill path is a directory: ${targetPath}`); + throw new Error(`Managed ${name} skill path is a directory: ${targetPath}`); } } @@ -96,8 +100,9 @@ function resolveSkillPath(path: string, cwd: string): string { export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; + const managedNames = managedSkillNames(); if (config.subagents.enabled) { - syncManagedSubagentsSkill(config); + for (const name of managedNames) syncManagedSkill(config, name); } const result = loadSkills({ @@ -107,30 +112,38 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk includeDefaults: false, }); - const withoutSubagents = withoutSubagentsSkill(result); - if (!config.subagents.enabled) return withoutSubagents; + const withoutManaged = withoutManagedSkills(config, result, managedNames); + if (!config.subagents.enabled) return withoutManaged; - const managedDir = dirname(join(config.devspaceSkillsDir, SUBAGENTS_SKILL)); - const managed = loadSkillsFromDir({ - dir: managedDir, - source: "devspace", - }).skills.find((skill) => skill.name === SUBAGENTS_SKILL_NAME); - if (!managed) { - throw new Error("Managed subagents skill could not be loaded."); - } + const managed = managedNames.map((name) => { + const managedDir = dirname(join(config.devspaceSkillsDir, name, "SKILL.md")); + const skill = loadSkillsFromDir({ dir: managedDir, source: "devspace" }) + .skills.find((entry) => entry.name === name); + if (!skill) throw new Error(`Managed ${name} skill could not be loaded.`); + return skill; + }); return { - skills: [...withoutSubagents.skills, managed], - diagnostics: withoutSubagents.diagnostics, + skills: [...withoutManaged.skills, ...managed], + diagnostics: withoutManaged.diagnostics, }; } -function withoutSubagentsSkill(result: LoadSkillsResult): LoadedSkills { +function withoutManagedSkills( + config: ServerConfig, + result: LoadSkillsResult, + managedNames: readonly ManagedSkillName[], +): LoadedSkills { + // A managed workflows copy left behind while the feature was on must not be advertised once it is off. + const staleWorkflows = join(config.devspaceSkillsDir, WORKFLOWS_SKILL_NAME); return { - skills: result.skills.filter((skill) => skill.name !== SUBAGENTS_SKILL_NAME), + skills: result.skills.filter((skill) => ( + !managedNames.includes(skill.name as ManagedSkillName) + && !(skill.name === WORKFLOWS_SKILL_NAME && isPathInsideRoot(skill.filePath, staleWorkflows)) + )), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; - return !(collision?.resourceType === "skill" && collision.name === SUBAGENTS_SKILL_NAME); + return !(collision?.resourceType === "skill" && managedNames.includes(collision.name as ManagedSkillName)); }), }; } diff --git a/src/workflow-cli.test.ts b/src/workflow-cli.test.ts new file mode 100644 index 000000000..f0f124476 --- /dev/null +++ b/src/workflow-cli.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + ensureLocalAgentDaemonSecret, + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; +import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; +import type { WorkflowRun } from "./workflow-types.js"; + +const execFileAsync = promisify(execFile); +// macOS limits the complete Unix socket path to 104 bytes. +const root = await mkdtemp(join(tmpdir(), "ds-wf-")); +const configDir = join(root, "config"); +const stateDir = join(root, "state"); +const workspaceDirectory = join(root, "project"); +await mkdir(workspaceDirectory, { recursive: true }); +const workspaceRoot = await realpath(workspaceDirectory); +const env = writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [workspaceRoot] }, + storage: { stateDir }, +}); +const paths = localAgentDaemonPaths(stateDir); +ensureLocalAgentDaemonSecret(paths); +const requests: Array<{ method: string; params: Record }> = []; +const run: WorkflowRun = { + id: "wfl_test", + workspaceId: "ws_test", + workspaceRoot, + name: "\u001b[31mreview\u001b[0m", + status: "running", + writeMode: "read_only", + concurrency: 2, + createdAt: "now", + updatedAt: "now", + callCount: 0, +}; +const daemon = createServer((socket) => { + let buffer = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const request = JSON.parse(buffer.slice(0, newline)) as { + requestId: string; + method: string; + params: Record; + }; + requests.push(request); + const result = request.method === "hello" + ? { + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: process.pid, + endpoint: paths.endpoint, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: true, + } + : run; + socket.end(encodeLocalAgentDaemonResponse({ + requestId: request.requestId, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + ok: true, + result, + })); + }); +}); + +try { + await new Promise((resolve, reject) => { + daemon.once("error", reject); + daemon.listen(paths.endpoint, resolve); + }); + const workflowFile = join(root, "review.workflow.js"); + await writeFile(workflowFile, "return agent('Review this', { target: 'reviewer' });\n"); + const { stdout } = await execFileAsync("node", [ + "--import", "tsx", "src/cli.ts", "workflow", "run", + "--file", workflowFile, + "--args", '{"base":"main"}', + "--json", + ], { + cwd: process.cwd(), + env: { + ...process.env, + ...env, + DEVSPACE_EXPERIMENTAL_WORKFLOWS: "1", + DEVSPACE_WORKSPACE_ID: "ws_test", + DEVSPACE_WORKSPACE_ROOT: workspaceRoot, + }, + }); + assert.equal(stdout, `${JSON.stringify({ id: run.id, name: run.name, status: run.status })}\n`); + const submitted = requests.find((request) => request.method === "workflow.run"); + assert.deepEqual(submitted?.params, { + workspaceId: "ws_test", + workspaceRoot, + source: "return agent('Review this', { target: 'reviewer' });\n", + args: { base: "main" }, + }); + const status = await execFileAsync("node", [ + "--import", "tsx", "src/cli.ts", "workflow", "status", run.id, + ], { + cwd: process.cwd(), + env: { + ...process.env, + ...env, + DEVSPACE_EXPERIMENTAL_WORKFLOWS: "1", + DEVSPACE_WORKSPACE_ID: "ws_test", + DEVSPACE_WORKSPACE_ROOT: workspaceRoot, + }, + }); + assert.equal(status.stdout.includes("\u001b"), false); + assert.match(status.stdout, /name="\\u001b\[31mreview\\u001b\[0m"/); + + // Hidden by default: without the flag the command does not exist and help does not mention it. + const hiddenEnv = { ...process.env, ...env }; + delete hiddenEnv.DEVSPACE_EXPERIMENTAL_WORKFLOWS; + const hidden = await execFileAsync("node", [ + "--import", "tsx", "src/cli.ts", "workflow", "status", run.id, + ], { cwd: process.cwd(), env: hiddenEnv }).then( + () => assert.fail("workflow command must be rejected without the flag"), + (error: { stderr: string }) => error.stderr, + ); + assert.match(hidden, /Unknown command: workflow/); + const hiddenHelp = await execFileAsync("node", ["--import", "tsx", "src/cli.ts", "help"], { + cwd: process.cwd(), + env: hiddenEnv, + }); + assert.doesNotMatch(hiddenHelp.stdout, /workflow/); + const flaggedHelp = await execFileAsync("node", ["--import", "tsx", "src/cli.ts", "help"], { + cwd: process.cwd(), + env: { ...hiddenEnv, DEVSPACE_EXPERIMENTAL_WORKFLOWS: "1" }, + }); + assert.match(flaggedHelp.stdout, /devspace workflow ((resolve) => daemon.close(() => resolve())); + await rm(root, { recursive: true, force: true }); +} diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts new file mode 100644 index 000000000..469b26486 --- /dev/null +++ b/src/workflow-cli.ts @@ -0,0 +1,276 @@ +import { open } from "node:fs/promises"; +import type { Result } from "better-result"; +import { loadConfig } from "./config.js"; +import { resolveCliWorkspaceContext } from "./cli-workspace.js"; +import { createLocalAgentClient } from "./local-agent-client.js"; +import type { WorkflowCall, WorkflowRun, WorkflowRunInput } from "./workflow-types.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; + +export async function runWorkflowCommand(args: string[], json: boolean): Promise { + try { + const [command, ...rest] = args; + if (!command || command === "help" || command === "--help" || command === "-h") { + printWorkflowHelp(); + return; + } + const config = loadConfig(); + const scope = resolveCliWorkspaceContext( + config.allowedRoots, + process.env, + process.cwd(), + (root, id) => isManagedWorkflowWorkspace(config, root, id), + ); + const client = createLocalAgentClient(config); + switch (command) { + case "run": { + const input = await parseWorkflowRunArgs(rest); + printWorkflow(unwrap(await client.runWorkflow({ ...scope, ...input })), json, true); + return; + } + case "status": { + const id = oneId(rest, "status"); + printWorkflow(unwrap(await client.getWorkflow(id, scope)), json); + return; + } + case "ls": + case "list": { + if (rest.length > 0) usage("ls"); + const runs = unwrap(await client.listWorkflows(scope)); + if (json) printJson(runs); + else for (const run of runs) console.log(formatWorkflow(run)); + return; + } + case "wait": { + const { id, timeoutMs } = parseWorkflowWaitArgs(rest); + printWorkflow(unwrap(await client.waitWorkflow(id, scope, timeoutMs)), json); + return; + } + case "calls": { + const id = oneId(rest, "calls"); + const calls = unwrap(await client.workflowCalls(id, scope)); + if (json) printJson(calls.map(summarizeCall)); + else for (const call of calls) console.log(formatCall(call)); + return; + } + case "call": { + const [id, rawIndex, ...extra] = rest; + if (!id || !rawIndex || extra.length > 0 || !/^\d+$/.test(rawIndex)) usage("call "); + const call = unwrap(await client.workflowCall(id, Number(rawIndex), scope)); + if (json) printJson(call); + else console.log(formatCall(call, true)); + return; + } + case "events": { + const { id, after } = parseWorkflowEventsArgs(rest); + const events = unwrap(await client.workflowEvents(id, scope, after)); + if (json) printJson(events); + else for (const event of events) { + console.log(`${xml(JSON.stringify(event.data) ?? "null")}`); + } + return; + } + case "cancel": { + const id = oneId(rest, "cancel"); + printWorkflow(unwrap(await client.cancelWorkflow(id, scope)), json); + return; + } + default: + throw new Error(`Unknown workflow command: ${command}`); + } + } catch (error) { + const payload = workflowErrorPayload(error); + if (json) printJson({ error: payload }); + else console.error(`${xml(payload.message)}`); + process.exitCode = 1; + } +} + +async function parseWorkflowRunArgs(args: string[]): Promise> { + let file: string | undefined; + let name: string | undefined; + let resume: string | undefined; + let rawArgs: string | undefined; + let argsFile: string | undefined; + let writeMode: WorkflowRunInput["writeMode"]; + for (let index = 0; index < args.length; index += 1) { + const option = args[index]!; + const value = args[index + 1]; + if (!value) usage("run --file |--name |--resume "); + switch (option) { + case "--file": file = value; break; + case "--name": name = value; break; + case "--resume": resume = value; break; + case "--args": rawArgs = value; break; + case "--args-file": argsFile = value; break; + case "--write-mode": + if (value !== "read_only" && value !== "allowed") throw new Error("Workflow write mode must be read_only or allowed."); + writeMode = value; + break; + default: throw new Error(`Unknown option: ${option}.`); + } + index += 1; + } + if ([file, name, resume].filter(Boolean).length !== 1) { + throw new Error("Exactly one of --file, --name, or --resume is required."); + } + if (rawArgs !== undefined && argsFile !== undefined) { + throw new Error("Use only one of --args or --args-file."); + } + const input: Omit = { + ...(file ? { source: await readBoundedFile(file, 64 * 1024, "Workflow source") } : {}), + ...(name ? { name } : {}), + ...(resume ? { resume } : {}), + ...(writeMode ? { writeMode } : {}), + }; + const argsJson = rawArgs ?? (argsFile ? await readBoundedFile(argsFile, 128 * 1024, "Workflow arguments") : undefined); + if (argsJson !== undefined) { + if (Buffer.byteLength(argsJson) > 128 * 1024) throw new Error("Workflow arguments exceed 131072 bytes."); + try { + input.args = JSON.parse(argsJson) as unknown; + } catch (cause) { + throw new Error(`Workflow arguments are not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`); + } + } + return input; +} + +async function readBoundedFile(path: string, maxBytes: number, label: string): Promise { + const file = await open(path, "r"); + try { + const buffer = Buffer.allocUnsafe(maxBytes + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes.`); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); + } catch { + throw new Error(`${label} must be UTF-8 text.`); + } + } finally { + await file.close(); + } +} + +function parseWorkflowWaitArgs(args: string[]): { id: string; timeoutMs: number } { + let id: string | undefined; + let timeoutMs = 60_000; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]!; + if (value === "--timeout") { + const seconds = args[index + 1]; + if (!seconds || !/^\d+$/.test(seconds) || Number(seconds) > 60) { + throw new Error("Workflow wait timeout must be an integer from 0 to 60 seconds."); + } + timeoutMs = Number(seconds) * 1_000; + index += 1; + } else if (value.startsWith("-")) { + throw new Error(`Unknown option: ${value}.`); + } else if (id) { + usage("wait [--timeout ]"); + } else { + id = value; + } + } + if (!id) usage("wait [--timeout ]"); + return { id, timeoutMs }; +} + +function parseWorkflowEventsArgs(args: string[]): { id: string; after: number } { + let id: string | undefined; + let after = 0; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]!; + if (value === "--after") { + const sequence = args[index + 1]; + if (!sequence || !/^\d+$/.test(sequence) || !Number.isSafeInteger(Number(sequence))) { + throw new Error("Workflow event sequence must be a non-negative integer."); + } + after = Number(sequence); + index += 1; + } else if (value.startsWith("-")) { + throw new Error(`Unknown option: ${value}.`); + } else if (id) { + usage("events [--after ]"); + } else { + id = value; + } + } + if (!id) usage("events [--after ]"); + return { id, after }; +} + +function oneId(args: string[], command: string): string { + if (args.length !== 1 || !args[0]) usage(`${command} `); + return args[0]; +} + +function unwrap(result: Result): T { + if (result.isErr()) throw result.error; + return result.value; +} + +function printWorkflow(run: WorkflowRun, json: boolean, receipt = false): void { + if (json) printJson(receipt ? { id: run.id, name: run.name, status: run.status } : run); + else console.log(formatWorkflow(run, receipt)); +} + +function formatWorkflow(run: WorkflowRun, receipt = false): string { + const attributes = [ + `id="${xml(run.id)}"`, + `name="${xml(run.name)}"`, + `status="${run.status}"`, + ...(run.error ? [`code="${xml(run.error.code)}"`, `retryable="${run.error.retryable}"`] : []), + ].join(" "); + if (receipt) return ``; + const detail = run.error?.message ?? (run.result === undefined ? "" : JSON.stringify(run.result)); + return detail + ? `${xml(detail)}` + : ``; +} + +function summarizeCall({ prompt: _prompt, result: _result, fingerprint: _fingerprint, ...call }: WorkflowCall): Omit { + return call; +} + +function formatCall(call: WorkflowCall, detailed = false): string { + const attributes = `run="${xml(call.runId)}" index="${call.index}" agent="${xml(call.agentId)}" status="${call.status}" target="${xml(call.options.target)}"`; + if (!detailed) return ``; + return `${xml(JSON.stringify({ prompt: call.prompt, options: call.options, result: call.result, error: call.error }))}`; +} + +function workflowErrorPayload(error: unknown): { code: string; message: string; retryable: boolean } { + const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { + code: typeof value?.code === "string" ? value.code : "WORKFLOW_COMMAND_ERROR", + message: typeof value?.message === "string" ? value.message : String(error), + retryable: value?.retryable === true, + }; +} + +function xml(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`) + .replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">"); +} + +function printJson(value: unknown): void { + console.log(JSON.stringify(value)); +} + +function usage(command: string): never { + throw new Error(`Usage: devspace workflow ${command}`); +} + +export function printWorkflowHelp(): void { + console.log([ + "DevSpace workflow", + "", + "Usage:", + " devspace workflow run --file |--name |--resume [--args |--args-file ] [--write-mode read_only|allowed] [--json]", + " devspace workflow status [--json]", + " devspace workflow wait [--timeout ] [--json]", + " devspace workflow calls [--json]", + " devspace workflow call [--json]", + " devspace workflow events [--after ] [--json]", + " devspace workflow cancel [--json]", + " devspace workflow ls [--json]", + ].join("\n")); +} diff --git a/src/workflow-context.ts b/src/workflow-context.ts new file mode 100644 index 000000000..4c7f2a324 --- /dev/null +++ b/src/workflow-context.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { lstat, open, readdir } from "node:fs/promises"; +import { join } from "node:path"; +const exec = promisify(execFile); + +export function workflowHash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +const GIT_MAX_BUFFER = 64 * 1024 * 1024; + +/** + * No guessed cache hits: large/linked/unreadable contexts disable replay. + * Only files Git tracks or would add (not ignored) are hashed, so build output such as + * `node_modules` neither exhausts the bounds nor invalidates a resume. + */ +export async function workflowContextHash(root: string): Promise { + const hash = createHash("sha256"); + let count = 0; + let bytes = 0; + try { + const head = await exec("git", ["rev-parse", "HEAD"], { cwd: root, timeout: 5_000 }); + hash.update(JSON.stringify({ head: head.stdout })); + const staged = await exec("git", ["diff", "--cached", "--binary", "--no-ext-diff", "--no-textconv"], { cwd: root, timeout: 5_000, maxBuffer: GIT_MAX_BUFFER }); + hash.update(JSON.stringify({ staged: staged.stdout })); + async function visit(path: string, relative: string): Promise { + if (++count > 20_000) throw new Error("context too large"); + const stat = await lstat(path); + hash.update(JSON.stringify({ path: relative, mode: stat.mode, size: stat.isFile() ? stat.size : 0, directory: stat.isDirectory() })); + if (stat.isSymbolicLink()) { + // A symlink can expose changing data outside the observed tree. + throw new Error("linked context"); + } + if (stat.isDirectory()) { + for (const entry of (await readdir(path)).sort()) { + if (entry === ".git") continue; + await visit(join(path, entry), `${relative}/${entry}`); + } + } else if (stat.isFile()) { + bytes += stat.size; + if (bytes > 64 * 1024 * 1024) throw new Error("context too large"); + const file = await open(path, "r"); + try { + const buffer = Buffer.alloc(stat.size + 1); + let length = 0; + while (length < buffer.length) { + const chunk = await file.read(buffer, length, buffer.length - length, length); + if (!chunk.bytesRead) break; + length += chunk.bytesRead; + } + const after = await file.stat(); + if (length !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || after.ino !== stat.ino) throw new Error("changing context"); + hash.update(buffer.subarray(0, length)); + } finally { await file.close(); } + } else throw new Error("special file context"); + } + // `-z` output is NUL separated with forward slashes on every platform (no CRLF or quoting). + const listed = await exec("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, timeout: 5_000, maxBuffer: GIT_MAX_BUFFER }); + const paths = [...new Set(listed.stdout.split("\0").filter(Boolean))].sort(); + for (const relative of paths) { + const path = join(root, relative); + // Tracked but deleted from the worktree: the deletion is part of the context. + const present = await lstat(path).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }); + if (present) await visit(path, relative); + else hash.update(JSON.stringify({ path: relative, deleted: true })); + } + return hash.digest("hex"); + } catch { return undefined; } +} diff --git a/src/workflow-flag.test.ts b/src/workflow-flag.test.ts new file mode 100644 index 000000000..d0fe317a4 --- /dev/null +++ b/src/workflow-flag.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { workflowsEnabled } from "./workflow-flag.js"; + +test("workflows are enabled only by DEVSPACE_EXPERIMENTAL_WORKFLOWS=1", () => { + assert.equal(workflowsEnabled({}), false); + assert.equal(workflowsEnabled({ DEVSPACE_EXPERIMENTAL_WORKFLOWS: "0" }), false); + assert.equal(workflowsEnabled({ DEVSPACE_EXPERIMENTAL_WORKFLOWS: "true" }), false); + assert.equal(workflowsEnabled({ DEVSPACE_EXPERIMENTAL_WORKFLOWS: "1" }), true); +}); diff --git a/src/workflow-flag.ts b/src/workflow-flag.ts new file mode 100644 index 000000000..7080c6542 --- /dev/null +++ b/src/workflow-flag.ts @@ -0,0 +1,9 @@ +/** + * Workflows ship hidden in v1.1. This environment variable is the only switch: + * there is intentionally no config key and no MCP tool surface until they graduate. + */ +export const WORKFLOWS_ENV = "DEVSPACE_EXPERIMENTAL_WORKFLOWS"; + +export function workflowsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[WORKFLOWS_ENV] === "1"; +} diff --git a/src/workflow-manager.test.ts b/src/workflow-manager.test.ts new file mode 100644 index 000000000..59018931d --- /dev/null +++ b/src/workflow-manager.test.ts @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { Result } from "better-result"; +import { AgentConflictError, AgentProviderCancelledError } from "./local-agent-errors.js"; +import { LocalAgentManager } from "./local-agent-manager.js"; +import { LocalAgentStore, type LocalAgentTurnRecord } from "./local-agent-store.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +import type { LocalAgentRunInput, LocalAgentRuntime } from "./local-agent-runtime.js"; +import { WorkflowManager } from "./workflow-manager.js"; +import { WorkflowStore } from "./workflow-store.js"; +import type { runWorkflowScript } from "./workflow-runner.js"; +import { WorkflowError } from "./workflow-types.js"; +import type { ServerConfig } from "./config.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; + +async function fixture(runner?: typeof runWorkflowScript) { + const dir = await mkdtemp(join(tmpdir(), "devspace-workflow-")); + await mkdir(join(dir, "project")); + const root = await realpath(join(dir, "project")); + execFileSync("git", ["init", "-q"], { cwd: root }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "Initial"], { cwd: root }); + const stateDir = join(dir, "state"); + const config = { stateDir, allowedRoots: [root], worktreeRoot: join(dir, "worktrees") } as ServerConfig; + const inputs: LocalAgentRunInput[] = []; + let active = 0; + let maximum = 0; + let releaseHold: (() => void) | undefined; + const runtime: LocalAgentRuntime = { + provider: "codex", isAlive: () => true, close: async () => {}, releaseSession: async () => {}, + run: async (input, callbacks) => { + inputs.push(input); + active++; + maximum = Math.max(maximum, active); + try { + await callbacks?.onSessionId?.(`session_${inputs.length}`); + if (input.prompt === "commit") { + await writeFile(join(input.workspaceRoot, "committed.txt"), "preserve this commit"); + execFileSync("git", ["add", "committed.txt"], { cwd: input.workspaceRoot }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "Worker change"], { cwd: input.workspaceRoot }); + await writeFile(join(input.workspaceRoot, "untracked.txt"), "preserve this too"); + } + if (input.prompt === "hold") { + await new Promise((resolve) => { + releaseHold = resolve; + input.signal?.addEventListener("abort", () => resolve(), { once: true }); + if (input.signal?.aborted) resolve(); + }); + if (input.signal?.aborted) return Result.err(new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", provider: "codex", operation: "run", retryable: false, message: "Test provider confirmed cancellation.", + })); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + return Result.ok({ provider: "codex" as const, providerSessionId: null, finalResponse: input.prompt.startsWith("Your last response") ? '{"ok":true}' : input.prompt, items: [] }); + } finally { active--; } + }, + }; + const agents = new LocalAgentManager({ + store: new LocalAgentStore(stateDir), pool: new LocalAgentRuntimePool(), loadProfiles: async () => [], + subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", driver: "codex", enabled: true }] }, + drivers: [{ + providerInstanceId: "codex", provider: "codex", runtimePolicy: { scope: "instance" }, + capabilities: { + sessions: { resume: true, close: false }, turns: { interrupt: true, streaming: false }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, mcp: { supported: false }, + }, + createRuntime: async () => Result.ok(runtime), + }], + allowedRoots: [root], + authorizeManagedWorkspace: (path, id) => isManagedWorkflowWorkspace(config, path, id), + }); + const store = new WorkflowStore(stateDir); + const workflows = new WorkflowManager({ + store, agents, runner, config, loadProfiles: async () => [], allowedRoots: [root], + subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", driver: "codex", enabled: true }] }, + }); + const scope = { workspaceRoot: root }; + return { + dir, root, inputs, agents, store, workflows, scope, maximum: () => maximum, + release: () => releaseHold?.(), + close: async () => { releaseHold?.(); await workflows.close(); await agents.close(); await rm(dir, { recursive: true, force: true }); }, + }; +} + +test("real JS pipeline runs in parallel, preserves order and durable turn ownership", async () => { + const f = await fixture(); + try { + const run = await f.workflows.run({ ...f.scope, source: `export const meta = {name:'test',concurrency:2}; + return await pipeline(['a','b','c'], value => agent(value,{target:'codex'}), (value,item) => agent(value+item,{target:'codex'}));` }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "completed", JSON.stringify(done.error)); + assert.deepEqual(done.result, ["aa", "bb", "cc"].map((value) => ({ status: "completed", value }))); + assert.equal(f.maximum(), 2); + assert.equal(f.workflows.calls(run.id, f.scope).length, 6); + assert.ok(f.workflows.calls(run.id, f.scope).every((call) => call.turnId && call.agentId && call.status === "completed")); + // Workflow turns are ordinary agent turns: the call's turnId addresses their event log. + const first = f.workflows.calls(run.id, f.scope)[0]!; + const events = f.agents.events(first.agentId, f.scope, { turnId: first.turnId }); + assert.ok(events.isOk()); + assert.equal(events.value.turnId, first.turnId); + assert.equal(events.value.events[0]?.type, "turn_started"); + assert.equal(events.value.events.at(-1)?.type, "turn_completed"); + } finally { await f.close(); } +}); + +test("workspaces outside the allowed roots are rejected with a workflow error", async () => { + const f = await fixture(); + try { + const outside = await mkdtemp(join(tmpdir(), "devspace-workflow-outside-")); + try { + await assert.rejects( + f.workflows.run({ workspaceRoot: outside, source: "return 1;" }), + (error) => error instanceof WorkflowError && error.code === "WORKSPACE_NOT_ALLOWED" && /outside|allowed/i.test(error.message), + ); + await assert.rejects( + f.workflows.run({ workspaceRoot: join(outside, "missing"), source: "return 1;" }), + (error) => error instanceof WorkflowError && error.code === "WORKSPACE_NOT_ALLOWED", + ); + } finally { await rm(outside, { recursive: true, force: true }); } + } finally { await f.close(); } +}); + +test("shared daemon capacity bounds simultaneous workflows", async () => { + const f = await fixture(async ({ onAgent }) => { + await Promise.all(Array.from({ length: 16 }, (_, i) => onAgent(String(i), { target: "codex" }))); + return "done"; + }); + try { + const source = "export const meta={concurrency:16}; return null;"; + const runs = await Promise.all([f.workflows.run({ ...f.scope, source }), f.workflows.run({ ...f.scope, source })]); + await Promise.all(runs.map((r) => f.workflows.wait(r.id, f.scope))); + assert.equal(f.maximum(), 8); + } finally { await f.close(); } +}); + +test("cancelling one workflow stops its exact owned turn and preserves another shared-runtime agent", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("hold", { target: "codex" }) as string); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + await until(() => f.inputs.length === 1); + const other = await f.agents.start({ ...f.scope, target: "codex", prompt: "other" }); + assert.ok(other.isOk()); + await f.workflows.cancel(run.id, f.scope); + assert.equal((await f.workflows.wait(run.id, f.scope)).status, "cancelled"); + const call = f.workflows.call(run.id, 0, f.scope); + assert.equal(call.status, "cancelled"); + assert.equal(unwrap(f.agents.getTurn(call.agentId, call))?.status, "stopped"); + assert.equal(unwrap(await f.agents.wait([other.value.id], f.scope))[0]?.status, "completed"); + } finally { await f.close(); } +}); + +test("normal return with unawaited calls cancels children before persisting failure", async () => { + const f = await fixture(async ({ onAgent }) => { + void Promise.resolve(onAgent("hold", { target: "codex" })).catch(() => {}); + await until(() => f.inputs.length === 1); + return "early"; + }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "failed"); + assert.equal(done.error?.code, "UNAWAITED_CALLS"); + assert.equal(f.agents.activeTurnCount, 0); + } finally { await f.close(); } +}); + +test("schema validates before dispatch and one correction runs with reduced authority", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("work", { + target: "codex", schema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false }, + }) as { ok: boolean }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;", writeMode: "allowed" }); + assert.deepEqual((await f.workflows.wait(run.id, f.scope)).result, { ok: true }); + assert.deepEqual(f.inputs.map((i) => i.writeMode), ["allowed", "read_only"]); + assert.equal(f.workflows.calls(run.id, f.scope).length, 1); + } finally { await f.close(); } + const invalid = await fixture(async ({ onAgent }) => await onAgent("never", { target: "codex", schema: { type: "nonsense" } }) as null); + try { + const run = await invalid.workflows.run({ ...invalid.scope, source: "return null;" }); + assert.equal((await invalid.workflows.wait(run.id, invalid.scope)).error?.code, "INVALID_SCHEMA"); + assert.equal(invalid.inputs.length, 0); + } finally { await invalid.close(); } +}); + +test("resume reuses only compatible read-only prefix and rejects changed context and active runs", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("review", { target: "codex" }) as string); + try { + const first = await f.workflows.run({ ...f.scope, source: "return null;" }); + await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "WORKFLOW_ACTIVE"); + await f.workflows.wait(first.id, f.scope); + const resumed = await f.workflows.run({ ...f.scope, resume: first.id }); + await f.workflows.wait(resumed.id, f.scope); + assert.equal(f.inputs.length, 1); + assert.equal(f.workflows.call(resumed.id, 0, f.scope).reusedFrom, first.id); + await writeFile(join(f.root, "changed"), "changed"); + await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "RECOVERY_CONTEXT_CHANGED"); + assert.throws(() => f.workflows.get(first.id, { workspaceRoot: f.dir }), /not in this workspace/); + } finally { await f.close(); } +}); + +test("resume hashes only files Git does not ignore", async () => { + const f = await fixture(async () => null); + try { + await writeFile(join(f.root, ".gitignore"), "node_modules/\n"); + await writeFile(join(f.root, "tracked.txt"), "tracked"); + execFileSync("git", ["add", ".gitignore", "tracked.txt"], { cwd: f.root }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "ignore"], { cwd: f.root }); + await mkdir(join(f.root, "node_modules", "dep"), { recursive: true }); + await writeFile(join(f.root, "node_modules", "dep", "index.js"), "one"); + const first = await f.workflows.run({ ...f.scope, source: "return null;" }); + await f.workflows.wait(first.id, f.scope); + // Ignored changes (even a symlink, which would otherwise disable hashing) leave resume available. + await writeFile(join(f.root, "node_modules", "dep", "index.js"), "two"); + await symlink(join(f.root, "node_modules", "dep"), join(f.root, "node_modules", "link"), "junction"); + const resumed = await f.workflows.run({ ...f.scope, resume: first.id }); + await f.workflows.wait(resumed.id, f.scope); + // Deleting a tracked file or adding an unignored one still changes the context. + await writeFile(join(f.root, "new.txt"), "x"); + await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "RECOVERY_CONTEXT_CHANGED"); + await rm(join(f.root, "new.txt")); + await rm(join(f.root, "tracked.txt")); + await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "RECOVERY_CONTEXT_CHANGED"); + } finally { await f.close(); } +}); + +test("resume rejects staged Git state changes even when worktree files are unchanged", async () => { + const f = await fixture(async () => null); + try { + await writeFile(join(f.root, "staged.txt"), "same content"); + const first = await f.workflows.run({ ...f.scope, source: "return null;" }); + await f.workflows.wait(first.id, f.scope); + execFileSync("git", ["add", "staged.txt"], { cwd: f.root }); + await assert.rejects( + f.workflows.run({ ...f.scope, resume: first.id }), + (error: unknown) => error instanceof WorkflowError && error.code === "RECOVERY_CONTEXT_CHANGED", + ); + } finally { await f.close(); } +}); + +test("nested workflow shares capacity at concurrency one and cannot recursively nest", async () => { + const f = await fixture(); + try { + await mkdir(join(f.root, ".devspace", "workflows"), { recursive: true }); + await writeFile(join(f.root, ".devspace", "workflows", "child.js"), "return await agent('nested',{target:'codex'});"); + const run = await f.workflows.run({ ...f.scope, source: "export const meta={concurrency:1}; return await workflow('child',{});" }); + assert.equal((await f.workflows.wait(run.id, f.scope)).result, "nested"); + await writeFile(join(f.root, ".devspace", "workflows", "child.js"), "try { return await workflow('child',{}); } catch { return 'ignored'; }"); + const recursive = await f.workflows.run({ ...f.scope, source: "return await workflow('child',{});" }); + assert.equal((await f.workflows.wait(recursive.id, f.scope)).status, "failed"); + } finally { await f.close(); } +}); + +test("worktree calls share their logical workspace and preserve commits plus dirty files", async () => { + const f = await fixture(async ({ onAgent }) => { + await onAgent("commit", { target: "codex", isolation: "worktree", workspace: "implementation" }); + return await onAgent("verify", { target: "codex", workspace: "implementation", writeMode: "read_only" }) as string; + }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;", writeMode: "allowed" }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "completed", JSON.stringify(done.error)); + const [implementation, verify] = f.workflows.calls(run.id, f.scope); + assert.equal(implementation!.workspaceRoot, verify!.workspaceRoot); + assert.notEqual(implementation!.workspaceRoot, f.root); + assert.equal(await readFile(join(implementation!.workspaceRoot, "untracked.txt"), "utf8"), "preserve this too"); + assert.equal(execFileSync("git", ["show", "HEAD:committed.txt"], { cwd: implementation!.workspaceRoot, encoding: "utf8" }), "preserve this commit"); + await assert.rejects(readFile(join(f.root, "committed.txt"))); + await assert.rejects(f.workflows.run({ ...f.scope, resume: run.id }), (error: unknown) => error instanceof WorkflowError && error.code === "RECOVERY_REQUIRED"); + } finally { await f.close(); } +}); + +test("restart reconciliation preserves durable ownership without dispatching interrupted calls", async () => { + const f = await fixture(async () => "done"); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + await f.workflows.wait(run.id, f.scope); + f.store.addCall({ runId: run.id, index: 0, agentId: "agt_interrupted", status: "queued", prompt: "pending", + options: { target: "codex", writeMode: "read_only" }, fingerprint: "old", ...f.scope, createdAt: "now", updatedAt: "now" }); + const child = unwrap(await f.agents.start({ ...f.scope, agentId: "agt_interrupted", target: "codex", prompt: "done", writeMode: "read_only" })); + await f.agents.wait([child.id], f.scope); + f.store.update(run.id, { status: "running" }); + f.workflows.reconcile(); + assert.equal(f.workflows.get(run.id, f.scope).status, "interrupted"); + const recovered = f.workflows.call(run.id, 0, f.scope); + assert.equal(recovered.agentId, "agt_interrupted"); + assert.ok(recovered.turnId); + assert.equal(recovered.status, "interrupted"); + assert.equal(f.inputs.length, 1); + } finally { await f.close(); } +}); + +test("parallel unnamed isolated calls receive distinct worktrees", async () => { + const f = await fixture(async ({ onAgent }) => { + await Promise.all([0, 1].map((i) => onAgent(String(i), { target: "codex", isolation: "worktree" }))); + return "done"; + }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "completed", JSON.stringify(done.error)); + const calls = f.workflows.calls(run.id, f.scope); + assert.equal(new Set(calls.map((call) => call.workspaceRoot)).size, 2); + } finally { await f.close(); } +}); + +test("shutdown stops retrying unconfirmed provider cancellation and preserves recovery state", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("hold", { target: "codex" }) as string); + const originalGetTurn = f.agents.getTurn.bind(f.agents); + const originalStop = f.agents.stop.bind(f.agents); + let close: Promise | undefined; + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + await until(() => f.inputs.length === 1); + await until(() => f.workflows.calls(run.id, f.scope)[0]?.turnId !== undefined); + const call = f.workflows.call(run.id, 0, f.scope); + const runningTurn: LocalAgentTurnRecord = { + id: 1, turnId: call.turnId!, agentId: call.agentId, prompt: call.prompt, status: "running", createdAt: "now", + }; + f.agents.getTurn = () => Result.ok(runningTurn); + f.agents.stop = async () => Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", agentId: call.agentId, operation: "stop", retryable: true, + message: "Provider cancellation is unavailable.", + })); + close = f.workflows.close(); + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + close, + new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("workflow close timed out")), 2_000); }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + const reopened = new WorkflowStore(join(f.dir, "state")); + try { + assert.equal(reopened.get(run.id)?.status, "stopping"); + assert.equal(reopened.get(run.id)?.error?.code, "RECOVERY_REQUIRED"); + } finally { reopened.close(); } + } finally { + f.agents.getTurn = originalGetTurn; + f.agents.stop = originalStop; + f.release(); + await close?.catch(() => {}); + await f.agents.close(); + await rm(f.dir, { recursive: true, force: true }); + } +}); + +async function until(check: () => boolean): Promise { + const end = Date.now() + 5_000; + while (!check()) { + if (Date.now() > end) throw new Error("Test condition timed out"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} +function unwrap(result: import("better-result").Result): T { + if (result.isErr()) throw result.error; + return result.value; +} diff --git a/src/workflow-manager.ts b/src/workflow-manager.ts new file mode 100644 index 000000000..3efe55dbf --- /dev/null +++ b/src/workflow-manager.ts @@ -0,0 +1,489 @@ +import { randomUUID } from "node:crypto"; +import { open, realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { ServerConfig } from "./config.js"; +import type { SubagentsConfig } from "./local-agent-config.js"; +import type { LocalAgentManager } from "./local-agent-manager.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { resolveLocalAgentTarget } from "./local-agent-targets.js"; +import { resolveCanonicalAllowedPath } from "./roots.js"; +import { workflowContextHash, workflowHash } from "./workflow-context.js"; +import { runWorkflowScript } from "./workflow-runner.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { compileWorkflowSchema, parseWorkflowOutput } from "./workflow-schema.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import { + WorkflowError, workflowFailure, workflowTerminal, + type WorkflowAgentOptions, type WorkflowCall, type WorkflowEvent, type WorkflowRun, type WorkflowRunInput, type WorkflowSnapshot, +} from "./workflow-types.js"; +import { createWorkflowWorkspace, isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; + +interface WorkflowManagerOptions { + store: WorkflowStore; + agents: LocalAgentManager; + loadProfiles: (workspaceRoot: string) => Promise; + subagents: SubagentsConfig; + allowedRoots?: readonly string[]; + config?: ServerConfig; + /** Tests may exercise the supervisor without a provider or child process. */ + runner?: typeof runWorkflowScript; +} +interface ActiveRun { + run: WorkflowSnapshot; + controller: AbortController; + done: Promise; + pending: Set>; + preparing: Promise; + calls: number; + dispatches: number; + nested: number; + eventBytes: number; + slots: Slots; + workspaces: Map>; + previous: WorkflowCall[]; + replayPrefix: boolean; + unconfirmedStops: boolean; +} + +/** Owns execution; host-authored JavaScript owns the workflow decisions. */ +export class WorkflowManager { + private readonly active = new Map(); + private readonly slots = new Slots(8); + private scripts = 0; + private accepting = true; + constructor(private readonly options: WorkflowManagerOptions) {} + get activeRunCount(): number { return this.active.size; } + + async run(input: WorkflowRunInput): Promise { + if (!this.accepting) throw new WorkflowError("WORKFLOW_STOPPING", "Workflow service is stopping."); + if (this.active.size >= 8) throw new WorkflowError("WORKFLOW_CAPACITY", "Eight workflows are already active. Wait for one to finish.", true); + const scope = await this.authorize(input); + if ([input.source !== undefined, input.name !== undefined, input.resume !== undefined].filter(Boolean).length !== 1) { + throw new WorkflowError("INVALID_WORKFLOW", "Supply exactly one source, name, or resume ID."); + } + const previous = input.resume ? this.snapshot(input.resume, scope) : undefined; + if (previous && (!workflowTerminal(previous.status) || this.active.has(previous.id))) { + throw new WorkflowError("WORKFLOW_ACTIVE", "Cancel and wait for the previous run before resuming."); + } + const previousCalls = previous ? this.options.store.calls(previous.id) : []; + for (const call of previousCalls) { + const record = this.options.agents.get(call.agentId, call); + if (record.isErr() && record.error.code !== "AGENT_NOT_FOUND") { + throw new WorkflowError("RECOVERY_REQUIRED", `Cannot establish ownership of prior agent ${call.agentId}: ${record.error.message}`); + } + if (record.isOk() && (record.value.status === "running" || record.value.status === "starting")) { + throw new WorkflowError("WORKFLOW_ACTIVE", `Child agent ${call.agentId} is still active.`); + } + if (call.options.writeMode !== "read_only" || call.options.isolation || call.options.workspace) { + throw new WorkflowError("RECOVERY_REQUIRED", "This run may have changed a workspace. Inspect its calls and preserved worktrees, then start a new explicit workflow; editing calls are never replayed."); + } + } + if (previous && (input.args !== undefined || input.writeMode !== undefined)) { + throw new WorkflowError("INVALID_WORKFLOW", "Resume uses the original arguments and authority. Start a new run to change them."); + } + const source = previous?.source ?? input.source ?? await this.namedSource(input.name!, scope.workspaceRoot); + if (typeof source !== "string" || Buffer.byteLength(source) > 65_536) throw new WorkflowError("SOURCE_LIMIT", "Workflow source exceeds 64 KiB."); + let meta: ReturnType["meta"]; + try { meta = parseWorkflowScript(source).meta; } + catch (error) { throw new WorkflowError("INVALID_WORKFLOW", workflowFailure(error).message); } + const args = jsonValue(previous ? previous.args : input.args ?? {}, 131_072, "arguments"); + const contextHash = await workflowContextHash(scope.workspaceRoot); + if (previous && (!contextHash || contextHash !== previous.contextHash)) { + throw new WorkflowError("RECOVERY_CONTEXT_CHANGED", "Workspace context changed or cannot be fully fingerprinted. Start a new workflow after reviewing the previous run."); + } + const writeMode = previous?.writeMode ?? input.writeMode ?? "read_only"; + if (writeMode !== "read_only" && writeMode !== "allowed") throw new WorkflowError("INVALID_WORKFLOW", "Invalid workflow write mode."); + const now = new Date().toISOString(); + const run: WorkflowSnapshot = { + id: `wf_${randomUUID()}`, ...scope, name: meta.name, source, args, contextHash: contextHash ?? "unavailable", + writeMode, concurrency: meta.concurrency, status: "starting", resumeOf: previous?.id, + createdAt: now, updatedAt: now, callCount: 0, + }; + if (this.active.size >= 8) throw new WorkflowError("WORKFLOW_CAPACITY", "Eight workflows are already active. Wait for one to finish.", true); + this.options.store.create(run); + const active: ActiveRun = { + run, controller: new AbortController(), pending: new Set(), preparing: Promise.resolve(), + calls: 0, dispatches: 0, nested: 0, eventBytes: 0, slots: new Slots(run.concurrency), workspaces: new Map(), previous: previousCalls, replayPrefix: true, + unconfirmedStops: false, + done: Promise.resolve(), + }; + this.active.set(run.id, active); + active.done = this.execute(active); + // Completion failures remain visible in persisted state; never create an unhandled rejection. + void active.done.catch(() => {}); + return summary(run); + } + + get(id: string, scope: LocalAgentWorkspaceScope): WorkflowRun { return summary(this.snapshot(id, scope)); } + list(scope: LocalAgentWorkspaceScope): WorkflowRun[] { + return this.options.store.list({ ...scope, workspaceRoot: canonicalRoot(scope.workspaceRoot) }).map(summary); + } + calls(id: string, scope: LocalAgentWorkspaceScope): WorkflowCall[] { + this.snapshot(id, scope); + return this.options.store.calls(id); + } + call(id: string, index: number, scope: LocalAgentWorkspaceScope): WorkflowCall { + this.snapshot(id, scope); + const call = this.options.store.call(id, index); + if (!call) throw new WorkflowError("WORKFLOW_CALL_NOT_FOUND", `Unknown workflow call: ${index}`); + return call; + } + events(id: string, scope: LocalAgentWorkspaceScope, after = 0): WorkflowEvent[] { + this.snapshot(id, scope); + if (!Number.isSafeInteger(after) || after < 0) throw new WorkflowError("INVALID_WORKFLOW", "Event cursor must be a nonnegative sequence number."); + return this.options.store.events(id, after); + } + async wait(id: string, scope: LocalAgentWorkspaceScope, timeoutMs = 60_000, signal?: AbortSignal): Promise { + const run = this.get(id, scope); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 60_000) throw new WorkflowError("INVALID_WORKFLOW", "Wait timeout must be 0–60000 milliseconds."); + const active = this.active.get(id); + if (!active || workflowTerminal(run.status) || signal?.aborted) return run; + await new Promise((done) => { + const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); done(); }; + const timer = setTimeout(finish, timeoutMs); + signal?.addEventListener("abort", finish, { once: true }); + void active.done.then(finish, finish); + }); + return this.get(id, scope); + } + async cancel(id: string, scope: LocalAgentWorkspaceScope): Promise { + const run = this.get(id, scope); + const active = this.active.get(id); + if (active) { + this.options.store.update(id, { status: "stopping" }); + active.controller.abort(new WorkflowError("WORKFLOW_CANCELLED", "Workflow cancellation requested.")); + } + return active ? this.get(id, scope) : run; + } + reconcile(): void { + for (const run of this.options.store.list()) { + if (workflowTerminal(run.status)) continue; + for (const call of this.options.store.calls(run.id)) { + if (call.status !== "queued" && call.status !== "running") continue; + // Recover a crash between durable agent dispatch and saving the turn link. + const turn = this.options.agents.getTurn(call.agentId, call); + this.options.store.updateCall(run.id, call.index, { + status: "interrupted", turnId: turn.isOk() ? turn.value?.turnId : call.turnId, + error: { code: "WORKFLOW_INTERRUPTED", message: "Daemon stopped before this call was finalized. Inspect the agent before retrying.", retryable: false }, + }); + } + this.options.store.update(run.id, { + status: "interrupted", error: { code: "WORKFLOW_INTERRUPTED", message: "Daemon stopped. Explicit resume is required; no calls were automatically dispatched.", retryable: false }, + }); + } + } + async close(): Promise { + this.accepting = false; + const active = [...this.active.values()]; + for (const run of active) run.controller.abort(new WorkflowError("WORKFLOW_CANCELLED", "Daemon is stopping.")); + await Promise.allSettled(active.map((run) => run.done)); + this.options.store.close(); + } + + private async execute(active: ActiveRun): Promise { + const { run, controller } = active; + let result: unknown; + let failure: unknown; + try { + this.options.store.update(run.id, { status: "running" }); + result = await this.script(active, run.source, run.args, 0); + if (active.pending.size) throw new WorkflowError("UNAWAITED_CALLS", "Workflow returned while agent calls were still pending. Await all calls before returning."); + if (controller.signal.aborted) throw controller.signal.reason; + } catch (error) { failure = error; } + finally { + // One finalization path: stop admission first, then confirm every owned turn has settled. + controller.abort(failure ?? new WorkflowError("WORKFLOW_FINISHED", "Workflow finished.")); + try { + if (active.pending.size) { + try { this.options.store.update(run.id, { status: "stopping" }); } catch { /* Still stop children if persistence is unavailable. */ } + } + await Promise.allSettled([...active.pending]); + if (active.unconfirmedStops) return; + const error = failure ? workflowFailure(failure) : undefined; + this.options.store.update(run.id, { + status: error ? (error.code === "WORKFLOW_CANCELLED" ? "cancelled" : "failed") : "completed", + result: error ? undefined : result, error, + }); + } finally { this.active.delete(run.id); } + } + } + private async script(active: ActiveRun, source: string, args: unknown, depth: number): Promise { + if (this.scripts >= 16) throw this.fatal(active, new WorkflowError("WORKFLOW_RUNNER_LIMIT", "Sixteen script runners are already active.")); + this.scripts++; + const runner = this.options.runner ?? runWorkflowScript; + try { return await runner({ + source, args, signal: active.controller.signal, depth, + onAgent: (prompt, options) => { + const task = this.agent(active, prompt, options as WorkflowAgentOptions, workflowHash(source)); + active.pending.add(task); + void task.then(() => active.pending.delete(task), () => active.pending.delete(task)); + return task; + }, + onWorkflow: (name, nestedArgs) => { + if (depth >= 1) throw this.fatal(active, new WorkflowError("WORKFLOW_DEPTH_LIMIT", "Nested workflows are limited to one level.")); + if (++active.nested > 8) throw this.fatal(active, new WorkflowError("WORKFLOW_NESTED_LIMIT", "A run can invoke at most eight nested workflows.")); + const task = (async () => { + const nestedSource = await this.namedSource(name, active.run.workspaceRoot); + this.options.store.event(active.run.id, "nested_workflow", { name, source: nestedSource }); + return this.script(active, nestedSource, nestedArgs, depth + 1); + })(); + active.pending.add(task); + void task.then(() => active.pending.delete(task), (error) => { + active.pending.delete(task); + if (workflowFailure(error).code.includes("LIMIT")) this.fatal(active, error); + }); + return task; + }, + onEvent: (type, data) => { + active.eventBytes += Buffer.byteLength(JSON.stringify(data)); + if (active.eventBytes > 65_536) throw this.fatal(active, new WorkflowError("WORKFLOW_LOG_LIMIT", "Combined workflow events exceed 64 KiB.")); + this.options.store.event(active.run.id, type, data); + }, + }); } finally { this.scripts--; } + } + private async agent(active: ActiveRun, prompt: string, value: WorkflowAgentOptions, sourceHash: string): Promise { + const signal = active.controller.signal; + signal.throwIfAborted(); + const index = active.calls++; + if (index >= 128) throw this.fatal(active, new WorkflowError("WORKFLOW_CALL_LIMIT", "Workflow exceeds 128 agent calls.")); + // Serialize preparation/replay decisions, never the agent execution itself. + const prepared = active.preparing.then(async () => { + signal.throwIfAborted(); + const options = agentOptions(value, active.run.writeMode); + if (typeof prompt !== "string" || !prompt.trim() || Buffer.byteLength(prompt) > 65_536) throw new WorkflowError("INVALID_AGENT_CALL", "Agent prompt must be nonempty and at most 64 KiB."); + const validate = options.schema ? compileWorkflowSchema(options.schema) : undefined; + const scope = await this.callWorkspace(active, options, index); + const profiles = await this.options.loadProfiles(scope.workspaceRoot); + const target = resolveLocalAgentTarget(options.target, profiles, options.model, options.effort, this.options.subagents.providers); + if (!target) throw new WorkflowError("UNKNOWN_TARGET", `Unknown agent target: ${options.target}`); + options.model = target.model; + options.effort = target.effort; + const fingerprint = workflowHash({ prompt, options, sourceHash, scope, context: active.run.contextHash, + profile: target.kind === "profile" ? target.profile : null, providers: this.options.subagents }); + const previous = active.previous[index]; + const reuse = active.replayPrefix && previous?.status === "completed" && previous.fingerprint === fingerprint && options.writeMode === "read_only"; + if (!reuse) { + if (active.replayPrefix && active.run.resumeOf) this.options.store.event(active.run.id, "replay_invalidated", { index, reason: previous ? "call context or result changed" : "end of previous calls" }); + active.replayPrefix = false; + } + const now = new Date().toISOString(); + const call: WorkflowCall = { + runId: active.run.id, index, agentId: `agt_${randomUUID()}`, ...scope, prompt, options, fingerprint, + status: reuse ? "completed" : "queued", createdAt: now, updatedAt: now, + ...(reuse ? { reusedFrom: active.run.resumeOf, result: previous.result, agentId: previous.agentId, turnId: previous.turnId } : {}), + }; + this.options.store.addCall(call); + return { call, validate, reuse }; + }); + active.preparing = prepared.catch(() => {}); + const { call, validate, reuse } = await prepared; + if (reuse) return call.result; + let releaseRun: (() => void) | undefined; + let releaseGlobal: (() => void) | undefined; + try { + releaseRun = await active.slots.acquire(signal); + releaseGlobal = await this.slots.acquire(signal); + signal.throwIfAborted(); + this.countDispatch(active); + const fullPrompt = validate ? `${prompt}\n\nReturn only JSON matching this schema:\n${JSON.stringify(call.options.schema)}` : prompt; + const started = await this.options.agents.start({ ...call, ...call.options, prompt: fullPrompt, agentId: call.agentId }); + if (started.isErr()) throw started.error; + let turn = this.options.agents.getTurn(call.agentId, call); + if (turn.isErr()) throw turn.error; + if (!turn.value) throw new WorkflowError("WORKFLOW_INTERNAL", "Agent dispatch returned without a durable turn."); + call.turnId = turn.value.turnId; + this.options.store.updateCall(call.runId, call.index, { status: "running", turnId: turn.value.turnId }); + this.options.store.event(call.runId, "agent_turn", { index, agentId: call.agentId, turnId: turn.value.turnId, repair: false }); + let response = await this.awaitTurn(active, call, turn.value.turnId); + let output: unknown = response; + if (validate) { + try { output = parseWorkflowOutput(response, validate); } + catch (error) { + // Repair only formatting, in the same session with authority reduced to read-only. + signal.throwIfAborted(); + this.countDispatch(active); + this.options.store.event(call.runId, "schema_repair", { index, reason: workflowFailure(error).message }); + const repair = await this.options.agents.continue(call.agentId, + `Your last response failed JSON validation: ${workflowFailure(error).message}\nReturn corrected JSON only. Do not redo the task or change files. Schema: ${JSON.stringify(call.options.schema)}`, + { writeMode: "read_only" }, call); + if (repair.isErr()) throw repair.error; + turn = this.options.agents.getTurn(call.agentId, call); + if (turn.isErr()) throw turn.error; + if (!turn.value) throw new WorkflowError("WORKFLOW_INTERNAL", "Schema repair has no durable turn."); + call.turnId = turn.value.turnId; + this.options.store.updateCall(call.runId, call.index, { turnId: turn.value.turnId }); + this.options.store.event(call.runId, "agent_turn", { index, agentId: call.agentId, turnId: turn.value.turnId, repair: true }); + response = await this.awaitTurn(active, call, turn.value.turnId); + output = parseWorkflowOutput(response, validate); + } + } + output = jsonValue(output, 262_144, "agent result"); + this.options.store.updateCall(call.runId, index, { status: "completed", result: output }); + return output; + } catch (error) { + // Resolve ownership again if cancellation raced dispatch or the turn-link write. + await this.stopOwnedCall(active, call); + this.options.store.updateCall(call.runId, index, { status: signal.aborted ? "cancelled" : "failed", error: workflowFailure(error) }); + if (workflowFailure(error).code === "WORKFLOW_INTERNAL" || workflowFailure(error).code.includes("LIMIT")) this.fatal(active, error); + throw error; + } finally { releaseGlobal?.(); releaseRun?.(); } + } + private async awaitTurn(active: ActiveRun, call: WorkflowCall, turnId: string): Promise { + const signal = active.controller.signal; + const waited = await this.options.agents.wait([call.agentId], call, undefined, signal); + if (signal.aborted) { + await this.stopOwnedCall(active, { ...call, turnId }); + throw signal.reason; + } + if (waited.isErr()) throw waited.error; + const result = waited.value[0]; + if (result?.status === "completed") { + const response = result.response ?? ""; + if (Buffer.byteLength(response) > 262_144) throw new WorkflowError("WORKFLOW_VALUE_LIMIT", "Agent result exceeds 256 KiB."); + return response; + } + if (result?.status === "failed" || result?.status === "stopped") throw new WorkflowError(result.error?.code ?? "AGENT_STOPPED", result.error?.message ?? "Agent stopped.", result.error?.retryable); + throw new WorkflowError("WORKFLOW_INTERNAL", "Agent wait ended without a terminal result."); + } + private async stopOwnedCall(active: ActiveRun, call: WorkflowCall): Promise { + for (;;) { + let uncertainty: unknown; + try { + const current = this.options.agents.getTurn(call.agentId, call); + if (current.isErr()) { + if (current.error.code === "AGENT_NOT_FOUND") return; + uncertainty = current.error; + } else { + if (!current.value || current.value.status !== "running") return; + if (call.turnId !== undefined && current.value.turnId !== call.turnId) return; + call.turnId ??= current.value.turnId; + const stopped = await this.options.agents.stop(call.agentId, call); + if (stopped.isOk()) return; + // stop() conflicts when the turn settled by itself; re-read before treating that as uncertainty. + const settled = this.options.agents.getTurn(call.agentId, call); + if (settled.isOk() && settled.value?.status !== "running") return; + uncertainty = stopped.error; + } + } catch (error) { uncertainty = error; } + const error = new WorkflowError("RECOVERY_REQUIRED", `Waiting to confirm agent ${call.agentId} stopped: ${workflowFailure(uncertainty).message}`, true); + this.fatal(active, error); + try { + this.options.store.update(active.run.id, { status: "stopping", error: workflowFailure(error) }); + this.options.store.updateCall(call.runId, call.index, { error: workflowFailure(error) }); + } catch { /* Keep ownership in memory until persistence returns or the daemon restarts. */ } + // Admission remains closed while confirmation is unavailable; cancellation is not completion. + if (!this.accepting) { + active.unconfirmedStops = true; + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + private fatal(active: ActiveRun, error: unknown): unknown { active.controller.abort(error); return error; } + private countDispatch(active: ActiveRun): void { + if (++active.dispatches > 128) throw this.fatal(active, new WorkflowError("WORKFLOW_CALL_LIMIT", "Workflow exceeds 128 agent turns, including schema repairs.")); + } + private snapshot(id: string, scope: LocalAgentWorkspaceScope): WorkflowSnapshot { + const run = this.options.store.get(id); + if (!run || run.workspaceRoot !== canonicalRoot(scope.workspaceRoot) || run.workspaceId !== scope.workspaceId) { + throw new WorkflowError("WORKFLOW_NOT_FOUND", `Workflow is not in this workspace: ${id}`); + } + return run; + } + private async authorize(scope: LocalAgentWorkspaceScope): Promise { + let root: string; + try { + root = await realpath(scope.workspaceRoot); + if (this.options.allowedRoots) { + const managed = this.options.config && scope.workspaceId && isManagedWorkflowWorkspace(this.options.config, root, scope.workspaceId); + if (!managed) await resolveCanonicalAllowedPath(root, root, [...this.options.allowedRoots]); + } + } catch (error) { + // Keep the reason visible across the daemon boundary instead of DAEMON_INTERNAL_ERROR. + throw new WorkflowError("WORKSPACE_NOT_ALLOWED", `Workspace is not available for workflows: ${workflowFailure(error).message}`); + } + return { workspaceRoot: root, ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}) }; + } + private async namedSource(name: string, root: string): Promise { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) throw new WorkflowError("INVALID_WORKFLOW", "Workflow name must be a simple name, without paths."); + try { + const path = await resolveCanonicalAllowedPath(join(root, ".devspace", "workflows", `${name}.js`), root, [root]); + const file = await open(path, "r"); + try { + const buffer = Buffer.alloc(65_537); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > 65_536) throw new WorkflowError("SOURCE_LIMIT", "Workflow source exceeds 64 KiB."); + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); + } finally { await file.close(); } + } catch (error) { + if (error instanceof WorkflowError) throw error; + throw new WorkflowError("INVALID_WORKFLOW", `Cannot read workflow ${name}: ${workflowFailure(error).message}`); + } + } + private async callWorkspace(active: ActiveRun, options: WorkflowAgentOptions, index: number): Promise { + if (!options.workspace && !options.isolation) return { workspaceRoot: active.run.workspaceRoot, workspaceId: active.run.workspaceId }; + const key = options.workspace ?? `@call:${index}`; + let workspace = active.workspaces.get(key); + if (!workspace) { + if (!options.isolation) throw new WorkflowError("INVALID_WORKSPACE", `Unknown workflow workspace: ${key}. Create it with isolation: 'worktree'.`); + if (!this.options.config) throw new WorkflowError("INVALID_WORKSPACE", "Managed worktrees are unavailable."); + workspace = createWorkflowWorkspace(this.options.config, active.run); + active.workspaces.set(key, workspace); + const scope = await workspace; + this.options.store.event(active.run.id, "workspace_created", { key, ...scope, retained: true }); + } + return workspace; + } +} + +function summary({ source: _source, args: _args, contextHash: _hash, ...run }: WorkflowSnapshot): WorkflowRun { return run; } +function canonicalRoot(root: string): string { + try { return realpathSync.native(root); } catch { return resolve(root); } +} +function jsonValue(value: unknown, bytes: number, label: string): unknown { + let encoded: string | undefined; + try { encoded = JSON.stringify(value); } catch { /* Fail with the same public boundary error. */ } + if (encoded === undefined || Buffer.byteLength(encoded) > bytes) throw new WorkflowError("WORKFLOW_VALUE_LIMIT", `Workflow ${label} must be JSON and at most ${bytes} bytes.`); + return JSON.parse(encoded); +} +function agentOptions(value: WorkflowAgentOptions, authority: "read_only" | "allowed"): WorkflowAgentOptions { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new WorkflowError("INVALID_AGENT_CALL", "Agent options must be an object."); + const options = jsonValue(value, 32_768, "agent options") as WorkflowAgentOptions; + const keys = new Set(["target", "model", "effort", "schema", "label", "phase", "writeMode", "isolation", "workspace"]); + for (const key of Object.keys(options)) if (!keys.has(key)) throw new WorkflowError("INVALID_AGENT_CALL", `Unknown agent option: ${key}`); + if (typeof options.target !== "string" || !options.target.trim()) throw new WorkflowError("INVALID_AGENT_CALL", "Agent target is required."); + for (const key of ["model", "effort", "label", "phase", "workspace"] as const) { + if (options[key] !== undefined && (typeof options[key] !== "string" || options[key]!.length > 256 || !options[key]!.trim())) throw new WorkflowError("INVALID_AGENT_CALL", `Invalid agent ${key}.`); + } + if (options.workspace && !/^[\w-]{1,64}$/.test(options.workspace)) throw new WorkflowError("INVALID_WORKSPACE", "Workspace is a logical name, not a path."); + if (options.isolation !== undefined && options.isolation !== "worktree") throw new WorkflowError("INVALID_AGENT_CALL", "Only worktree isolation is supported."); + options.writeMode ??= authority; + if (!["read_only", "allowed"].includes(options.writeMode) || (authority === "read_only" && options.writeMode !== "read_only")) throw new WorkflowError("WORKFLOW_AUTHORITY", "Agent call cannot increase the workflow's write authority."); + if (options.schema !== undefined && (!options.schema || typeof options.schema !== "object" || Array.isArray(options.schema))) throw new WorkflowError("INVALID_SCHEMA", "Schema must be an object."); + return options; +} + +class Slots { + private used = 0; + private readonly waiting: Array<() => void> = []; + constructor(private readonly limit: number) {} + async acquire(signal: AbortSignal): Promise<() => void> { + signal.throwIfAborted(); + if (this.used >= this.limit) { + await new Promise((resolve, reject) => { + const ready = () => { signal.removeEventListener("abort", aborted); resolve(); }; + const aborted = () => { const index = this.waiting.indexOf(ready); if (index >= 0) this.waiting.splice(index, 1); reject(signal.reason); }; + this.waiting.push(ready); + signal.addEventListener("abort", aborted, { once: true }); + }); + } else this.used++; + if (signal.aborted) { this.release(); throw signal.reason; } + return () => this.release(); + } + private release(): void { + const next = this.waiting.shift(); + if (next) next(); else this.used--; + } +} diff --git a/src/workflow-runner-child.ts b/src/workflow-runner-child.ts new file mode 100644 index 000000000..51f3a5f3c --- /dev/null +++ b/src/workflow-runner-child.ts @@ -0,0 +1,430 @@ +import { + getQuickJS, + type QuickJSContext, + type QuickJSDeferredPromise, + type QuickJSHandle, + type QuickJSRuntime, +} from "quickjs-emscripten"; +import type { WorkflowRunnerLimits } from "./workflow-runner.js"; +import type { WorkflowScriptMeta } from "./workflow-script.js"; + +interface ErrorPayload { code: string; message: string; retryable?: boolean } +interface RunMessage { + type: "run"; + source: string; + args: unknown; + meta: WorkflowScriptMeta; + limits: WorkflowRunnerLimits; +} +interface HostResultMessage { + type: "host-result"; + id: number; + ok: boolean; + value?: unknown; + error?: ErrorPayload; + fatal?: boolean; +} + +let context: QuickJSContext | undefined; +let runtime: QuickJSRuntime | undefined; +let jsonObject: QuickJSHandle | undefined; +let jsonParse: QuickJSHandle | undefined; +let jsonStringify: QuickJSHandle | undefined; +let limits: WorkflowRunnerLimits | undefined; +let deadline = 0; +let nextCallId = 1; +let fatalError: ErrorPayload | undefined; +let logBytes = 0; +let logEntries = 0; +let shuttingDown = false; +let unflushed = 0; +const parentPid = process.ppid; +const pending = new Map(); + +process.once("disconnect", () => { + if (!shuttingDown) process.exit(1); +}); + +process.on("message", (message: unknown) => { + if (!isRecord(message)) return; + if (message.type === "run") { + if (runtime) return fail({ code: "WORKFLOW_PROTOCOL_ERROR", message: "Workflow runner received more than one run request." }); + void run(message as unknown as RunMessage); + } else if (message.type === "host-result") { + receiveHostResult(message as unknown as HostResultMessage); + } +}); + +async function run(message: RunMessage): Promise { + try { + limits = message.limits; + deadline = Date.now() + limits.timeoutMs; + const QuickJS = await getQuickJS(); + runtime = QuickJS.newRuntime(); + // ponytail: QuickJS exposes hard limits but no allocator-failure/high-water hook; + // use a custom WASM allocator if caught OOM/stack exceptions must stay observable. + runtime.setMemoryLimit(limits.memoryBytes); + runtime.setMaxStackSize(limits.stackBytes); + runtime.setInterruptHandler(() => { + if (process.ppid !== parentPid) process.exit(1); + if (fatalError) return true; + if (Date.now() <= deadline) return false; + fatalError = { code: "WORKFLOW_TIMEOUT", message: `Workflow exceeded ${limits?.timeoutMs ?? 0}ms.` }; + return true; + }); + context = runtime.newContext(); + jsonObject = context.getProp(context.global, "JSON"); + jsonParse = context.getProp(jsonObject, "parse"); + jsonStringify = context.getProp(jsonObject, "stringify"); + exposeHostFunctions(context); + + const evaluation = context.evalCode(workflowProgram(message), "workflow.js"); + if (evaluation.error) { + const error = quickJsError(evaluation.error); + evaluation.error.dispose(); + return fail(classifyExecutionError(error)); + } + + const promise = evaluation.value; + const resolved = context.resolvePromise(promise); + pumpJobs(); + const result = await resolved; + promise.dispose(); + if (fatalError) return fail(fatalError, true); + if (result.error) { + const error = quickJsError(result.error); + result.error.dispose(); + return fail(classifyExecutionError(error)); + } + if (pending.size > 0) { + return fail({ code: "UNAWAITED_CALLS", message: "Workflow returned while host calls were still pending. Await all calls before returning." }); + } + if (fatalError) return fail(fatalError, true); + + const encoded = encodeGuestJson(context, result.value, "Workflow result"); + result.value.dispose(); + if (Buffer.byteLength(encoded, "utf8") > limits.maxResultBytes) { + return fail({ code: "WORKFLOW_RESULT_LIMIT", message: `Workflow result exceeds the ${limits.maxResultBytes}-byte limit.` }, true); + } + send({ type: "result", value: JSON.parse(encoded) }); + } catch (error) { + fail(classifyExecutionError(error)); + } finally { + cleanup(); + } +} + +function exposeHostFunctions(vm: QuickJSContext): void { + const hostCall = vm.newFunction("__hostCall", (kindHandle, payloadHandle) => { + const deferred = vm.newPromise(); + try { + if (!limits) throw new Error("Workflow limits are unavailable."); + if (pending.size >= limits.maxOutstanding) { + const error = { + code: "WORKFLOW_OUTSTANDING_LIMIT", + message: `Workflow has more than ${limits.maxOutstanding} outstanding host calls.`, + }; + fatalError = error; + reject(deferred, error); + return deferred.handle; + } + const kind = vm.getString(kindHandle); + if (kind !== "agent" && kind !== "workflow") throw new Error("Unknown workflow host call."); + const encoded = encodeGuestJson(vm, payloadHandle, "Workflow host call"); + if (Buffer.byteLength(encoded, "utf8") > limits.maxArgsBytes) { + const error = { + code: "WORKFLOW_SIZE_LIMIT", + message: `Workflow host call exceeds the ${limits.maxArgsBytes}-byte limit.`, + }; + fatalError = error; + reject(deferred, error); + return deferred.handle; + } + const id = nextCallId++; + pending.set(id, deferred); + const safePayload = JSON.parse(encoded) as Record; + send(kind === "agent" + ? { type: "host-call", id, kind, prompt: safePayload.prompt, options: safePayload.options } + : { type: "host-call", id, kind, name: safePayload.name, args: safePayload.args }); + return deferred.handle; + } catch (error) { + reject(deferred, toErrorPayload(error)); + return deferred.handle; + } + }); + vm.setProp(vm.global, "__hostCall", hostCall); + hostCall.dispose(); + + const emit = vm.newFunction("__emit", (typeHandle, dataHandle) => { + if (!limits) throw new Error("Workflow limits are unavailable."); + const eventType = vm.getString(typeHandle); + const encoded = encodeGuestJson(vm, dataHandle, "Workflow event"); + logEntries += 1; + logBytes += Buffer.byteLength(encoded, "utf8") + eventType.length; + if (logEntries > limits.maxLogEntries || logBytes > limits.maxLogBytes) { + fatalError = { + code: "WORKFLOW_LOG_LIMIT", + message: `Workflow events exceed their configured limit.`, + }; + throw Object.assign(new Error(fatalError.message), fatalError); + } + send({ type: "event", eventType, data: JSON.parse(encoded) }); + }); + vm.setProp(vm.global, "__emit", emit); + emit.dispose(); +} + +function receiveHostResult(message: HostResultMessage): void { + if (!context || !runtime) return; + const deferred = pending.get(message.id); + if (!deferred) return; + pending.delete(message.id); + let failed = false; + try { + if (message.ok) { + const handle = valueHandle(context, message.value); + deferred.resolve(handle); + handle.dispose(); + } else { + const error = message.error ?? { code: "WORKFLOW_HOST_ERROR", message: "Workflow host call failed." }; + if (message.fatal) fatalError = error; + reject(deferred, error); + } + pumpJobs(); + } catch (error) { + failed = true; + fatalError = classifyExecutionError(error); + fail(fatalError, true); + } finally { + // The guest owns the duplicated promise returned by the host callback. + // This idempotently releases any host-side handle left after settlement. + try { deferred.dispose(); } finally { if (failed) cleanup(); } + } +} + +function pumpJobs(): void { + if (!runtime) return; + const jobs = runtime.executePendingJobs(); + if (jobs.error) { + const error = quickJsError(jobs.error); + jobs.error.dispose(); + throw error; + } +} + +function workflowProgram(message: RunMessage): string { + const args = JSON.stringify(message.args); + const concurrency = message.meta.concurrency; + return ` +"use strict"; +const args = Object.freeze(JSON.parse(${JSON.stringify(args)})); +const __error = error => ({ + code: typeof error?.code === "string" ? error.code : "WORKFLOW_STEP_FAILED", + message: typeof error?.message === "string" ? error.message : String(error), + retryable: error?.retryable === true, +}); +const { agent, workflow, log, phase, parallel, pipeline } = (() => { +const hostCall = globalThis.__hostCall; +const emit = globalThis.__emit; +delete globalThis.__hostCall; +delete globalThis.__emit; +const agent = (prompt, options = {}) => hostCall("agent", { prompt, options }); +const workflow = (name, workflowArgs = null) => hostCall("workflow", { name, args: workflowArgs }); +const log = (...values) => emit("log", values.length === 1 ? values[0] : values); +const phase = async (name, run) => { + if (typeof name !== "string" || !name || typeof run !== "function") throw new TypeError("phase(name, run) requires a name and function"); + emit("phase_started", { name }); + try { + const value = await run((prompt, options = {}) => agent(prompt, { ...options, phase: name })); + emit("phase_completed", { name }); + return value; + } catch (error) { + emit("phase_failed", { name, error: __error(error) }); + throw error; + } +}; +const parallel = async (items, worker, concurrency = ${concurrency}) => { + if (!Array.isArray(items) || typeof worker !== "function") throw new TypeError("parallel(items, worker) requires an array and function"); + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new RangeError("parallel concurrency must be between 1 and 16"); + const outcomes = new Array(items.length); + let next = 0; + const consume = async () => { + while (next < items.length) { + const index = next++; + try { + outcomes[index] = { status: "completed", value: await worker(items[index], index) }; + } catch (error) { + outcomes[index] = { status: "failed", error: __error(error) }; + } + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, consume)); + return outcomes; +}; +const pipeline = (items, ...stages) => { + if (!Array.isArray(stages) || stages.some(stage => typeof stage !== "function")) throw new TypeError("pipeline stages must be functions"); + return parallel(items, async (item, index) => { + let value = item; + for (const stage of stages) value = await stage(value, item, index); + return value; + }, ${concurrency}); +}; +return Object.freeze({ agent, workflow, log, phase, parallel, pipeline }); +})(); +globalThis.__workflowResult = (async () => { +${message.source} +})(); +globalThis.__workflowResult; +`; +} + +function valueHandle(vm: QuickJSContext, value: unknown) { + if (!jsonObject || !jsonParse) throw new Error("Workflow JSON parser is unavailable."); + const encoded = encodeJson(value, "Host result"); + const encodedHandle = vm.newString(encoded); + const result = vm.callFunction(jsonParse, jsonObject, encodedHandle); + encodedHandle.dispose(); + if (result.error) { + const error = quickJsError(result.error); + result.error.dispose(); + throw error; + } + return result.value; +} + +function encodeGuestJson(vm: QuickJSContext, value: QuickJSHandle, label: string): string { + if (!jsonObject || !jsonStringify) throw new Error("Workflow JSON serializer is unavailable."); + const result = vm.callFunction(jsonStringify, jsonObject, value); + if (result.error) { + const error = quickJsError(result.error); + result.error.dispose(); + throw Object.assign(new TypeError(`${label} must be JSON: ${error.message}`), { + code: "WORKFLOW_INVALID_JSON", + }); + } + try { + if (vm.typeof(result.value) !== "string") { + throw Object.assign(new TypeError(`${label} must be JSON.`), { code: "WORKFLOW_INVALID_JSON" }); + } + return vm.getString(result.value); + } finally { + result.value.dispose(); + } +} + +function reject(deferred: QuickJSDeferredPromise, error: ErrorPayload): void { + if (!context) return; + const handle = context.newError(error.message); + const code = context.newString(error.code); + const retryable = error.retryable ? context.true : context.false; + context.setProp(handle, "code", code); + context.setProp(handle, "retryable", retryable); + code.dispose(); + deferred.reject(handle); + handle.dispose(); +} + +function quickJsError(handle: Parameters[0]): Error & { code?: string; retryable?: boolean } { + if (!context) return new Error("Workflow execution failed."); + const value = context.dump(handle) as { name?: unknown; message?: unknown; stack?: unknown } | unknown; + if (isRecord(value)) { + const error: Error & { code?: string; retryable?: boolean } = new Error( + typeof value.message === "string" ? value.message : "Workflow execution failed.", + ); + if (typeof value.name === "string") error.name = value.name; + if (typeof value.stack === "string") error.stack = value.stack; + if (typeof value.code === "string") error.code = value.code; + if (value.retryable === true) error.retryable = true; + return error; + } + return new Error(typeof value === "string" ? value : JSON.stringify(value)); +} + +function classifyExecutionError(error: unknown): ErrorPayload { + if (fatalError) return fatalError; + const payload = toErrorPayload(error); + const text = payload.message.toLowerCase(); + if (text.includes("out of memory") || text.includes("cannot allocate memory")) { + return { code: "WORKFLOW_MEMORY_LIMIT", message: "Workflow exceeded its memory limit." }; + } + if (text.includes("stack overflow") || text.includes("stack size")) { + return { code: "WORKFLOW_STACK_LIMIT", message: "Workflow exceeded its stack limit." }; + } + if (text.includes("interrupted")) return { code: "WORKFLOW_TIMEOUT", message: "Workflow execution was interrupted." }; + return payload; +} + +function toErrorPayload(error: unknown): ErrorPayload { + const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { + code: typeof value?.code === "string" ? value.code : "WORKFLOW_EXECUTION_ERROR", + message: typeof value?.message === "string" ? value.message : String(error), + ...(value?.retryable === true ? { retryable: true } : {}), + }; +} + +function encodeJson(value: unknown, label: string): string { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value, (_key, current: unknown) => { + if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("non-finite number"); + if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || current === undefined) { + throw new TypeError(`unsupported ${typeof current}`); + } + return current; + }); + } catch (error) { + throw new TypeError(`${label} must be JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (encoded === undefined) throw new TypeError(`${label} must be JSON.`); + return encoded; +} + +function fail(error: ErrorPayload, fatal = false): void { + send({ type: fatal || isFatal(error.code) ? "fatal" : "error", error }); +} + +function isFatal(code: string): boolean { + return code.includes("LIMIT") + || code.includes("INTERNAL") + || code.includes("PROTOCOL") + || code === "WORKFLOW_TIMEOUT" + || code === "WORKFLOW_CANCELLED"; +} + +function cleanup(): void { + for (const deferred of pending.values()) deferred.dispose(); + pending.clear(); + jsonParse?.dispose(); + jsonStringify?.dispose(); + jsonObject?.dispose(); + jsonParse = undefined; + jsonStringify = undefined; + jsonObject = undefined; + try { context?.dispose(); } catch { /* The process is disposable. */ } + try { runtime?.dispose(); } catch { /* The process is disposable. */ } + context = undefined; + runtime = undefined; + shuttingDown = true; + disconnectWhenFlushed(); +} + +// Disconnecting before queued messages are written would drop the result the parent is waiting for. +function disconnectWhenFlushed(): void { + if (shuttingDown && unflushed === 0 && process.connected) process.disconnect?.(); +} + +function send(message: unknown): void { + if (!process.connected) return; + unflushed += 1; + try { + process.send?.(message as never, () => { unflushed -= 1; disconnectWhenFlushed(); }); + } catch { + unflushed -= 1; + if (!shuttingDown) process.exit(1); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/workflow-runner.test.ts b/src/workflow-runner.test.ts new file mode 100644 index 000000000..cb53deab6 --- /dev/null +++ b/src/workflow-runner.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { setTimeout as delay } from "node:timers/promises"; +import { runWorkflowScript } from "./workflow-runner.js"; +import { WorkflowError } from "./workflow-types.js"; + +const basic = await runWorkflowScript({ + source: `return { args, host: [typeof process, typeof require, typeof fetch, typeof globalThis.__hostCall, typeof globalThis.__emit] };`, + args: { value: 42 }, + onAgent: () => null, +}); +assert.deepEqual(basic, { + args: { value: 42 }, + host: ["undefined", "undefined", "undefined", "undefined", "undefined"], +}); +assert.deepEqual(await runWorkflowScript({ + source: `return [ + Function("return typeof process")(), + Function("return typeof globalThis.__hostCall")(), + agent.constructor("return typeof globalThis.__emit")(), + ];`, + onAgent: () => null, +}), ["undefined", "undefined", "undefined"], "dynamic code cannot recover host capabilities"); +assert.equal(await runWorkflowScript({ + source: `try { await import("node:fs"); return false; } catch { return true; }`, + onAgent: () => null, +}), true, "the guest has no module loader for host imports"); + +const prototypeArgs = JSON.parse('{"__proto__":{"polluted":true}}') as unknown; +assert.deepEqual(await runWorkflowScript({ + source: `return { own: Object.hasOwn(args, "__proto__"), polluted: ({}).polluted ?? null };`, + args: prototypeArgs, + onAgent: () => null, +}), { own: true, polluted: null }); + +const prototypeResult = JSON.parse('{"__proto__":{"polluted":true}}') as unknown; +assert.deepEqual(await runWorkflowScript({ + source: ` + const value = await agent("proto", { target: "worker" }); + return { own: Object.hasOwn(value, "__proto__"), polluted: ({}).polluted ?? null }; + `, + onAgent: () => prototypeResult, +}), { own: true, polluted: null }); + +const calls: Array<{ prompt: string; target: string }> = []; +const bridged = await runWorkflowScript({ + source: ` + export const meta = { name: 'bridge', concurrency: 2 }; + return agent("inspect", { target: "reviewer", writeMode: "read_only", workspace: "shared" }); + `, + onAgent: async (prompt, options) => { + calls.push({ prompt, target: options.target }); + return { ok: true }; + }, +}); +assert.deepEqual(bridged, { ok: true }); +assert.deepEqual(calls, [{ prompt: "inspect", target: "reviewer" }]); + +const parallel = await runWorkflowScript({ + source: ` + return parallel([30, 5, 10], async (wait, index) => { + const result = await agent(String(wait), { target: "worker", label: String(index) }); + if (index === 1) throw new Error("expected failure"); + return result; + }, 3); + `, + onAgent: async (prompt) => { + await delay(Number(prompt)); + return Number(prompt) * 2; + }, +}); +assert.deepEqual(parallel, [ + { status: "completed", value: 60 }, + { status: "failed", error: { code: "WORKFLOW_STEP_FAILED", message: "expected failure", retryable: false } }, + { status: "completed", value: 20 }, +]); + +const pipeline = await runWorkflowScript({ + source: ` + return pipeline([2, 3], + async (value, item, index) => await agent(String(value * 2 + index), { target: "worker" }), + async (value, item, index) => ({ value, item, index }), + ); + `, + onAgent: (prompt) => Number(prompt), +}); +assert.deepEqual(pipeline, [ + { status: "completed", value: { value: 4, item: 2, index: 0 } }, + { status: "completed", value: { value: 7, item: 3, index: 1 } }, +]); + +const events: Array<{ type: string; data: unknown }> = []; +const phased = await runWorkflowScript({ + source: ` + const value = await phase("review", boundAgent => boundAgent("go", { target: "worker" })); + log("done", value); + return value; + `, + onAgent: (_prompt, options) => options.phase, + onEvent: (type, data) => { events.push({ type, data }); }, +}); +assert.equal(phased, "review"); +assert.deepEqual(events.map(({ type }) => type), ["phase_started", "phase_completed", "log"]); +assert.deepEqual(events[2]?.data, ["done", "review"]); + +// A slow async onEvent must not turn the child's exit after its result into a runner failure. +const slowEvents: string[] = []; +assert.equal(await runWorkflowScript({ + source: `log("late"); return "kept";`, + onAgent: () => null, + onEvent: async (type) => { await delay(150); slowEvents.push(type); }, +}), "kept"); +assert.deepEqual(slowEvents, ["log"]); + +const nested = await runWorkflowScript({ + source: `return workflow("child", { n: 1 });`, + onAgent: () => null, + onWorkflow: (name, args) => ({ name, args }), +}); +assert.deepEqual(nested, { name: "child", args: { n: 1 } }); +await assert.rejects( + runWorkflowScript({ + source: `return workflow("grandchild", null);`, + depth: 1, + onAgent: () => null, + onWorkflow: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_NESTING_LIMIT", +); +await assert.rejects( + runWorkflowScript({ + source: `try { await workflow("grandchild", null); } catch {} return "swallowed";`, + depth: 1, + onAgent: () => null, + onWorkflow: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_NESTING_LIMIT", +); + +const caughtValidation = await runWorkflowScript({ + source: `try { await agent("go", {}); } catch (error) { return error.code; }`, + onAgent: () => null, +}); +assert.equal(caughtValidation, "WORKFLOW_INVALID_AGENT_CALL"); + +await assert.rejects( + runWorkflowScript({ + source: `return agent("fail", { target: "worker" });`, + onAgent: () => { throw Object.assign(new Error("provider unavailable"), { code: "PROVIDER_DOWN", retryable: true }); }, + }), + (error: unknown) => error instanceof WorkflowError + && error.code === "PROVIDER_DOWN" + && error.retryable === true, +); + +let finishDetached!: () => void; +const detachedCall = new Promise((resolve) => { finishDetached = resolve; }); +const detached = runWorkflowScript({ + source: `agent("detached", { target: "worker" }); return "done";`, + onAgent: async () => { await detachedCall; return null; }, +}); +await assert.rejects( + detached, + (error: unknown) => error instanceof WorkflowError && error.code === "UNAWAITED_CALLS", +); +finishDetached(); + +const largeAgentResult = "x".repeat(240 * 1024); +assert.equal(await runWorkflowScript({ + source: ` + for (let index = 0; index < 128; index += 1) { + const value = await agent(String(index), { target: "worker" }); + if (value.length !== ${240 * 1024}) throw new Error("truncated agent result"); + } + return 128; + `, + onAgent: () => largeAgentResult, +}), 128, "settled host-call handles do not accumulate against the QuickJS memory limit"); + +await assert.rejects( + runWorkflowScript({ + source: `try { for (;;) log("0123456789"); } catch {} return "swallowed";`, + limits: { maxLogBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_LOG_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `while (true) {}`, + limits: { timeoutMs: 2_000 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_TIMEOUT", +); + +await assert.rejects( + runWorkflowScript({ + source: `const recurse = () => recurse(); return recurse();`, + limits: { stackBytes: 64 * 1024 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_STACK_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `const values = []; for (;;) values.push("x".repeat(1024));`, + limits: { memoryBytes: 4 * 1024 * 1024, timeoutMs: 10_000 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_MEMORY_LIMIT", +); + +const controller = new AbortController(); +let agentStarted!: () => void; +const started = new Promise((resolve) => { agentStarted = resolve; }); +const aborted = runWorkflowScript({ + source: `return agent("wait", { target: "worker" });`, + signal: controller.signal, + onAgent: async () => { + agentStarted(); + await new Promise(() => undefined); + }, +}); +await started; +controller.abort(); +await assert.rejects( + aborted, + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_CANCELLED", +); + +await assert.rejects( + runWorkflowScript({ + source: `return "x".repeat(100);`, + limits: { maxResultBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_RESULT_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `return () => "not JSON";`, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_INVALID_JSON", +); + +await assert.rejects( + runWorkflowScript({ + source: `const value = {}; value.self = value; return value;`, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_INVALID_JSON", +); + +await assert.rejects( + runWorkflowScript({ + source: "return 1;".repeat(20), + limits: { maxSourceBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_SIZE_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `return args;`, + args: "x".repeat(100), + limits: { maxArgsBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_SIZE_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `return Promise.all(Array.from({ length: 3 }, () => agent("wait", { target: "worker" })));`, + limits: { maxOutstanding: 2 }, + onAgent: async () => await new Promise(() => undefined), + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_OUTSTANDING_LIMIT", +); diff --git a/src/workflow-runner.ts b/src/workflow-runner.ts new file mode 100644 index 000000000..0703e401b --- /dev/null +++ b/src/workflow-runner.ts @@ -0,0 +1,374 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { WorkflowError, type WorkflowAgentOptions } from "./workflow-types.js"; +import { parseWorkflowScript } from "./workflow-script.js"; + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export interface WorkflowRunnerLimits { + timeoutMs: number; + memoryBytes: number; + stackBytes: number; + maxSourceBytes: number; + maxArgsBytes: number; + maxResultBytes: number; + maxLogBytes: number; + maxLogEntries: number; + maxOutstanding: number; +} + +export interface RunWorkflowScriptInput { + source: string; + args?: unknown; + limits?: Partial; + signal?: AbortSignal; + /** Internal nesting marker. A nested runner must pass depth: 1. */ + depth?: number; + onAgent(prompt: string, options: WorkflowAgentOptions): Promise | unknown; + onWorkflow?(name: string, args: unknown): Promise | unknown; + onEvent?(type: string, data: unknown): Promise | void; +} + +export const DEFAULT_WORKFLOW_LIMITS: Readonly = Object.freeze({ + timeoutMs: 15 * 60_000, + memoryBytes: 32 * 1024 * 1024, + stackBytes: 512 * 1024, + maxSourceBytes: 64 * 1024, + maxArgsBytes: 128 * 1024, + maxResultBytes: 256 * 1024, + maxLogBytes: 64 * 1024, + maxLogEntries: 1_024, + maxOutstanding: 256, +}); + +const HARD_LIMITS: WorkflowRunnerLimits = { + timeoutMs: 24 * 60 * 60_000, + memoryBytes: 512 * 1024 * 1024, + stackBytes: 16 * 1024 * 1024, + maxSourceBytes: 4 * 1024 * 1024, + maxArgsBytes: 16 * 1024 * 1024, + maxResultBytes: 64 * 1024 * 1024, + maxLogBytes: 4 * 1024 * 1024, + maxLogEntries: 100_000, + maxOutstanding: 4_096, +}; + +interface RunnerErrorPayload { code: string; message: string; retryable?: boolean } +interface HostCallMessage { + type: "host-call"; + id: number; + kind: "agent" | "workflow"; + prompt?: unknown; + options?: unknown; + name?: unknown; + args?: unknown; +} + +export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise { + const limits = resolveLimits(input.limits); + if (typeof input.source !== "string") { + throw new WorkflowError("WORKFLOW_INVALID_SOURCE", "Workflow source must be a string."); + } + validateBytes(input.source, "Workflow source", limits.maxSourceBytes); + const parsed = parseWorkflowScript(input.source); + const args = validateJson(input.args ?? null, "Workflow arguments", limits.maxArgsBytes); + if (input.depth !== undefined && (!Number.isInteger(input.depth) || input.depth < 0 || input.depth > 1)) { + throw new WorkflowError("WORKFLOW_NESTING_LIMIT", "Workflows may be nested at most one level."); + } + if (input.signal?.aborted) throw cancelled(input.signal.reason); + + const extension = import.meta.url.endsWith(".ts") ? "ts" : "js"; + const childPath = fileURLToPath(new URL(`./workflow-runner-child.${extension}`, import.meta.url)); + const child = fork(childPath, [], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + serialization: "json", + env: {}, + }); + + let settled = false; + let outstanding = 0; + let eventBytes = 0; + let eventEntries = 0; + let stderr = ""; + let eventChain = Promise.resolve(); + let resultReceived = false; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-8_192); }); + + return await new Promise((resolve, reject) => { + const finish = (error?: unknown, value?: JsonValue) => { + if (settled) return; + settled = true; + clearTimeout(timer); + input.signal?.removeEventListener("abort", abort); + child.removeAllListeners(); + if (child.connected) { + try { child.disconnect(); } catch { /* The child is already closing. */ } + } + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (error) reject(error); + else resolve(value as JsonValue); + }; + const fatal = (error: unknown) => finish(asWorkflowError(error)); + const abort = () => fatal(cancelled(input.signal?.reason)); + const timer = setTimeout(() => { + fatal(new WorkflowError("WORKFLOW_TIMEOUT", `Workflow exceeded ${limits.timeoutMs}ms.`)); + }, limits.timeoutMs); + timer.unref(); + input.signal?.addEventListener("abort", abort, { once: true }); + + child.once("error", (error) => fatal(new WorkflowError("WORKFLOW_RUNNER_FAILED", error.message))); + // The child sends its result and then disconnects; `exit` can be observed before the + // message is delivered, so report the exit only once the IPC channel has drained. + child.once("exit", (code, signal) => { + const report = () => { + if (settled || resultReceived) return; + const detail = stderr.trim() ? `: ${stderr.trim()}` : ""; + fatal(new WorkflowError( + "WORKFLOW_RUNNER_EXITED", + `Workflow runner exited before returning a result (${signal ?? code ?? "unknown"})${detail}`, + )); + }; + if (!child.connected) return void setImmediate(report); + const grace = setTimeout(report, 1_000); + grace.unref(); + child.once("disconnect", () => { clearTimeout(grace); setImmediate(report); }); + }); + child.on("message", (message: unknown) => { + if (settled || !isRecord(message) || typeof message.type !== "string") return; + if (message.type === "host-call") { + void handleHostCall(message as unknown as HostCallMessage).catch(fatal); + return; + } + if (message.type === "event") { + if (message.eventType !== "log" + && message.eventType !== "phase_started" + && message.eventType !== "phase_completed" + && message.eventType !== "phase_failed") { + fatal(new WorkflowError("WORKFLOW_PROTOCOL_ERROR", "Workflow runner sent an invalid event.")); + return; + } + try { + const data = validateJson(message.data, "Workflow event", limits.maxArgsBytes); + eventEntries += 1; + eventBytes += Buffer.byteLength(JSON.stringify(data), "utf8") + message.eventType.length; + if (eventEntries > limits.maxLogEntries || eventBytes > limits.maxLogBytes) { + throw new WorkflowError("WORKFLOW_LOG_LIMIT", "Workflow events exceed their configured limit."); + } + eventChain = eventChain.then(() => input.onEvent?.(message.eventType as string, data)); + eventChain.catch(fatal); + } catch (error) { + fatal(error); + } + return; + } + if (message.type === "fatal" || message.type === "error") { + fatal(decodeError(message.error, message.type === "fatal" ? "WORKFLOW_LIMIT" : "WORKFLOW_FAILED")); + return; + } + if (message.type === "result") { + let result: JsonValue; + try { + result = validateJson(message.value, "Workflow result", limits.maxResultBytes); + } catch (error) { + fatal(error); + return; + } + resultReceived = true; + void eventChain.then(() => finish(undefined, result), fatal); + } + }); + + const handleHostCall = async (message: HostCallMessage): Promise => { + if (!Number.isSafeInteger(message.id) || message.id < 1 || outstanding >= limits.maxOutstanding) { + throw new WorkflowError("WORKFLOW_OUTSTANDING_LIMIT", "Workflow has too many outstanding host calls."); + } + outstanding += 1; + try { + let value: JsonValue; + if (message.kind === "agent") { + if (typeof message.prompt !== "string" || message.prompt.length === 0) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent prompt must be a non-empty string.")); + } + validateBytes(message.prompt, "Agent prompt", limits.maxArgsBytes); + const options = validateAgentOptions(message.options, limits.maxArgsBytes); + value = validateJson( + await input.onAgent(message.prompt, options), + "Agent result", + limits.maxResultBytes, + ); + } else if (message.kind === "workflow") { + if ((input.depth ?? 0) >= 1) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_NESTING_LIMIT", "Workflows may be nested at most one level.")); + } + if (!input.onWorkflow) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_UNAVAILABLE", "Nested workflows are unavailable.")); + } + if (typeof message.name !== "string" || message.name.trim().length === 0 || message.name.length > 128) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_INVALID_CALL", "workflow name must be a non-empty string.")); + } + const nestedArgs = validateJson(message.args ?? null, "Nested workflow arguments", limits.maxArgsBytes); + value = validateJson( + await input.onWorkflow(message.name, nestedArgs), + "Nested workflow result", + limits.maxResultBytes, + ); + } else { + throw new WorkflowError("WORKFLOW_PROTOCOL_ERROR", "Workflow runner requested an unknown host call."); + } + send(child, { type: "host-result", id: message.id, ok: true, value }); + } catch (error) { + sendHostError(child, message.id, error); + } finally { + outstanding -= 1; + } + }; + + send(child, { + type: "run", + source: parsed.body, + args, + meta: parsed.meta, + limits, + }); + }); +} + +export function validateJson(value: unknown, label: string, maxBytes: number): JsonValue { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value, (_key, current: unknown) => { + if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("non-finite number"); + if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || current === undefined) { + throw new TypeError(`unsupported ${typeof current}`); + } + if (current && typeof current === "object" && !Array.isArray(current)) { + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) throw new TypeError("non-plain object"); + } + return current; + }); + } catch (error) { + throw new WorkflowError("WORKFLOW_INVALID_JSON", `${label} must be JSON: ${errorMessage(error)}`); + } + if (encoded === undefined) throw new WorkflowError("WORKFLOW_INVALID_JSON", `${label} must be JSON.`); + validateBytes(encoded, label, maxBytes); + return JSON.parse(encoded) as JsonValue; +} + +function validateAgentOptions(value: unknown, maxBytes: number): WorkflowAgentOptions { + const options = validateJson(value, "Agent options", maxBytes); + if (!isRecord(options)) throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options must be an object."); + const allowed = new Set(["target", "model", "effort", "schema", "label", "phase", "writeMode", "isolation", "workspace"]); + for (const key of Object.keys(options)) { + if (!allowed.has(key)) throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", `Unknown agent option: ${key}`); + } + if (!validString(options.target, 128)) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.target must be a non-empty string."); + } + for (const key of ["model", "effort", "label", "phase"] as const) { + if (options[key] !== undefined && !validString(options[key], 256)) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", `agent options.${key} must be a non-empty string.`); + } + } + if (options.schema !== undefined && !isRecord(options.schema)) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.schema must be an object."); + } + if (options.writeMode !== undefined && options.writeMode !== "read_only" && options.writeMode !== "allowed") { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.writeMode must be read_only or allowed."); + } + if (options.isolation !== undefined && options.isolation !== "worktree") { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.isolation must be worktree."); + } + if (options.workspace !== undefined + && (typeof options.workspace !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(options.workspace))) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.workspace must be a logical key, not a path."); + } + return options as unknown as WorkflowAgentOptions; +} + +function resolveLimits(input: Partial | undefined): WorkflowRunnerLimits { + const limits = { ...DEFAULT_WORKFLOW_LIMITS, ...input }; + for (const key of Object.keys(DEFAULT_WORKFLOW_LIMITS) as Array) { + const value = limits[key]; + if (!Number.isSafeInteger(value) || value < 1 || value > HARD_LIMITS[key]) { + throw new WorkflowError("WORKFLOW_INVALID_LIMIT", `${key} must be an integer between 1 and ${HARD_LIMITS[key]}.`); + } + } + return limits; +} + +function validateBytes(value: string, label: string, limit: number): void { + if (Buffer.byteLength(value, "utf8") > limit) { + throw new WorkflowError("WORKFLOW_SIZE_LIMIT", `${label} exceeds the ${limit}-byte limit.`); + } +} + +function sendHostError(child: ChildProcess, id: number, error: unknown): void { + const workflowError = asWorkflowError(error); + send(child, { + type: "host-result", + id, + ok: false, + fatal: isFatalCode(workflowError.code), + error: { code: workflowError.code, message: workflowError.message, retryable: workflowError.retryable }, + }); +} + +function send(child: ChildProcess, message: unknown): void { + if (!child.connected) return; + try { + child.send(message as never, () => undefined); + } catch { + // The runner's exit/disconnect path reports the failure. + } +} + +function decodeError(value: unknown, fallbackCode: string): WorkflowError { + if (!isRecord(value)) return new WorkflowError(fallbackCode, "Workflow failed."); + return new WorkflowError( + typeof value.code === "string" ? value.code : fallbackCode, + typeof value.message === "string" ? value.message : "Workflow failed.", + value.retryable === true, + ); +} + +function asWorkflowError(error: unknown): WorkflowError { + if (error instanceof WorkflowError) return error; + const value = error as { code?: unknown; retryable?: unknown } | undefined; + return new WorkflowError( + typeof value?.code === "string" ? value.code : "WORKFLOW_FAILED", + errorMessage(error), + value?.retryable === true, + ); +} + +function cancelled(reason: unknown): WorkflowError { + if (reason instanceof WorkflowError) return reason; + const value = reason as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return new WorkflowError( + typeof value?.code === "string" ? value.code : "WORKFLOW_CANCELLED", + typeof value?.message === "string" ? value.message : "Workflow was cancelled.", + value?.retryable === true, + ); +} + +function validString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= maximum; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isFatalCode(code: string): boolean { + return code.includes("LIMIT") + || code.includes("INTERNAL") + || code.includes("PROTOCOL") + || code === "WORKFLOW_TIMEOUT" + || code === "WORKFLOW_CANCELLED"; +} diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts new file mode 100644 index 000000000..b47f82f53 --- /dev/null +++ b/src/workflow-schema.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { compileWorkflowSchema, parseWorkflowOutput } from "./workflow-schema.js"; + +const validate = compileWorkflowSchema({ + type: "object", properties: { summary: { type: "string" }, count: { type: "integer", minimum: 0 } }, + required: ["summary", "count"], additionalProperties: false, +}); +assert.deepEqual(parseWorkflowOutput('{"summary":"ok","count":2}', validate), { summary: "ok", count: 2 }); +assert.throws(() => parseWorkflowOutput('```json\n{}\n```', validate), /not JSON/); +assert.throws(() => parseWorkflowOutput('{"summary":"ok","count":-1}', validate), /JSON output/); +assert.doesNotThrow(() => compileWorkflowSchema({ type: ["string", "null"] })); +assert.throws(() => compileWorkflowSchema({ type: "not-a-type" }), /Invalid JSON schema/); +assert.throws(() => compileWorkflowSchema({ type: "string", pattern: "(a+)+$" }), /do not support pattern/); +assert.throws(() => compileWorkflowSchema({ $ref: "https://example.com/schema" }), /do not support \$ref/); +assert.throws(() => compileWorkflowSchema({ $async: true, type: "object" }), /do not support \$async/); +assert.doesNotThrow(() => compileWorkflowSchema({ type: "object", properties: { format: { type: "string" } } })); +console.log("Workflow structural schema validation checks passed."); diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts new file mode 100644 index 000000000..dff2be554 --- /dev/null +++ b/src/workflow-schema.ts @@ -0,0 +1,40 @@ +import { Ajv, type ValidateFunction } from "ajv"; +import { WorkflowError } from "./workflow-types.js"; + +/** Schemas are model supplied: bound compilation and disallow executable/recursive regex paths. */ +export function compileWorkflowSchema(schema: Record): ValidateFunction { + if (Buffer.byteLength(JSON.stringify(schema)) > 16_384) throw new WorkflowError("INVALID_SCHEMA", "Schema exceeds 16 KiB."); + let nodes = 0; + function visit(value: unknown, depth: number, position: "schema" | "schemas" | "data"): void { + if (++nodes > 512 || depth > 16) throw new WorkflowError("INVALID_SCHEMA", "Schema is too complex."); + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (position === "schema" && ["$ref", "$dynamicRef", "$recursiveRef", "$async", "pattern", "patternProperties", "format"].includes(key)) { + throw new WorkflowError("INVALID_SCHEMA", `Workflow schemas do not support ${key}. Use inline structural constraints.`); + } + const childPosition = position === "schemas" ? "schema" + : position !== "schema" ? "data" + : ["properties", "definitions", "$defs", "dependencies", "allOf", "anyOf", "oneOf"].includes(key) ? "schemas" + : ["additionalProperties", "additionalItems", "items", "contains", "not", "if", "then", "else", "propertyNames"].includes(key) ? (Array.isArray(child) ? "schemas" : "schema") + : "data"; + visit(child, depth + 1, childPosition); + } + } + visit(schema, 0, "schema"); + try { + return new Ajv({ strict: true, allowUnionTypes: true, allErrors: false, logger: false }).compile(schema); + } catch (error) { + throw new WorkflowError("INVALID_SCHEMA", `Invalid JSON schema: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function parseWorkflowOutput(text: string, validate: ValidateFunction): unknown { + let value: unknown; + try { value = JSON.parse(text); } + catch { throw new WorkflowError("INVALID_OUTPUT", "Agent output was not JSON."); } + if (!validate(value)) { + const error = validate.errors?.[0]; + throw new WorkflowError("INVALID_OUTPUT", `JSON output ${error?.instancePath ?? ""} ${error?.message ?? "does not match the schema"}.`); + } + return value; +} diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts new file mode 100644 index 000000000..5aab64e39 --- /dev/null +++ b/src/workflow-script.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript } from "./workflow-script.js"; + +assert.deepEqual(parseWorkflowScript("return args;"), { + meta: { name: "workflow", concurrency: 4 }, + body: "return args;", +}); + +const parsed = parseWorkflowScript(` + export const meta = { + name: 'review-files', + description: "Review\\nfiles", + concurrency: 8, + }; + return args; +`); +assert.deepEqual(parsed.meta, { + name: "review-files", + description: "Review\nfiles", + concurrency: 8, +}); +assert.match(parsed.body, /return args/); + +assert.deepEqual( + parseWorkflowScript(`export const meta = { "name": "quoted" }; return 1;`).meta, + { name: "quoted", concurrency: 4 }, +); +const commented = parseWorkflowScript(`// @ts-check +/* workflow metadata */ +export const meta = { name: "commented" }; return 1;`); +assert.equal(commented.meta.name, "commented"); +assert.equal(commented.body.trim(), "return 1;"); +// Long whitespace runs that do not lead to a meta declaration must fail fast (no regex backtracking blowup). +const slowStart = Date.now(); +for (const filler of [" ", "\n", "\r\n", " //a\n"]) { + const source = `${filler.repeat(5_000)}return 1;`; + assert.equal(parseWorkflowScript(source).body, source); +} +assert.ok(Date.now() - slowStart < 1_000, "meta prefix scan must be linear"); +assert.equal(parseWorkflowScript(`${" ".repeat(5_000)}export const meta = { name: "late" }; return 1;`).meta.name, "late"); +assert.throws( + () => parseWorkflowScript("export const meta = { name: getName() }; return 1;"), + /must be strings or numbers/, +); +assert.throws( + () => parseWorkflowScript("export const meta = { unknown: 'value' }; return 1;"), + /Unknown workflow metadata field/, +); +assert.throws( + () => parseWorkflowScript("export const meta = { concurrency: 17 }; return 1;"), + /between 1 and 16/, +); +assert.throws( + () => parseWorkflowScript("export const meta = { name: 'a', name: 'b' }; return 1;"), + /Duplicate workflow metadata field/, +); diff --git a/src/workflow-script.ts b/src/workflow-script.ts new file mode 100644 index 000000000..a24949792 --- /dev/null +++ b/src/workflow-script.ts @@ -0,0 +1,140 @@ +export interface WorkflowScriptMeta { + name: string; + description?: string; + concurrency: number; +} + +export interface ParsedWorkflowScript { + meta: WorkflowScriptMeta; + body: string; +} + +const DEFAULT_META: WorkflowScriptMeta = { name: "workflow", concurrency: 4 }; +// Each alternative matches in exactly one way (no nested quantifiers), so a failed match stays linear. +const META_PREFIX = /^(?:\s|\/\/[^\r\n]*(?=[\r\n]|$)|\/\*(?:[^*]|\*(?!\/))*\*\/)*export\s+const\s+meta\s*=/; + +/** Parse the optional, data-only metadata declaration without executing workflow code. */ +export function parseWorkflowScript(source: string): ParsedWorkflowScript { + const prefix = META_PREFIX.exec(source); + if (!prefix) return { meta: { ...DEFAULT_META }, body: source }; + + const parser = new MetaParser(source, prefix[0].length); + const fields = parser.object(); + parser.space(); + if (source[parser.position] === ";") parser.position += 1; + + const name = fields.name ?? DEFAULT_META.name; + const description = fields.description; + const concurrency = fields.concurrency ?? DEFAULT_META.concurrency; + if (typeof name !== "string" || name.trim().length === 0 || name.length > 128) { + throw new Error("Workflow meta.name must be a non-empty string of at most 128 characters."); + } + if (description !== undefined && (typeof description !== "string" || description.length > 2_048)) { + throw new Error("Workflow meta.description must be a string of at most 2048 characters."); + } + if (!Number.isInteger(concurrency) || (concurrency as number) < 1 || (concurrency as number) > 16) { + throw new Error("Workflow meta.concurrency must be an integer between 1 and 16."); + } + + return { + meta: { + name: name.trim(), + ...(description === undefined ? {} : { description }), + concurrency: concurrency as number, + }, + body: source.slice(parser.position), + }; +} + +class MetaParser { + position: number; + + constructor(private readonly source: string, position: number) { + this.position = position; + } + + object(): Record { + this.space(); + this.expect("{"); + const result: Record = {}; + this.space(); + while (this.source[this.position] !== "}") { + const key = this.key(); + if (key !== "name" && key !== "description" && key !== "concurrency") { + throw this.error(`Unknown workflow metadata field: ${key}`); + } + if (Object.hasOwn(result, key)) throw this.error(`Duplicate workflow metadata field: ${key}`); + this.space(); + this.expect(":"); + this.space(); + result[key] = this.source[this.position] === "'" || this.source[this.position] === '"' + ? this.string() + : this.number(); + this.space(); + if (this.source[this.position] !== ",") break; + this.position += 1; + this.space(); + if (this.source[this.position] === "}") break; + } + this.expect("}"); + return result; + } + + space(): void { + while (/\s/.test(this.source[this.position] ?? "")) this.position += 1; + } + + private key(): string { + this.space(); + if (this.source[this.position] === "'" || this.source[this.position] === '"') return this.string(); + const match = /^[A-Za-z_$][\w$]*/.exec(this.source.slice(this.position)); + if (!match) throw this.error("Expected a workflow metadata field."); + this.position += match[0].length; + return match[0]; + } + + private string(): string { + const quote = this.source[this.position++]; + let value = ""; + while (this.position < this.source.length) { + const character = this.source[this.position++]; + if (character === quote) return value; + if (character === "\n" || character === "\r") throw this.error("Workflow metadata strings cannot span lines."); + if (character !== "\\") { + value += character; + continue; + } + const escaped = this.source[this.position++]; + const simple: Record = { + "\\": "\\", "'": "'", '"': '"', n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", v: "\v", 0: "\0", + }; + if (escaped === "u") { + const hex = this.source.slice(this.position, this.position + 4); + if (!/^[0-9A-Fa-f]{4}$/.test(hex)) throw this.error("Invalid Unicode escape in workflow metadata."); + value += String.fromCharCode(Number.parseInt(hex, 16)); + this.position += 4; + } else if (escaped && Object.hasOwn(simple, escaped)) { + value += simple[escaped]; + } else { + throw this.error("Unsupported escape in workflow metadata."); + } + } + throw this.error("Unterminated workflow metadata string."); + } + + private number(): number { + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?/.exec(this.source.slice(this.position)); + if (!match) throw this.error("Workflow metadata values must be strings or numbers."); + this.position += match[0].length; + return Number(match[0]); + } + + private expect(character: string): void { + if (this.source[this.position] !== character) throw this.error(`Expected ${character} in workflow metadata.`); + this.position += 1; + } + + private error(message: string): SyntaxError { + return new SyntaxError(`${message} (at character ${this.position})`); + } +} diff --git a/src/workflow-store.ts b/src/workflow-store.ts new file mode 100644 index 000000000..8dc0bc40a --- /dev/null +++ b/src/workflow-store.ts @@ -0,0 +1,70 @@ +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { WorkflowError, type WorkflowCall, type WorkflowEvent, type WorkflowSnapshot } from "./workflow-types.js"; + +/** Durable ownership is written before dispatch; a provider session is never an ownership key. */ +export class WorkflowStore { + private readonly database: DatabaseHandle; + constructor(stateDir: string) { this.database = openDatabase(stateDir); } + close(): void { this.database.close(); } + + create(run: WorkflowSnapshot): void { + this.database.sqlite.prepare("insert into workflow_runs (id, workspace_root, workspace_id, record_json) values (?, ?, ?, ?)") + .run(run.id, run.workspaceRoot, run.workspaceId ?? null, JSON.stringify(run)); + } + get(id: string): WorkflowSnapshot | undefined { + return decode(this.database.sqlite.prepare("select record_json from workflow_runs where id = ?").get(id)); + } + list(scope?: LocalAgentWorkspaceScope): WorkflowSnapshot[] { + const rows = scope + ? this.database.sqlite.prepare("select record_json from workflow_runs where workspace_root = ? and workspace_id is ? order by rowid desc") + .all(scope.workspaceRoot, scope.workspaceId ?? null) + : this.database.sqlite.prepare("select record_json from workflow_runs order by rowid desc").all(); + return rows.map((row) => decode(row)!); + } + update(id: string, patch: Partial>): WorkflowSnapshot { + const run = this.get(id); + if (!run) throw new WorkflowError("WORKFLOW_NOT_FOUND", `Unknown workflow run: ${id}`); + Object.assign(run, patch, { updatedAt: new Date().toISOString() }); + this.database.sqlite.prepare("update workflow_runs set record_json = ? where id = ?").run(JSON.stringify(run), id); + return run; + } + addCall(call: WorkflowCall): void { + this.database.sqlite.transaction(() => { + const run = this.get(call.runId)!; + this.database.sqlite.prepare("insert into workflow_calls (run_id, call_index, agent_id, record_json) values (?, ?, ?, ?)") + .run(call.runId, call.index, call.agentId, JSON.stringify(call)); + run.callCount++; + run.updatedAt = new Date().toISOString(); + this.database.sqlite.prepare("update workflow_runs set record_json = ? where id = ?").run(JSON.stringify(run), run.id); + })(); + } + calls(runId: string): WorkflowCall[] { + return this.database.sqlite.prepare("select record_json from workflow_calls where run_id = ? order by call_index").all(runId) + .map((row) => decode(row)!); + } + call(runId: string, index: number): WorkflowCall | undefined { + return decode(this.database.sqlite.prepare("select record_json from workflow_calls where run_id = ? and call_index = ?").get(runId, index)); + } + updateCall(runId: string, index: number, patch: Partial): WorkflowCall { + const call = this.call(runId, index); + if (!call) throw new WorkflowError("WORKFLOW_CALL_NOT_FOUND", `Unknown workflow call: ${index}`); + Object.assign(call, patch, { updatedAt: new Date().toISOString() }); + this.database.sqlite.prepare("update workflow_calls set record_json = ? where run_id = ? and call_index = ?") + .run(JSON.stringify(call), runId, index); + return call; + } + event(runId: string, type: string, data: unknown): void { + this.database.sqlite.prepare("insert into workflow_events (run_id, type, data_json, created_at) values (?, ?, ?, ?)") + .run(runId, type, JSON.stringify(data), new Date().toISOString()); + } + events(runId: string, after = 0): WorkflowEvent[] { + const rows = this.database.sqlite.prepare("select * from workflow_events where run_id = ? and sequence > ? order by sequence limit 100").all(runId, after) as Array<{ + sequence: number; run_id: string; type: string; data_json: string; created_at: string; + }>; + return rows.map((r) => ({ sequence: r.sequence, runId: r.run_id, type: r.type, data: JSON.parse(r.data_json), createdAt: r.created_at })); + } +} +function decode(row: unknown): T | undefined { + return row ? JSON.parse((row as { record_json: string }).record_json) as T : undefined; +} diff --git a/src/workflow-types.ts b/src/workflow-types.ts new file mode 100644 index 000000000..1cc88d61e --- /dev/null +++ b/src/workflow-types.ts @@ -0,0 +1,77 @@ +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; + +export type WorkflowStatus = "starting" | "running" | "stopping" | "completed" | "failed" | "cancelled" | "interrupted"; +export type WorkflowWriteMode = "read_only" | "allowed"; +export interface WorkflowFailure { code: string; message: string; retryable: boolean } +export class WorkflowError extends Error { + constructor(readonly code: string, message: string, readonly retryable = false) { + super(message); + this.name = "WorkflowError"; + } +} +export interface WorkflowRunInput extends LocalAgentWorkspaceScope { + source?: string; + name?: string; + args?: unknown; + resume?: string; + writeMode?: WorkflowWriteMode; +} +export interface WorkflowRun extends LocalAgentWorkspaceScope { + id: string; + name: string; + status: WorkflowStatus; + writeMode: WorkflowWriteMode; + concurrency: number; + resumeOf?: string; + result?: unknown; + error?: WorkflowFailure; + createdAt: string; + updatedAt: string; + callCount: number; +} +export interface WorkflowSnapshot extends WorkflowRun { + source: string; + args: unknown; + contextHash: string; +} +export interface WorkflowAgentOptions { + target: string; + model?: string; + effort?: string; + schema?: Record; + label?: string; + phase?: string; + writeMode?: WorkflowWriteMode; + isolation?: "worktree"; + /** A logical name, shared by calls in this run; never a filesystem path. */ + workspace?: string; +} +export interface WorkflowCall { + runId: string; + index: number; + agentId: string; + turnId?: string; + status: "queued" | "running" | "completed" | "failed" | "cancelled" | "interrupted"; + prompt: string; + options: WorkflowAgentOptions; + fingerprint: string; + workspaceRoot: string; + workspaceId?: string; + result?: unknown; + error?: WorkflowFailure; + reusedFrom?: string; + createdAt: string; + updatedAt: string; +} +export interface WorkflowEvent { sequence: number; runId: string; type: string; data: unknown; createdAt: string } +export function workflowFailure(error: unknown): WorkflowFailure { + const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { + code: typeof value?.code === "string" ? value.code : "WORKFLOW_FAILED", + message: typeof value?.message === "string" ? value.message : String(error), + retryable: value?.retryable === true, + }; +} +export function workflowTerminal(status: WorkflowStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted"; +} diff --git a/src/workflow-workspaces.ts b/src/workflow-workspaces.ts new file mode 100644 index 000000000..5c76efad0 --- /dev/null +++ b/src/workflow-workspaces.ts @@ -0,0 +1,59 @@ +import { realpathSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { ServerConfig } from "./config.js"; +import { createManagedWorktree } from "./git-worktrees.js"; +import { isPathInsideRoot, resolveCanonicalAllowedPath } from "./roots.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { WorkflowError } from "./workflow-types.js"; + +// Native realpath expands Windows 8.3 short names (RUNNER~1), matching the async realpath in roots.ts. +// The JS-implemented realpathSync does not, so mixing the two makes equal paths look unrelated. +const canonical = realpathSync.native; + +export function isManagedWorkflowWorkspace(config: ServerConfig, root: string, id: string): boolean { + const store = new SqliteWorkspaceStore(config.stateDir); + try { + const workspace = store.getSession(id); + if (!workspace || workspace.status !== "active" || !workspace.managed || workspace.mode !== "worktree" || !workspace.sourceRoot) return false; + const path = canonical(root); + return path === canonical(workspace.root) + && isPathInsideRoot(path, canonical(config.worktreeRoot)) + && config.allowedRoots.some((allowed) => { + try { return isPathInsideRoot(canonical(workspace.sourceRoot!), canonical(allowed)); } + catch { return false; } + }); + } catch { return false; } + finally { store.close(); } +} + +export async function createWorkflowWorkspace(config: ServerConfig, source: LocalAgentWorkspaceScope): Promise { + let sourcePath = source.workspaceRoot; + let baseRef: string | undefined; + if (source.workspaceId && isManagedWorkflowWorkspace(config, sourcePath, source.workspaceId)) { + const store = new SqliteWorkspaceStore(config.stateDir); + try { + const session = store.getSession(source.workspaceId); + if (!session?.sourceRoot || session.status !== "active") throw new WorkflowError("INVALID_WORKSPACE", "Source worktree is no longer active."); + sourcePath = session.sourceRoot; + } + finally { store.close(); } + const head = await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd: source.workspaceRoot, timeout: 5_000 }); + baseRef = head.stdout.trim(); + } + await resolveCanonicalAllowedPath(sourcePath, sourcePath, config.allowedRoots); + const worktree = await createManagedWorktree({ sourcePath, baseRef, config }); + const root = await resolveCanonicalAllowedPath(worktree.path, worktree.path, [config.worktreeRoot]); + const store = new SqliteWorkspaceStore(config.stateDir); + try { + const workspace = store.createSession({ + id: `ws_${randomUUID()}`, root, mode: "worktree", sourceRoot: worktree.sourceRoot, + baseRef: worktree.baseRef, baseSha: worktree.baseSha, managed: true, + }); + return { workspaceRoot: root, workspaceId: workspace.id }; + } catch (error) { + throw new WorkflowError("WORKSPACE_REGISTRATION_FAILED", `Worktree was retained at ${root}, but registering it failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { store.close(); } +}