diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index ecb4e98def..27fb29dffd 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 14e8dde04d9526aaffc0e58be049e13858362887 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +2026-07-07-claude-code-and-codex-subagent-backends.md: 86a2e3489a84408e24c6c8091bc52b747b9069b9 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: ef2098b3afe3e5602ed93de1984c91a5c4c1e79e diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index ee8576f97a..86a2e3489a 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents) +# Agent Note: Claude Code and Codex subagent providers Status: proposed @@ -6,84 +6,69 @@ English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) ## Problem -The subagent seam ([the seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. +The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, use the parent Session's workspace, return a final answer or explicit failure, and leave no managed product process behind. + +Product integration must not create a second owner for task text, cwd, cancellation, result settlement, or process trees. It must also prove the real product path in required keyless tests: a fake wrapper or direct model HTTP request cannot establish that the Loader, provider registration, official product protocol, authentication, final answer, and teardown compose correctly. ## Proposal -Two sibling provider packages, structural variants of the ACP backend, plus one extraction: +Two sibling one-shot providers register fixed deployment names and are exposed through two fixed `dsh-tool-subagent` instances: -- `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. -- `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. -- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. +- `@deepseek-ai/dsh-subagent-codex` registers `codex`, driven through `codex app-server --stdio`, and is implemented. +- `@deepseek-ai/dsh-subagent-claude-code` will register `claude-code`, driven through the official Claude Agent SDK and its bundled CLI, and remains pending. -Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. +The model-facing tools are `subagent_codex` and `subagent_claude_code`. Each tool binds one provider at deployment time, accepts a standalone task, and omits the background parameter in the initial compositions. Product selection is not another model argument. -## Verified interface facts (pinned versions) +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and use the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and one non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground disposal; the shared subprocess service owns environment scrubbing, process-tree termination, and whole-tree exit observation. -Both integration surfaces were verified against pinned implementations before this proposal — types and bundled source read, keyless spikes run — not from vendor docs alone. The pins are the verification baseline, not a runtime contract: the backends perform no runtime version probe (no `codex --version` gate, no SDK version sniffing). Compatibility is enforced at development time — every dependency bump re-runs the keyless suites against the real load path — and at runtime by failing loudly: a protocol-level surprise settles `error` via `onError`, never a silent misbehavior. +## Codex provider -**`@anthropic-ai/claude-agent-sdk` 0.3.202.** `options.env` REPLACES the child environment (no merge with `process.env`), which is exactly what the scrub needs. `settingSources` defaults to loading ALL filesystem settings — isolation requires explicitly passing `[]`. Result subtypes are `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`. On abort the SDK escalates the CLI child itself: stdin EOF immediately, SIGTERM ~2s later if the child ignores it (observed; no leftover processes) — no bespoke kill fallback needed. `outputFormat: {type: 'json_schema'}` and an `agents` option exist, giving future landing points for the seam's `outputSchema` capability and named subagent types; both are out of scope here. +The Codex provider has fixed name `codex` and fixed command `codex app-server --stdio`. Its public configuration contains only explicit `env` entries and a positive finite `disposeGraceMs`; it does not expose command, cwd, model, base URL, API key, sandbox, approval, product home, or session settings. Production resolves Codex from `PATH` and uses the host's native Codex configuration and authentication. Credential-shaped ambient variables are scrubbed by `dsh-subprocess`, while explicit `env` values merge afterward. -**codex CLI 0.142.5, `codex app-server` (v2 vocabulary).** LF-delimited JSON, JSON-RPC 2.0 shapes with the `"jsonrpc"` header omitted. +Before publication, the provider validates a non-empty text-only task, starts the managed app-server, performs `initialize` → `initialized`, and creates an `ephemeral: true` thread in the parent workspace. The returned run owns exactly one `turn/start`; product thread and turn ids stay private and are not persisted in the parent Session. -- Lifecycle: `initialize{clientInfo}` + `initialized` → `thread/start` (accepts `cwd`, `model`, `sandbox`, `approvalPolicy`, `ephemeral`; succeeds unauthenticated) → `turn/start{threadId, input:[{type:'text',text}]}` returns an `inProgress` turn immediately; the terminal signal is the `turn/completed` notification carrying `Turn{status: completed|interrupted|failed|inProgress, error}`. -- Approvals are server-initiated requests — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request` — answered with `accept`/`decline`-family decisions. -- Auth: `account/login/start{type:'apiKey', apiKey}` is a first-class RPC and `account/read` reports `requiresOpenaiAuth` — and an unauthenticated `turn/start` does NOT fail fast (it hangs in retry), so the backend MUST pre-check auth and settle `error` loudly instead of waiting on the turn. -- Isolation: `CODEX_HOME` redirection is honored (the `initialize` response echoes it, so tests can assert isolation), and `ephemeral: true` threads leave no session files at all. +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins, with the latest nullable-phase message as the compatibility fallback; commentary never replaces an answer. A completed turn without an answer, a failed or interrupted remote turn, malformed payload, protocol closure, early process exit, or unknown server request becomes a shared `error`. Local cancellation wins the race and remains `aborted`. -## Isolation and credentials +The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. It fails closed for every other server request instead of waiting for UI that this provider does not supply. -Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. +Publication transfers the wire and process handle to one holder. Idempotent disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. An unpublished startup failure performs the same cleanup before `start()` rejects. -## Permission and approval policy +## Claude Code provider -Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. +The Claude Code sibling follows the same fixed-name, self-contained, one-shot, parent-cwd, shared-result, and managed-tree boundaries. Its product-specific implementation will use the official Agent SDK's `query()` and spawn hook, keep SDK protocol ownership separate from `dsh-subprocess` process-tree ownership, omit human-interaction callbacks, and derive only a strict final SDK result after the message iterator ends normally. -## StopReason mapping +The Claude package will expose the same two configuration concerns, `env` and `disposeGraceMs`. Product installation, native settings, and login remain deployment responsibilities rather than plugin-managed state. This note stays proposed until that sibling and the combined two-product evidence are implemented. -Claude Code: `success` → `completed`; `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries` → `error` (aligning with the ACP call on `max_turn_requests`: an unfinished task is not success); generator abort → `aborted`; anything unknown → `error`. Codex: `Turn.status` `completed` → `completed`; `interrupted` → `aborted`; `failed` with `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`, any other `failed` → `error`; transport/spawn/auth-precheck failure → `error` (or `aborted` if cancel was requested). In both, `cancel()` is the ACP shape: flag + abort/interrupt + a cancel-settled race arm so an uncooperative child cannot stall the result. +## Evidence contract -Liveness posture, stated explicitly: teardown timing is config, turn duration is not. Both backends take the dispose ladder's grace periods as defaulted validated config fields (the ACP backend's `disposeEofGraceMs`/`disposeGraceMs` shape, carried by the extraction), but there is deliberately NO turn-duration or startup timeout — matching ACP, liveness during a turn belongs to the caller via `cancel()`/the abort signal, a subagent turn is legitimately minutes long, and the Codex auth precheck removes the one verified guaranteed-hang; a deployment wanting a wall-clock bound cancels from the parent. +Each product owns package-level branch-complete tests, a required real-product spec, and a real Loader snapshot. The real-product tier must use the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product configuration, and a loopback fixed-answer model; it fails rather than skips when the binary, authentication request, task, answer, cancellation, or process-exit proof is missing. -## Testing - -Named at every tier per the root AGENTS.md rule, and de-risked up front: - -- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. -- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. -- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. +The Codex evidence pins `@openai/codex@0.146.0` / `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and every managed handle reaching whole-tree quiescence. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, full persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still uses `codex` from `PATH`. ## Alternatives considered -### Why not the official `@openai/codex-sdk` instead of a hand-rolled client? +**Direct model HTTP or `codex exec`.** These paths bypass the products' official extensible process protocols and cannot prove product configuration, tools, approvals, lifecycle, or teardown. The providers use app-server and the official Agent SDK instead. -The dispose ladder and env scrub require owning the child process (spawn args, env, signals, exit await); the SDK hides the process. The wire format is trivial to frame (LF JSON), the shapes are generatable per pinned version (`codex app-server generate-json-schema`), and the repo precedent (`hook-protocol`) is to own thin protocol cores rather than wrap someone's runtime. The SDK would save protocol-evolution maintenance but costs the exact control this backend exists to have. +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before two production consumers demonstrated a missing common contract, so product-specific adapters call the existing seams directly. -### Why not a model-visible `subagent_type` parameter (one Task-style tool)? +**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. -Claude Code's own Task tool puts the subagent type in the model-facing schema, selecting a prompt-plus-toolset persona. Here the choice is between EXECUTION ENGINES, and only the deployer knows which engines have credentials configured — so selection stays deployment config, preserving `dsh-tool-subagent`'s documented one-provider-per-tool contract. A persona-style type selector would be a separate Agent Note against the tool, not the backends. +**Product doubles as required evidence.** Doubles are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives the official product against loopback model fixtures. -### Why not login-state credentials and the user's own config? +**Plugin-managed login, product home, models, or permissions.** Those settings would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only explicit environment overlay and teardown grace; unattended interaction fails closed. -Inheriting `~/.claude` / `~/.codex` (subscription login, user settings, skills, MCP servers) would make child behavior depend on host-machine state and punch an implicit exception through the "credentials enter explicitly via `config.env`, never ambiently" rule the ACP backend and bash executor established. API-key-only plus forced config-dir isolation keeps runs reproducible; deployments wanting shared state can point the config-dir field at a persistent directory deliberately. - -### Why not a driver-injection seam for the Claude Code keyless tests? - -Injecting a fake `query()` would mock our own boundary and leave the real SDK load path untested (the real-over-mock policy in docs/testing.md). The risk that justified considering it — the SDK↔CLI stream-json control protocol being internal — was retired by the spike: the fake-CLI harness works against the real pinned SDK today. If an SDK upgrade breaks the mock, the keyless suite fails the upgrade PR, which is the gate working. - -### Why not ACP adapters (e.g. `claude-code-acp`) reusing the existing backend? - -Community shims wrap both engines in ACP, which would make them "just config" on `dsh-subagent-acp`. But that inserts an unofficial third-party layer between the harness and the engine, erases the native control surfaces this Agent Note exposes (permissionMode, sandboxMode/approvalPolicy, config-dir isolation, apiKey RPC), and trades first-party protocol stability for a shim's release cadence. First-party surfaces — the Agent SDK and the app-server — are the supported integration points. +**Continuation, progress, and shared parent context.** The first user result needs one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt. ## Acceptance criteria -On a machine with both engines and keys configured: a REPL-driven model completes one real file task through `subagent_claude_code` and one through `subagent_codex`, the tool result being the child's final answer, with only `tool/call` + `tool/result` in the parent session log. Keyless suites pass at 100% per-file coverage in a credential-less environment, asserting isolation (scrubbed child env, no temp config dirs left after dispose) and that child behavior is unchanged by the presence or absence of `~/.claude` / `~/.codex`. Cancelling a parent turn quiesces both backends in bounded time with no leftover child processes. E2e suites self-skip cleanly, naming the missing prerequisite. +The proposal is complete when both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure/cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages have complete configuration, lifecycle, failure, model-experience, and limitation documentation; the generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. + +The implemented Codex half already satisfies this contract for its fixed tool and 0.146.0 product baseline. The note remains proposed because the Claude Code sibling and combined final evidence are not yet implemented. ## Risks -- `codex app-server` is CLI-flagged experimental and its v1/v2 vocabularies coexist; the client pins 0.142.5, implements v2 only, and consumes unknown methods/notifications without crashing, but a future codex bump can still force rework (regenerate schemas and re-run the keyless suite on every bump — the development-time enforcement behind the no-runtime-version-probe stance above). -- The Claude Code fake-CLI mock rides an internal protocol: any SDK upgrade must go through the keyless suite, and a breaking control-protocol change means reworking the mock (fallback: the driver-injection seam rejected above becomes the escape hatch). -- The SDK's optionalDependencies weigh ~280MB per platform — accepted, and confined to the one backend package. -- The SDK's SIGKILL branch beyond EOF→SIGTERM was not observed and is trusted; e2e keeps a no-leftover-process assertion. -- Codex is a deployment prerequisite (no npm-bundled binary); a missing or incompatible binary surfaces as a loud spawn/protocol `error`, not a version probe. -- Every run pays a fresh child process and only the final answer surfaces — thoughts, tool cards, and usage are consumed and dropped; pooling, intermediate-progress surfacing, `sendMessage`/`resume`, `outputSchema` via the SDK's `outputFormat`, and named subagent types via the SDK's `agents` option are all deliberate deferrals. +- The Codex app-server protocol is product-versioned and may change; production performs no runtime version probe, so every supported baseline change must refresh schema investigation and real-product compatibility evidence. +- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's own authority. +- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. +- Product tool or file side effects are not rolled back when a run fails or is cancelled. +- Unattended approval denial keeps the initial provider safe from interactive hangs but cannot satisfy tasks that require new permission. diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 14e8dde04d..ef2098b3af 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent(智能体)的进程外委派) +# Agent Note: Claude Code 与 Codex subagent 提供方 Status: proposed @@ -6,84 +6,69 @@ Status: proposed ## 问题 -subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 +命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要接入真实 Codex 与 Claude Code 产品的第一方路径。一个实用的首版必须能把一个自包含任务交给任一产品,使用父会话的工作区,返回最终答案或明确失败,并且不留下任何受管产品进程。 + +产品集成不得让任务文本、工作目录、取消、结果结算或进程树出现第二个所有者。它还必须在强制无密钥测试中证明真实产品链路:假包装层或直接向模型发起的 HTTP 请求无法证明 Loader、提供方注册、官方产品协议、认证、最终答案和清理能够正确组合运行。 ## 提案 -两个兄弟提供方包(package),作为 ACP 后端的结构变体,另加一次提取: +两个同级的单次执行提供方注册固定部署名称,并通过两个固定的 `dsh-tool-subagent` 实例对外提供: -- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI(命令行界面)作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。 -- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 -- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose(资源释放)阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 +- `@deepseek-ai/dsh-subagent-codex` 注册 `codex`,由 `codex app-server --stdio` 驱动,现已实现。 +- `@deepseek-ai/dsh-subagent-claude-code` 将注册 `claude-code`,由官方 Claude Agent SDK 及其捆绑的 CLI(命令行界面)驱动,目前仍待实现。 -两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次提示词往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。 +面向模型的工具为 `subagent_codex` 和 `subagent_claude_code`。每个工具在部署时绑定一个提供方,接受一个独立任务,并在初始组合中省略后台参数。产品选择不作为额外的模型参数。 -## 已验证的接口事实(固定版本) +两个提供方均报告 `inheritsParentContext: false`,不声明任何可选启动能力,并使用父会话的工作目录而不复制父会话对话。每次调用都会创建一个全新的产品进程和一次不可恢复的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台 dispose(资源释放);共享子进程服务负责环境清洗、进程树终止和整棵进程树的退出观测。 -两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 +## Codex 提供方 -**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行逐级加强对 CLI 子进程的终止措施:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。 +Codex 提供方的固定名称为 `codex`,固定命令为 `codex app-server --stdio`。其公开配置只包含显式 `env` 条目和取正有限值的 `disposeGraceMs`;不公开命令、工作目录、模型、基础 URL、API 密钥、沙箱、审批、产品主目录或会话设置。生产环境从 `PATH` 解析 Codex,并使用宿主机原生的 Codex 配置和认证。`dsh-subprocess` 会清洗环境中形似凭证的变量,之后再合并显式 `env` 值。 -**codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 +在发布运行实例前,提供方会验证任务非空且仅含文本,启动受管 app-server,依次执行 `initialize` → `initialized`,并在父工作区中创建一个 `ephemeral: true` 线程。返回的运行实例只负责一次 `turn/start`;产品线程 ID 和轮次 ID 始终为私有信息,不会持久化到父会话中。 -- 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的轮次;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 -- 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 -- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待轮次。 -- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。 +`turn/completed` 是判定远端终止状态的权威依据。最新一条内容非空且带有 `phase: "final_answer"` 的 `agentMessage` 优先;阶段字段可为空值的最新消息作为兼容回退。过程说明绝不取代答案。已完成但无答案的轮次、失败或中断的远端轮次、格式错误的载荷、协议关闭、进程提前退出或未知服务端请求,都会结算为共享的 `error`。本地取消会在竞态中胜出,结果仍为 `aborted`。 -## 隔离与凭证 +无人值守通信层会拒绝命令审批和文件审批,对于该轮次请求的权限一概不予授予,并拒绝 MCP elicitation。对于其他所有服务端请求,它都会以失败响应,而不会等待本提供方并未提供的 UI。 -部署只使用 API key 认证,子进程不得看到宿主用户的 Claude Code / Codex 配置:行为必须只由 `cordis.yml` 决定。每次运行获得一个全新的 `mkdtemp` 配置目录——Claude Code 使用 `CLAUDE_CONFIG_DIR`(并显式设置 `settingSources: []`),Codex 使用 `CODEX_HOME`——dispose 时尽力删除;配置字段也可以固定一个持久目录。子进程环境通过提取逐字复用 ACP 后端的 `buildChildEnv` 语义:转发环境变量,但移除凭证形态的变量(`/KEY|SECRET|TOKEN/i`),再叠加 `config.env`——因此 `PATH`、`HOME`、`TMPDIR`、locale 和代理变量保留,CLI 正常运行;只有环境中的凭证形态变量被清洗(Claude Code 的 `ANTHROPIC_API_KEY` 通过 `config.env` 显式进入),Codex key 则通过 `account/login/start` RPC 进入隔离的 `CODEX_HOME`,而非手写 `auth.json`。 +发布时,协议连接和进程句柄会移交给唯一持有者。幂等 dispose 会尽力中断已知轮次、关闭协议连接、结束 stdin、调用共享的逐级终止流程,并等待整棵进程树退出。若启动在发布前失败,`start()` 会先执行同样的清理,再以拒绝结束。 -## 权限与审批策略 +## Claude Code 提供方 -每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。 +Claude Code 同级提供方沿用相同边界:名称固定、任务自包含、仅执行一次、使用父级工作目录、结果由共享服务结算,且进程树受管。其产品专用实现将使用官方 Agent SDK 的 `query()` 与 spawn 钩子,将 SDK 协议所有权同 `dsh-subprocess` 的进程树所有权分开,不设置人机交互回调,并且仅在消息迭代器正常结束后提取严格的最终 SDK 结果。 -## StopReason 映射 +Claude 包将公开相同的两个配置项:`env` 和 `disposeGraceMs`。产品安装、原生设置和登录仍由部署方负责,插件不管理这些内容。在该同级提供方及两种产品的组合证据实现之前,本文仍处于 proposed 状态。 -Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` 为 `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 +## 证据契约 -活性姿态,明确声明:teardown 时序是配置项,轮次时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设轮次时长或启动超时——与 ACP 一致:轮次期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent 轮次持续数分钟也属合理,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 +每个产品都有包(package)级分支完备测试、一项必需的真实产品规格测试,以及一份真实 Loader 快照。真实产品层必须使用受测的确切官方发行包、非空的假产品密钥、隔离的临时工作区与产品配置,以及固定答案的环回模型;如果缺少二进制文件、认证请求、任务、答案、取消或进程退出证明中的任一项,该层必须失败而非跳过。 -## 测试 - -依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险: - -- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 -- **有密钥 e2e 测试**:每个后端的真实引擎执行并由磁盘验证真实文件工作,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。 -- **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。 +Codex 证据固定使用 `@openai/codex@0.146.0` / `codex-cli 0.146.0`。其真实产品规格测试会观测确切的 Bearer 密钥、原始任务、字节完全一致的最终答案、无人值守下命令被拒绝且不产生文件副作用、本地取消,以及每个受管句柄对应的整棵进程树均达到完全停稳。其 Loader 快照固定记录不含后台参数的工具 schema、确切的工具调用与工具结果、完整持久化的父会话、产品请求,以及清理前的完全停稳状态。该 npm 包是用于提供可复现证据的开发依赖;生产环境仍使用 `PATH` 中的 `codex`。 ## 曾考虑的替代方案 -### 为什么不用官方 `@openai/codex-sdk` 而手写客户端? +**直接向模型发起 HTTP 请求或 `codex exec`。** 这些路径会绕过产品官方的可扩展进程协议,无法证明产品配置、工具、审批、生命周期或清理。提供方改用 app-server 和官方 Agent SDK。 -dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式(wire format)极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 +**共享产品进程辅助包。** 现有 subagent seam 和子进程 seam 已经负责所有共享任务、结果、环境和进程树关注点。在两个生产消费方证明通用契约确有缺口之前,新辅助包会造成所有权重复,因此产品专用适配器直接调用现有 seam。 -### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? +**面向模型的产品选择器。** 产品可用性与认证属于部署事实。两个固定工具让各自的 schema 和提供方绑定保持显式,并避免向通用服务加入动态选择状态。 -Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个提示词 + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。 +**将产品替身作为必需证据。** 替身适合完整覆盖私有协议分支,但无法证明包导出、官方二进制文件、认证或真实进程行为。必需证据使用环回模型 fixture(测试前置数据)驱动官方产品。 -### 为什么不用登录态凭证和用户自身的配置? +**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外另立一个管理权威,并把单次执行提供方变成账户管理功能。提供方只公开显式环境叠加和清理宽限期;无人值守交互一律以失败响应。 -继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill(技能)、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。 - -### 为什么不为 Claude Code 无密钥测试注入驱动层 seam? - -注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR(Pull Request)失败,这正是门禁在发挥作用。 - -### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端? - -社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 Agent Note 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 +**续接、进度与共享父级上下文。** 首版面向用户的功能只需接收一个自包含任务,并返回一个最终答案。产品会话、恢复、后续请求、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集各自需要独立的用户契约,本提案不会预先构建这些内容。 ## 验收标准 -在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内完全停稳,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 +当两个固定工具都能通过 Loader 接入各自的真实产品,返回精确的最终答案或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制无密钥 CI 中证明受管进程树完全停稳时,本提案即告完成。两个包都具备覆盖配置、生命周期、失败、模型体验与限制的完整文档;生成的包记录、配置记录、能力记录、依赖记录和第三方记录均与已发布的 manifest(元数据清单)一致。 + +已实现的 Codex 部分已经针对其固定工具和 0.146.0 产品基线满足此契约。本文仍处于 proposed 状态,因为 Claude Code 同级提供方和两种产品的最终组合证据尚未实现。 ## 风险 -- `codex app-server` 被 CLI 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、对未知方法/通知消费而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑无密钥套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。 -- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过无密钥套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生舱口)。 -- SDK 的 optionalDependencies 每平台约 280MB——已接受,限制在单个后端包内。 -- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其实现;e2e 保留无残留进程断言。 -- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制以大声的 spawn/协议 `error` 呈现,而非版本探测。 -- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;连接池、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。 +- Codex app-server 协议随产品版本演进,可能发生变化;生产环境不执行运行时版本探测,因此每次变更受支持的基线时,都必须重新开展 schema 调查并更新真实产品兼容性证据。 +- 产品原生配置使行为取决于部署环境中安装的产品及其账户状态。强制测试会隔离这些输入,而生产环境则刻意让这些输入继续由产品自身掌控。 +- 每次委派都要承担启动全新进程和使用独立模型上下文的成本,而且只有最终文本会传回父 agent。 +- 运行失败或被取消时,产品工具或文件副作用不会回滚。 +- 无人值守模式下拒绝审批可防止初始提供方因交互而挂起,但无法满足需要新权限的任务。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..5c1eb10acf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -96,6 +96,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index b5136c31df..d2c60f23e1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: ee50e249f8e588a3f92f57db929c6bbfb1c853dd -architecture.zh.md: c2e596cd10c21be2bcaad11323277d04ef9e74a1 +architecture.md: af6c6fee0c11fcf2935956044996137367932a8b +architecture.zh.md: f399deb29efd3d64b63427c597de9f612ac37643 diff --git a/docs/architecture.md b/docs/architecture.md index ee50e249f8..af6c6fee0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,7 +167,7 @@ Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is on A swappable capability usually has **interface / implementation / consumer** layers: service/events, backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, use ACP children, or delegate one self-contained turn to a real product provider such as Codex ([subagent.md](core-data-structures/subagent.md)). `dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c2e596cd10..f399deb29e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -167,7 +167,7 @@ idle inject: 可替换功能通常具有**接口/实现/消费方**三层:服务和事件、后端、面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 -例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 +例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀、使用 ACP(Agent Client Protocol)子 agent,或将一个独立完整的轮次委派给 Codex 等真实产品提供方([subagent.md](core-data-structures/subagent.md))。 `dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3976c1fb24..44af0b5e76 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -103,6 +103,7 @@ flowchart LR pkg_bash_sandbox["bash-sandbox"] pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_codex["subagent-codex"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -223,6 +224,7 @@ flowchart LR pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents + pkg_subagent_codex --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_subprocess --> svc_subprocess @@ -311,6 +313,7 @@ flowchart LR svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp + svc_subprocess --> pkg_subagent_codex svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -370,7 +373,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -381,7 +384,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f85d39dcd7..8ac330c3f9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1519,6 +1519,25 @@ export type PermissionPolicy = 'allow' | 'reject' Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +## `@deepseek-ai/dsh-subagent-codex` + +Requires: `subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} +``` + +Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts) + ## `@deepseek-ai/dsh-subagent-dsh-sdk` Requires: `subagents` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a4c20672c0..f2438bea7a 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 07073c39f8a9b998b09b0815257d995b174c8be7 -extension-cookbook.zh.md: 10664af9a39f0a1663c316869ba8ef02f67c29fe +extension-cookbook.md: 379bd2644a4a7de005c8ea56d4857b6a6f9143b8 +extension-cookbook.zh.md: 09a5163c3b47e52e0e0e88f000a8f04302fa93e4 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 07073c39f8..379bd2644a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 10664af9a3..09a5163c3b 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d5de81fa45..d5682a47bc 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: c5fbf80ae71f99606dd86e38f06a4511b4ae4c73 -subagent.zh.md: 42c1fa7cb10863c1aa4ae975171b901207c08b85 +subagent.md: 917913470da389dccac83e455cf23486a94c23b1 +subagent.zh.md: efe12a5a5fe40d664f3071036157acd704b10c57 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c5fbf80ae7..917913470d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 42c1fa7cb1..efe12a5a5f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为四个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/module-graph.md b/docs/module-graph.md index b2a70d7ab7..35f832154b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] @@ -1004,6 +1005,12 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_sdk_protocol + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1225,6 +1232,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml new file mode 100644 index 0000000000..cc50ea2587 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -0,0 +1,42 @@ +# Test-only composition: one real Codex app-server delegation through the +# Loader, fixed provider tool, common foreground settlement, and JSONL store. +- id: fixture + name: './fixture.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY + CODEX_HOME: !!js process.cwd() + '/codex-home' + HOME: !!js process.cwd() + XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' + PATH: !!js process.env.PATH + HTTP_PROXY: '' + HTTPS_PROXY: '' + ALL_PROXY: '' + NO_PROXY: '127.0.0.1,localhost' + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Delegate the task through the fixed Codex tool.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts new file mode 100644 index 0000000000..9618c83654 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -0,0 +1,102 @@ +/** Deterministic parent model and process-quiescence observer for the Codex Loader snapshot. */ + +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + SubprocessHandle, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +const CODEX_TASK = 'Return the Loader snapshot sentinel exactly.' +const QUIESCENCE_FILE = '.codex-quiescence.json' + +function toolResultText(options: GenerateOptions): string { + return options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' +} + +class CodexDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const result = toolResultText(options) + if (result.length === 0) { + const args = JSON.stringify({ + description: 'Codex Loader snapshot', + prompt: CODEX_TASK, + }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 0, + id: CallId('call-codex-loader'), + name: 'subagent_codex', + argumentsDelta: args, + } + yield { + type: 'block-end', + index: 0, + block: { + type: 'tool-call', + id: CallId('call-codex-loader'), + name: 'subagent_codex', + arguments: args, + }, + } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `Codex child returned: ${result}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +interface ObservedProcess { + readonly spec: SubprocessSpawnSpec + readonly handle: SubprocessHandle +} + +export const name = 'codex-loader-snapshot-fixture' +export const inject = ['llm', 'subprocess'] + +/** + * Register the deterministic parent adapter and record whether every spawned + * product tree was already quiet when the assembled application disposed. + * @param ctx - Loader context supplying the LLM and subprocess seams. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CodexDelegatingAdapter()) + ctx.effect(() => { + const observed: ObservedProcess[] = [] + const originalSpawn = ctx.subprocess.spawn.bind(ctx.subprocess) + ctx.subprocess.spawn = (spec: SubprocessSpawnSpec): SubprocessHandle => { + const handle = originalSpawn(spec) + observed.push({ spec, handle }) + return handle + } + return async () => { + ctx.subprocess.spawn = originalSpawn + const alreadyExited = AbortSignal.abort() + const processes = await Promise.all(observed.map(async ({ spec, handle }) => ({ + argv: [...spec.argv], + quiescent: await handle.waitForExit(alreadyExited), + outcome: await handle.done, + }))) + await writeFile( + join(process.cwd(), QUIESCENCE_FILE), + `${JSON.stringify({ processes })}\n`, + ) + } + }, 'codex Loader snapshot process observer') +} diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json b/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json new file mode 100644 index 0000000000..f6f9b995ae --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json @@ -0,0 +1,38 @@ +{ + "stdout": { + "type": "result", + "success": true, + "sessionId": "{{sessionId}}", + "turn": 1, + "result": "Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0", + "reason": { + "kind": "completed" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 61 + } + }, + "request": { + "method": "POST", + "path": "/v1/responses", + "authorization": "Bearer dsh-fake-openai-loader-key", + "taskObserved": true + }, + "quiescence": { + "processes": [ + { + "argv": [ + "codex", + "app-server", + "--stdio" + ], + "quiescent": true, + "outcome": { + "exitCode": 0, + "signal": null + } + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl new file mode 100644 index 0000000000..e15b81ddf0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl @@ -0,0 +1,25 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Delegate through Codex once."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Delegate through Codex once.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":[{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent_codex","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}}]},"reason":"initial"}} +{"type":"request/context","seq":5,"time":0,"data":{"provider":"mock","model":"mock-delegate"}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-codex-loader","name":"subagent_codex","argumentsDelta":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}} +{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-codex-loader"},"content":[{"type":"tool-result","toolCallId":"call-codex-loader","content":[{"type":"text","text":"REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":56}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":56}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts new file mode 100644 index 0000000000..6df5b7f411 --- /dev/null +++ b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts @@ -0,0 +1,167 @@ +/** + * Real-product Loader snapshots for fixed subagent providers. + * + * PR1 owns the Codex scenario. PR2 extends this file with the sibling Claude + * Code scenario and reruns both from its final stacked candidate. + */ + +import { dirname, delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { + normalizeSessionLog, + normalizeStdout, + scrubSystemPrompts, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' +import { startResponsesFixture } from '../../../packages/subagent/subagent-codex/tests/responses-fixture.ts' + +const testsDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') +const configPath = join(fixtureDir, 'cordis.yml') +const snapshotDir = join(testsDir, 'snapshots/subagent-codex') +const sessionExpected = join(snapshotDir, 'session.expected.jsonl') +const evidenceExpected = join(snapshotDir, 'evidence.expected.json') +const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') +const repoTsconfig = join(repoRoot, 'tsconfig.json') +const codexBinDir = join( + repoRoot, + 'packages/subagent/subagent-codex/node_modules/.bin', +) +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const CODEX_SENTINEL = 'REAL_CODEX_LOADER_SENTINEL_0_146_0' +const FAKE_KEY = 'dsh-fake-openai-loader-key' + +interface PersistedSession { + readonly content: string + readonly header: { + readonly id: string + readonly cwd: string + } +} + +async function onlySession(root: string): Promise { + const paths = (await readdir(root, { recursive: true })) + .filter(path => path.endsWith('.jsonl')) + expect(paths).toHaveLength(1) + const path = paths[0] + if (path === undefined) throw new Error('Codex Loader snapshot persisted no session') + const content = await readFile(join(root, path), 'utf8') + const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as PersistedSession['header'] + return { content, header } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real product subagent providers through the Loader', () => { + it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { + const responses = await startResponsesFixture([ + { kind: 'complete', text: CODEX_SENTINEL }, + ]) + let session: PersistedSession | undefined + let quiescence: unknown + try { + const result = await runLoaderSmoke({ + label: 'Codex subagent Loader snapshot', + tempDirPrefix: 'dsh-subagent-codex-loader-', + binScript: cliBin, + configPath, + binArgs: [ + '--config', + configPath, + '--output-format', + 'json', + 'Delegate through Codex once.', + ], + tsconfigPath: repoTsconfig, + processTimeoutMs: 45_000, + env: { + DSH_TEST_OPENAI_API_KEY: FAKE_KEY, + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + }, + async prepare(cwd): Promise { + const codexHome = join(cwd, 'codex-home') + await mkdir(codexHome) + await writeFile(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${responses.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + }, + async inspect(cwd): Promise { + session = await onlySession(join(cwd, '.sessions')) + quiescence = JSON.parse(await readFile(join(cwd, '.codex-quiescence.json'), 'utf8')) + }, + }) + + expect(result.stderr).toBe('') + expect(session).toBeDefined() + if (session === undefined) throw new Error('Codex Loader snapshot session was not inspected') + const context: NormalizeContext = { + sessionIds: [session.header.id], + cwd: session.header.cwd, + } + const normalizedSession = scrubSystemPrompts(normalizeSessionLog(session.content, context)) + const request = responses.requests[0] + expect(request).toBeDefined() + if (request === undefined) throw new Error('Codex Loader snapshot made no Responses request') + const evidence = `${JSON.stringify({ + stdout: JSON.parse(normalizeStdout(result.stdout, context)) as unknown, + request: { + method: request.method, + path: request.path, + authorization: request.headers.authorization, + taskObserved: responseInputTexts(request.body) + .includes('Return the Loader snapshot sentinel exactly.'), + }, + quiescence, + }, null, 2)}\n` + + if (refreshing) { + await mkdir(snapshotDir, { recursive: true }) + await Promise.all([ + writeFile(sessionExpected, normalizedSession), + writeFile(evidenceExpected, evidence), + ]) + } + expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) + expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) + } finally { + await responses.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) +}) diff --git a/examples/package.json b/examples/package.json index 849d8fc3ff..83e20a5b2b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,9 +63,11 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", diff --git a/knip.json b/knip.json index fd941b03ca..75909cc8d3 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,7 @@ "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", @@ -540,6 +541,18 @@ "tests/**/*.ts" ] }, + "packages/subagent/subagent-codex": { + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@openai/codex" + ] + }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 360db31ade..875a9c93a7 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/README.md -README.md: f9b04b4aa80b6feacf5d0d1fa4cf6b3b2aebc211 -README.zh.md: 0afc01a00ae9089f603531345c8a3ac4dd760326 +README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 +README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd diff --git a/packages/subagent/README.md b/packages/subagent/README.md index f9b04b4aa8..abe1432d3c 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -11,11 +11,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | +| `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 0afc01a00a..3df2b6c62d 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -11,11 +11,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | +| `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml new file mode 100644 index 0000000000..bee793e09a --- /dev/null +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md +README.md: ca92f935539812351dd578dca700c9a0113dcd46 +README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md new file mode 100644 index 0000000000..ca92f93553 --- /dev/null +++ b/packages/subagent/subagent-codex/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-subagent-codex + +English | [中文](README.zh.md) + +This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. + +The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. + +The unattended provider answers command and file approvals with `decline`, answers permission requests with an empty turn-scoped permission set, and declines MCP elicitation. Any other server request fails the run instead of waiting for interaction that this provider cannot supply. + +Local cancellation wins the result race and maps to `aborted`; a remote interrupted or failed turn maps to `error`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` when the current ids are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | + +Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: package tests drive the real binary against a loopback Responses service with a non-empty fake key, and the Loader snapshot fixes the model-visible tool schema, exact tool result, persisted parent Session, original child task, authentication header, and pre-teardown process-tree quiescence. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. + +## Model Experience + +### Child request + +#### What the model sees + +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. + +#### Token effect + +The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. +- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md new file mode 100644 index 0000000000..6f6690ea51 --- /dev/null +++ b/packages/subagent/subagent-codex/README.zh.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-subagent-codex + +[English](README.md) | 中文 + +本包(package)注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 + +已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 + +无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。 + +本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | + +生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 + +#### 对 token 的影响 + +子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 +- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json new file mode 100644 index 0000000000..ea4a2a2e45 --- /dev/null +++ b/packages/subagent/subagent-codex/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-subagent-codex", + "description": "One-shot Codex subagent provider over the official app-server protocol", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@openai/codex": "0.146.0", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts new file mode 100644 index 0000000000..00fe95d817 --- /dev/null +++ b/packages/subagent/subagent-codex/src/index.ts @@ -0,0 +1,89 @@ +/** + * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh + * official `codex app-server --stdio` process in the delegating Session's + * workspace and publishes only after an ephemeral thread exists. + * + * @module @deepseek-ai/dsh-subagent-codex + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startCodexRun, + type CodexRunSpec, +} from './run.ts' + +export const name = 'subagent-codex' +export const inject = ['subagents', 'subprocess'] + +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required + +class CodexProvider implements SubagentProvider { + readonly name = 'codex' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const spec: CodexRunSpec = { + cwd: resolveChildCwd( + 'subagent-codex', + undefined, + request.parent.session.header.cwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-codex: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startCodexRun(request, spec) + } +} + +/** + * Register the fixed `codex` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-codex', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) +} diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts new file mode 100644 index 0000000000..a0c094af9c --- /dev/null +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`. + * @module @deepseek-ai/dsh-subagent-codex/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex' + +/** Cordis companion plugin name. */ +export const name = 'subagent-codex-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts new file mode 100644 index 0000000000..f58b0a6877 --- /dev/null +++ b/packages/subagent/subagent-codex/src/run.ts @@ -0,0 +1,209 @@ +/** + * One-shot Codex child lifecycle: spawn the real app-server through the + * subprocess seam, publish only after initialization and ephemeral thread + * creation, flatten post-publication failures, and dispose to whole-tree + * quiescence. + * + * @module @deepseek-ai/dsh-subagent-codex/run + */ + +import { randomUUID } from 'node:crypto' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { CodexAppServerWire } from './wire.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** Fully resolved inputs for one Codex app-server run. */ +export interface CodexRunSpec { + /** Parent Session workspace, also supplied to `thread/start`. */ + readonly cwd: string + /** Explicit deployment/test environment layered after the shared scrub. */ + readonly env: Record + /** Subprocess termination grace and final tree-exit bound. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Validate and preserve the one-shot task before crossing the process seam. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact non-empty text block sequence. + */ +export function textTask(prompt: readonly ContentBlock[]): string[] { + if (prompt.length === 0) { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-codex: the one-shot task must not be empty') + } + return texts +} + +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Close the private wire, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. + * @param wire - private app-server protocol connection. + * @param child - shared-service handle that owns the process tree. + * @param graceMs - termination grace used to bound final exit observation. + */ +export async function disposeCodexChild( + wire: CodexAppServerWire, + child: SubprocessHandle, + graceMs: number, +): Promise { + wire.close() + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + } + await child.done +} + +/** + * Start the real `codex app-server --stdio` child and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after initialization and ephemeral thread creation. + */ +export async function startCodexRun( + request: SubagentStartRequest, + spec: CodexRunSpec, +): Promise { + const texts = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + + const child = spec.spawn({ + argv: ['codex', 'app-server', '--stdio'], + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, + }) + if (child.stdin === undefined || child.stdout === undefined) { + child.terminate() + await child.waitForExit() + throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream') + } + + const wire = new CodexAppServerWire(child.stdout, child.stdin) + const disposeProcess = (): Promise => + disposeCodexChild(wire, child, spec.disposeGraceMs) + + const processFailure: Promise = child.done.then( + outcome => Promise.reject(new Error( + 'subagent-codex: app-server exited before the run settled ' + + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + )), + (error: unknown) => Promise.reject(thrown(error)), + ) + // A normal post-result dispose also closes the process. Keep that expected + // late rejection observed after the result race has already settled. + processFailure.catch(() => {}) + + const flags = { cancelled: false } + const runAbort = new AbortController() + let settleCancellation!: () => void + const cancellation = new Promise((resolve) => { settleCancellation = resolve }) + const requestCancel = (): void => { + if (flags.cancelled) return + flags.cancelled = true + runAbort.abort(new Error('subagent-codex: run cancelled locally')) + settleCancellation() + wire.interrupt() + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + try { + wire.start() + await Promise.race([wire.initialize(request.signal), processFailure]) + await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + try { + await disposeProcess() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-codex: startup failed and app-server cleanup also failed', + ) + } + if (flags.cancelled) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + throw thrown(error) + } + + const collectOutput = (): ContentBlock[] => wire.collectOutput() + const result: Promise = settleRunResult({ + attempt: () => Promise.race([ + wire.runTurn(texts, runAbort.signal, () => flags.cancelled), + processFailure, + cancellation.then((): SubagentResult => ({ + output: collectOutput(), + stopReason: 'aborted', + })), + ]), + collectOutput, + cancelled: () => flags.cancelled, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: disposeProcess, + }) +} diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts new file mode 100644 index 0000000000..e8f743d1fa --- /dev/null +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -0,0 +1,366 @@ +/** + * Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC + * transport owns framing and request correlation; this module owns only the + * product methods, current thread/turn association, unattended approval + * responses, and terminal-answer selection. + * + * @module @deepseek-ai/dsh-subagent-codex/wire + */ + +import type { Readable, Writable } from 'node:stream' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult } from '@deepseek-ai/dsh-subagent' +import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' + +type JsonObject = Record + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value as JsonObject +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed protocol and stream failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`) +} + +async function raceAbort(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + void pending.catch(() => {}) + throw abortError(signal) + } + let rejectAbort!: (error: Error) => void + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([pending, aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** + * One app-server connection and its single ephemeral thread/turn. + * + * The class deliberately exposes no generic request surface. Supporting + * another product method must first become part of the provider contract. + */ +export class CodexAppServerWire { + private readonly transport: JsonRpcLineTransport + private readonly fatal = deferred() + private threadId: string | undefined + private turnId: string | undefined + private pendingTurnId: string | undefined + private turnCompleted: Deferred | undefined + private readonly earlyTurnNotifications: Array<{ + readonly method: string + readonly params: JsonObject + }> = [] + private readonly finalAnswers: string[] = [] + private readonly unphasedAnswers: string[] = [] + private started = false + private closed = false + + constructor( + private readonly input: Readable, + output: Writable, + ) { + this.transport = new JsonRpcLineTransport(input, output) + this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) + this.transport.onNotification((method, params) => { + try { + this.handleNotification(method, params) + } catch (error: unknown) { + this.fail(thrown(error)) + } + }) + } + + /** Start reading app-server frames. */ + start(): void { + if (this.started) return + this.started = true + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + this.transport.start() + } + + /** + * Perform the required app-server initialize/initialized handshake. + * @param signal - unpublished-start cancellation. + */ + async initialize(signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('initialize', { + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }, signal), signal), 'initialize response') + string(response.userAgent, 'initialize userAgent') + this.transport.notify('initialized') + await this.guarded(this.transport.flush(), signal) + } + + /** + * Create the run's private ephemeral thread and retain its identity. + * @param cwd - parent Session workspace. + * @param signal - unpublished-start cancellation. + * @returns the app-server thread id. + */ + async startThread(cwd: string, signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('thread/start', { + cwd, + ephemeral: true, + }, signal), signal), 'thread/start response') + const thread = object(response.thread, 'thread/start thread') + const id = string(thread.id, 'thread/start thread id') + if (thread.ephemeral !== true) { + throw new Error('subagent-codex: app-server did not create an ephemeral thread') + } + this.threadId = id + return id + } + + /** + * Submit the one text-only task and wait for this thread/turn's authoritative + * terminal notification. + * @param texts - already validated task text blocks. + * @param signal - local cancellation for the published run. + * @param cancelled - whether local cancellation has already won. + * @returns the shared three-state subagent result. + */ + async runTurn( + texts: readonly string[], + signal: AbortSignal, + cancelled: () => boolean, + ): Promise { + if (this.threadId === undefined) { + throw new Error('subagent-codex: cannot start a turn before thread/start') + } + if (this.turnCompleted !== undefined) { + throw new Error('subagent-codex: this one-shot wire already started its turn') + } + const completion = deferred() + this.turnCompleted = completion + const response = object(await this.guarded(this.transport.request('turn/start', { + threadId: this.threadId, + input: texts.map(text => ({ type: 'text', text, text_elements: [] })), + }, signal), signal), 'turn/start response') + const turn = object(response.turn, 'turn/start turn') + this.commitTurnId(string(turn.id, 'turn/start turn id')) + + const completed = await this.guarded(completion.promise, signal) + if (cancelled()) return { output: this.collectOutput(), stopReason: 'aborted' } + + const terminal = object(completed.turn, 'turn/completed turn') + const status = terminal.status + if (status !== 'completed') { + const detail = status === 'failed' + ? `: ${JSON.stringify(terminal.error)}` + : '' + throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) + } + const output = this.collectOutput() + if (output.length === 0) { + throw new Error('subagent-codex: Codex completed without a final answer') + } + return { output, stopReason: 'completed' } + } + + /** + * Best-effort remote cancellation. Local settlement and process teardown + * remain authoritative when the child no longer accepts protocol requests. + */ + interrupt(): void { + if (this.threadId === undefined || this.turnId === undefined || this.closed) return + void this.transport.request('turn/interrupt', { + threadId: this.threadId, + turnId: this.turnId, + }).catch(() => {}) + } + + /** + * The best non-commentary answer observed so far, preserving exact bytes. + * @returns the selected final or nullable-phase text block, if any. + */ + collectOutput(): ContentBlock[] { + const selected = this.finalAnswers.length > 0 + ? this.finalAnswers.at(-1) + : this.unphasedAnswers.at(-1) + return selected !== undefined && selected.trim().length > 0 + ? [{ type: 'text', text: selected }] + : [] + } + + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.input.off('error', this.onInputError) + this.input.off('end', this.onInputEnd) + this.transport.close() + } + + private async guarded(pending: Promise, signal: AbortSignal): Promise { + const withFatal = Promise.race([ + pending, + this.fatal.promise.then((error): Promise => Promise.reject(error)), + ]) + return raceAbort(withFatal, signal) + } + + private fail(error: Error): void { + this.fatal.resolve(error) + } + + private readonly onInputError = (error: Error): void => { + this.fail(error) + } + + private readonly onInputEnd = (): void => { + this.fail(new Error('subagent-codex: app-server protocol stream closed')) + } + + private observePendingTurnId(id: string): void { + if (this.turnCompleted === undefined) { + throw new Error('subagent-codex: app-server referenced a turn before turn/start') + } + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: app-server referenced conflicting turns') + } + this.pendingTurnId = id + } + + private commitTurnId(id: string): void { + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: turn/start response did not match the active turn') + } + this.turnId = id + const notifications = this.earlyTurnNotifications.splice(0) + for (const notification of notifications) { + this.handleNotification(notification.method, notification.params) + } + } + + private validateRunIds(params: JsonObject, nullableTurn = false): void { + if (params.threadId !== this.threadId) { + throw new Error('subagent-codex: app-server request referenced another thread') + } + if (nullableTurn && params.turnId === null) return + const id = string(params.turnId, 'server request turn id') + if (this.turnId === undefined) { + this.observePendingTurnId(id) + return + } + if (id !== this.turnId) { + throw new Error('subagent-codex: app-server request referenced another turn') + } + } + + private handleServerRequest(method: string, params: JsonObject): Promise { + try { + switch (method) { + case 'item/commandExecution/requestApproval': + case 'item/fileChange/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ decision: 'decline' }) + case 'item/permissions/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ permissions: {}, scope: 'turn' }) + case 'mcpServer/elicitation/request': + this.validateRunIds(params, true) + return Promise.resolve({ action: 'decline', content: null, _meta: null }) + default: + throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) + } + } catch (error: unknown) { + const normalized = thrown(error) + this.fail(normalized) + return Promise.reject(normalized) + } + } + + private handleNotification(method: string, params: JsonObject): void { + if (method === 'turn/started') { + if (params.threadId !== this.threadId) return + const turn = object(params.turn, 'turn/started turn') + if (this.turnCompleted !== undefined && this.turnId === undefined) { + this.observePendingTurnId(string(turn.id, 'turn/started turn id')) + } + return + } + if (method === 'item/completed') { + if (params.threadId !== this.threadId) return + const id = string(params.turnId, 'item/completed turn id') + if (this.turnId === undefined) { + if (this.turnCompleted !== undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + } + return + } + if (id !== this.turnId) return + const item = object(params.item, 'item/completed item') + if (item.type !== 'agentMessage') return + const text = typeof item.text === 'string' + ? item.text + : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() + if (item.phase === 'final_answer') { + this.finalAnswers.push(text) + } else if (item.phase === null) { + this.unphasedAnswers.push(text) + } else if (item.phase !== 'commentary') { + throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) + } + return + } + if (method !== 'turn/completed') return + if (params.threadId !== this.threadId) return + const turn = object(params.turn, 'turn/completed turn') + const id = string(turn.id, 'turn/completed turn id') + const turnCompleted = this.turnCompleted + if (turnCompleted === undefined) return + if (this.turnId === undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + return + } + if (id !== this.turnId) return + if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { + throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) + } + turnCompleted.resolve(params) + } +} diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts new file mode 100644 index 0000000000..77c494f762 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -0,0 +1,230 @@ +import { execFile } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startResponsesFixture, + type ResponsesBehavior, + type ResponsesFixture, +} from './responses-fixture.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const fixtures: ResponsesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly env: Record + readonly workspace: string +} + +async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ + readonly harness: RealHarness + readonly fixture: ResponsesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + const fixture = await startResponsesFixture(script) + fixtures.push(fixture) + mkdirSync(workspace) + mkdirSync(codexHome) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${fixture.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + OPENAI_API_KEY: 'dsh-fake-openai-key', + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { harness: { ctx, handles, parent, env, workspace }, fixture } +} + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real @openai/codex 0.146.0 product', () => { + it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + const sentinel = 'REAL_CODEX_SENTINEL_0_146_0' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness([ + { kind: 'complete', text: sentinel }, + ]) + expect(codexPackage.version).toBe('0.146.0') + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: task }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toBe('/v1/responses') + expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key') + expect(responseInputTexts(recorded.body)).toContain(task) + await expectQuiescent(harness.handles) + }, 20_000) + + it('declines a real app-server command approval without executing the command', async () => { + const sentinel = 'REAL_CODEX_APPROVAL_DECLINED' + const { harness, fixture } = await realHarness([ + { + kind: 'functionCall', + name: 'exec_command', + arguments: { + cmd: 'touch approval-side-effect', + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + { kind: 'complete', text: sentinel }, + ]) + const sideEffect = join(harness.workspace, 'approval-side-effect') + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Attempt the fixture command.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(existsSync(sideEffect)).toBe(false) + expect(fixture.requests).toHaveLength(2) + const tools = fixture.requests[0]!.body.tools as Array> + expect(tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'function', name: 'exec_command' }), + ])) + const followup = JSON.stringify(fixture.requests[1]!.body) + expect(followup).toContain('call_fixture') + expect(followup).toContain('rejected by user') + expect(fixture.requests.every(requestEntry => + requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', + )).toBe(true) + await expectQuiescent(harness.handles) + }, 20_000) + + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const controller = new AbortController() + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Wait for cancellation.' }], + parent: harness.parent, + signal: controller.signal, + }) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + await run.dispose() + await expectQuiescent(harness.handles) + }, 20_000) +}) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts new file mode 100644 index 0000000000..940b0d52a0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -0,0 +1,283 @@ +import { createServer } from 'node:http' +import type { + IncomingHttpHeaders, + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' + +/** One request observed by the package-private Responses fixture. */ +interface RecordedResponsesRequest { + readonly method: string | undefined + readonly path: string | undefined + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Behavior consumed by one Responses request. */ +export type ResponsesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { + readonly kind: 'functionCall' + readonly name: string + readonly arguments: Record + } + | { readonly kind: 'hold' } + +/** Running package-private Responses fixture. */ +export interface ResponsesFixture { + readonly baseUrl: string + readonly requests: RecordedResponsesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function responseObject(text: string): Record { + const message = { + id: 'msg_fixture', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ + type: 'output_text', + annotations: [], + logprobs: [], + text, + }], + } + return { + id: 'resp_fixture', + object: 'response', + created_at: 1, + status: 'completed', + background: false, + error: null, + incomplete_details: null, + instructions: null, + max_output_tokens: null, + max_tool_calls: null, + model: 'fixture-model', + output: [message], + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + prompt_cache_retention: null, + reasoning: { effort: null, summary: null }, + safety_identifier: null, + service_tier: 'default', + store: false, + temperature: null, + text: { format: { type: 'text' }, verbosity: 'medium' }, + tool_choice: 'auto', + tools: [], + top_logprobs: 0, + top_p: null, + truncation: 'disabled', + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 11, + }, + user: null, + metadata: {}, + } +} + +function completeEvents(text: string): Record[] { + const completed = responseObject(text) + const message = (completed.output as Record[])[0]! + const part = (message.content as Record[])[0]! + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...message, status: 'in_progress', content: [] }, + }, + { + type: 'response.content_part.added', + item_id: message.id, + output_index: 0, + content_index: 0, + part: { ...part, text: '' }, + }, + { + type: 'response.output_text.delta', + item_id: message.id, + output_index: 0, + content_index: 0, + delta: text, + logprobs: [], + }, + { + type: 'response.output_text.done', + item_id: message.id, + output_index: 0, + content_index: 0, + text, + logprobs: [], + }, + { + type: 'response.content_part.done', + item_id: message.id, + output_index: 0, + content_index: 0, + part, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: message, + }, + { type: 'response.completed', response: completed }, + ] +} + +function functionCallEvents( + name: string, + argumentsValue: Record, +): Record[] { + const argumentsText = JSON.stringify(argumentsValue) + const item = { + id: 'fc_fixture', + type: 'function_call', + status: 'completed', + name, + arguments: argumentsText, + call_id: 'call_fixture', + } + const completed = { + ...responseObject(''), + output: [item], + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 15, + }, + } + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...item, status: 'in_progress', arguments: '' }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: item.id, + output_index: 0, + delta: argumentsText, + }, + { + type: 'response.function_call_arguments.done', + item_id: item.id, + output_index: 0, + arguments: argumentsText, + }, + { + type: 'response.output_item.done', + output_index: 0, + item, + }, + { type: 'response.completed', response: completed }, + ] +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start a loopback-only Responses SSE fixture. + * @param script - one behavior per expected Responses request. + * @returns the running fixture and its observed requests. + */ +export async function startResponsesFixture( + script: readonly ResponsesBehavior[], +): Promise { + const behaviors = [...script] + const requests: RecordedResponsesRequest[] = [] + const started = Promise.withResolvers() + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void readRequest(request).then((body) => { + requests.push({ + method: request.method, + path: request.url, + headers: request.headers, + body: JSON.parse(body) as Record, + }) + started.resolve(undefined) + const behavior = behaviors.shift() + if (behavior === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) + return + } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_fixture', + }) + if (behavior.kind === 'hold') return + const events = behavior.kind === 'complete' + ? completeEvents(behavior.text) + : functionCallEvents(behavior.name, behavior.arguments) + for (const event of events) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + }).catch((error: unknown) => { + response.destroy(error instanceof Error ? error : new Error(String(error))) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('responses fixture did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + requests, + requestStarted: started.promise, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts new file mode 100644 index 0000000000..6e6f3dbeaf --- /dev/null +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -0,0 +1,1053 @@ +import { PassThrough } from 'node:stream' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + DEFAULT_DISPOSE_GRACE_MS, + disposeCodexChild, + startCodexRun, + textTask, + type CodexRunSpec, +} from '../src/run.ts' +import { CodexAppServerWire } from '../src/wire.ts' + +type JsonObject = Record + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +class ProtocolPeer { + private buffer = '' + private readonly frames: JsonObject[] = [] + private readonly wakeups = new Set<() => void>() + + constructor( + input: PassThrough, + private readonly output: PassThrough, + ) { + input.on('data', (chunk: Buffer | string) => { + this.buffer += chunk.toString() + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject) + } + for (const wake of this.wakeups) wake() + this.wakeups.clear() + }) + } + + async next(predicate: (frame: JsonObject) => boolean): Promise { + for (;;) { + const index = this.frames.findIndex(predicate) + if (index >= 0) return this.frames.splice(index, 1)[0]! + await new Promise((resolve) => { this.wakeups.add(resolve) }) + } + } + + nextMethod(method: string): Promise { + return this.next(frame => frame.method === method) + } + + nextResponse(id: unknown): Promise { + return this.next(frame => frame.id === id && frame.method === undefined) + } + + send(...frames: readonly JsonObject[]): void { + this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`) + } + + respond(requestFrame: JsonObject, result: unknown): void { + this.send({ id: requestFrame.id, result }) + } +} + +interface FakeChildOptions { + readonly pid?: number + readonly stdin?: boolean + readonly stdout?: boolean + readonly exitOnTerminate?: boolean + readonly waitForExitResult?: boolean + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly peer: ProtocolPeer + readonly fromChild: PassThrough + readonly toChild: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: () => void + readonly waitForExit: (signal?: AbortSignal) => Promise +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const fromChild = new PassThrough() + const toChild = new PassThrough() + const peer = new ProtocolPeer(toChild, fromChild) + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (options.waitForExitResult !== undefined) { + return options.waitForExitResult + } + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin: options.stdin === false ? undefined : toChild, + stdout: options.stdout === false ? undefined : fromChild, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + peer, + fromChild, + toChild, + settle, + fail, + terminate, + waitForExit, + } +} + +function runSpec( + child: FakeChild, + overrides: Partial = {}, +): CodexRunSpec { + return { + cwd: process.cwd(), + env: {}, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: () => child.handle, + ...overrides, + } +} + +async function initializeWire(): Promise<{ + readonly child: FakeChild + readonly wire: CodexAppServerWire +}> { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + expect(await child.peer.nextMethod('initialized')).toEqual({ + jsonrpc: '2.0', + method: 'initialized', + }) + const starting = wire.startThread(process.cwd(), new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await expect(starting).resolves.toBe('thread-1') + return { child, wire } +} + +async function publishRun( + child = fakeChild(), + signal = new AbortController().signal, + specOverrides: Partial = {}, +) { + const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + const turnStart = await child.peer.nextMethod('turn/start') + return { child, run, turnStart } +} + +function agentMessage( + text: unknown, + phase: unknown, + turnId = 'turn-1', + threadId = 'thread-1', +): JsonObject { + return { + method: 'item/completed', + params: { + threadId, + turnId, + item: { type: 'agentMessage', text, phase }, + }, + } +} + +function turnCompleted( + status: unknown, + turnId = 'turn-1', + threadId = 'thread-1', + error: unknown = null, +): JsonObject { + return { + method: 'turn/completed', + params: { + threadId, + turn: { id: turnId, status, error }, + }, + } +} + +describe('task admission and package contracts', () => { + it('accepts one or more text blocks and rejects empty or non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toEqual(['one', 'two']) + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(codex, {}) + const provider = ctx.subagents.getProvider('codex')! + expect(provider).toMatchObject({ + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['codex']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(codex, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await ctx.fiber.dispose() + }) + + it('keeps the namespace export shape and package-owned empty invariant', async () => { + expect('default' in codex).toBe(false) + expect(codex.name).toBe('subagent-codex') + expect(codex.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(codex)).toBe(codex) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-codex', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-codex-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('CodexAppServerWire', () => { + it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + expect(wire.collectOutput()).toEqual([]) + wire.start() + wire.start() + + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + expect(initialize.params).toEqual({ + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + await child.peer.nextMethod('initialized') + + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + + const result = wire.runTurn( + ['first', 'second'], + new AbortController().signal, + () => false, + ) + const turnStart = await child.peer.nextMethod('turn/start') + expect(turnStart.params).toEqual({ + threadId: 'thread-1', + input: [ + { type: 'text', text: 'first', text_elements: [] }, + { type: 'text', text: 'second', text_elements: [] }, + ], + }) + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'), + agentMessage('other turn', 'final_answer', 'turn-2'), + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'reasoning', text: 'not output' }, + }, + }, + agentMessage('commentary', 'commentary'), + agentMessage('unphased', null), + agentMessage('first final', 'final_answer'), + agentMessage('last final', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'last final' }], + stopReason: 'completed', + }) + expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }]) + wire.close() + wire.close() + }) + + it('uses the last nullable-phase answer when no explicit final exists', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('first', null), + agentMessage('fallback', null), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'fallback' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects invalid handshake, thread, and turn response shapes', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + const frame = await child.peer.nextMethod('initialize') + child.peer.respond(frame, null) + await expect(pending).rejects.toThrow('invalid initialize response') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.startThread('/workspace', new AbortController().signal) + const frame = await child.peer.nextMethod('thread/start') + child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } }) + await expect(pending).rejects.toThrow('did not create an ephemeral thread') + wire.close() + } + { + const { child, wire } = await initializeWire() + const pending = wire.runTurn(['task'], new AbortController().signal, () => false) + const frame = await child.peer.nextMethod('turn/start') + child.peer.respond(frame, { turn: { id: '' } }) + await expect(pending).rejects.toThrow('turn/start turn id') + wire.close() + } + }) + + it('rejects a turn before thread publication and a second one-shot turn', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(wire.runTurn(['task'], new AbortController().signal, () => false)) + .rejects.toThrow('before thread/start') + const initialized = await initializeWire() + const first = initialized.wire.runTurn( + ['task'], + new AbortController().signal, + () => false, + ) + await initialized.child.peer.nextMethod('turn/start') + await expect(initialized.wire.runTurn( + ['again'], + new AbortController().signal, + () => false, + )).rejects.toThrow('already started') + initialized.wire.close() + await expect(first).rejects.toThrow('transport closed') + }) + + it('fails closed for empty output, malformed messages, phases, and terminal status', async () => { + const scenarios: Array<{ + readonly frames: JsonObject[] + readonly message: string + }> = [ + { + frames: [turnCompleted('completed')], + message: 'without a final answer', + }, + { + frames: [ + agentMessage('fallback', null), + agentMessage(' \n ', 'final_answer'), + turnCompleted('completed'), + ], + message: 'without a final answer', + }, + { + frames: [agentMessage(42, 'final_answer')], + message: 'invalid agent message', + }, + { + frames: [agentMessage('answer', 'future_phase')], + message: 'unknown agent message phase', + }, + { + frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })], + message: 'status failed', + }, + { + frames: [turnCompleted('interrupted')], + message: 'status interrupted', + }, + { + frames: [turnCompleted('inProgress')], + message: 'invalid terminal turn status', + }, + ] + for (const scenario of scenarios) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(...scenario.frames) + await expect(result).rejects.toThrow(scenario.message) + wire.close() + } + }) + + it('gives local cancellation precedence over a remote completed turn', async () => { + const { child, wire } = await initializeWire() + let cancelled = false + const result = wire.runTurn( + ['task'], + new AbortController().signal, + () => cancelled, + ) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + cancelled = true + child.peer.send(agentMessage('late', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'late' }], + stopReason: 'aborted', + }) + wire.close() + }) + + it('answers all four unattended request classes without granting authority', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + + child.peer.send({ + id: 'command', + method: 'item/commandExecution/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + expect(await child.peer.nextResponse('command')).toMatchObject({ + result: { decision: 'decline' }, + }) + + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + const requests = [ + { + id: 'file', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { decision: 'decline' }, + }, + { + id: 'permissions', + method: 'item/permissions/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { permissions: {}, scope: 'turn' }, + }, + { + id: 'mcp', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + result: { action: 'decline', content: null, _meta: null }, + }, + ] as const + for (const serverRequest of requests) { + child.peer.send(serverRequest) + expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ + result: serverRequest.result, + }) + } + + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + }) + + it('fails the run on unknown requests or wrong request association', async () => { + for (const serverRequest of [ + { + id: 'unknown', + method: 'item/tool/requestUserInput', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }, + { + id: 'thread', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-2', turnId: 'turn-1' }, + }, + { + id: 'turn', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-2' }, + }, + ]) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send(serverRequest) + const response = await child.peer.nextResponse(serverRequest.id) + expect(response.error).toMatchObject({ code: -32603 }) + await expect(result).rejects.toThrow() + wire.close() + } + }) + + it('rejects conflicting early turn identities before accepting output', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-early' } }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + wire.close() + }) + + it('rejects conflicting early notifications and requests before turn/start', async () => { + { + const { child, wire } = await initializeWire() + child.peer.send({ + id: 'too-early', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + const response = await child.peer.nextResponse('too-early') + expect(response.error).toMatchObject({ code: -32603 }) + wire.close() + } + { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + await child.peer.nextMethod('turn/start') + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('wrong', 'final_answer', 'turn-2'), + ) + await expect(result).rejects.toThrow('conflicting turns') + wire.close() + } + }) + + it('interrupts only an active open turn and contains remote interrupt failure', async () => { + const { child, wire } = await initializeWire() + wire.interrupt() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + wire.interrupt() + const interrupt = await child.peer.nextMethod('turn/interrupt') + expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' }) + child.peer.send({ + id: interrupt.id, + error: { code: -32000, message: 'already done' }, + }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + wire.interrupt() + }) + + it('ignores unrelated and out-of-window notifications', async () => { + const { child, wire } = await initializeWire() + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-2', turn: { id: 'turn-other' } }, + }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-before' } }, + }, + agentMessage('before', 'final_answer'), + { method: 'future/notification', params: {} }, + turnCompleted('completed'), + turnCompleted('completed', 'turn-other', 'thread-2'), + ) + await nextTask() + + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + agentMessage('wrong turn', 'final_answer', 'turn-2'), + turnCompleted('completed', 'turn-2'), + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects pending work on abort, EOF, and stream error', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + controller.abort('pre-aborted') + await expect(wire.initialize(controller.signal)) + .rejects.toThrow('app-server request aborted: pre-aborted') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + const pending = wire.initialize(controller.signal) + await child.peer.nextMethod('initialize') + controller.abort(new Error('cancel initialize')) + await expect(pending).rejects.toThrow('cancel initialize') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.end() + await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/) + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.emit('error', new Error('stdout broke')) + await expect(pending).rejects.toThrow('stdout broke') + wire.close() + } + }) +}) + +describe('run lifecycle and quiescence', () => { + it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => { + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const starting = startCodexRun( + request([{ type: 'text', text: 'task' }]), + runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }), + ) + let published = false + void starting.then(() => { published = true }) + const initialize = await child.peer.nextMethod('initialize') + expect(published).toBe(false) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + expect(published).toBe(false) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + expect(spawn).toHaveBeenCalledWith({ + argv: ['codex', 'app-server', '--stdio'], + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: DEFAULT_DISPOSE_GRACE_MS, + env: { OPENAI_API_KEY: 'fake' }, + }) + expect(run.localAgent).toBeUndefined() + + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal + await nextTask() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('settles local cancellation immediately and sends best-effort interrupt', async () => { + const controller = new AbortController() + const { child, run, turnStart } = await publishRun( + fakeChild(), + controller.signal, + ) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + controller.abort(new Error('stop')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({ + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + await run.dispose() + }) + + it('flattens child exit and protocol failures after publication', async () => { + const errors: string[] = [] + { + const child = fakeChild({ exitOnTerminate: false }) + const { run } = await publishRun(child, undefined, { + onError: (error) => { errors.push(error.message) }, + }) + child.settle({ exitCode: 9, signal: null }) + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + expect(errors.at(-1)).toContain('code 9') + await run.dispose().catch(() => {}) + } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child, undefined, { + onError: () => { throw new Error('diagnostic sink') }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.fromChild.end() + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + await run.dispose() + } + }) + + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { + const controller = new AbortController() + controller.abort() + const spawn = vi.fn() + await expect(startCodexRun( + request(undefined, controller.signal), + { + cwd: process.cwd(), + env: {}, + disposeGraceMs: 10, + spawn, + }, + )).rejects.toThrow('aborted before app-server startup') + expect(spawn).not.toHaveBeenCalled() + + const child = fakeChild() + const starting = startCodexRun(request(), runSpec(child)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: '' }) + await expect(starting).rejects.toThrow('initialize userAgent') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back an abort that wins immediately after thread creation', async () => { + const controller = new AbortController() + const child = fakeChild() + const starting = startCodexRun( + request(undefined, controller.signal), + runSpec(child), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + controller.abort('startup race') + await expect(starting).rejects.toThrow('aborted before app-server startup') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back a subprocess done rejection during startup', async () => { + const child = fakeChild({ doneError: new Error('spawn observer failed') }) + const error: unknown = await startCodexRun(request(), runSpec(child)).then( + () => undefined, + (failure: unknown) => failure, + ) + expect(error).toBeInstanceOf(AggregateError) + if (!(error instanceof AggregateError)) { + throw new Error('expected startup and rollback failures') + } + expect(error.errors).toEqual([ + expect.objectContaining({ message: 'spawn observer failed' }), + expect.objectContaining({ message: 'spawn observer failed' }), + ]) + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('reports both startup and rollback failures', async () => { + const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) + const starting = startCodexRun( + request(), + runSpec(child, { disposeGraceMs: 1 }), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: '' }) + await expect(starting).rejects.toThrow( + 'startup failed and app-server cleanup also failed', + ) + }) + + it('rejects a missing protocol stream after reaping the unpublished child', async () => { + for (const options of [{ stdin: false }, { stdout: false }]) { + const child = fakeChild(options) + await expect(startCodexRun(request(), runSpec(child))) + .rejects.toThrow('dropped a piped protocol stream') + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + } + }) + + it('keeps overlapping runs isolated', async () => { + const first = fakeChild() + const second = fakeChild() + const runs = await Promise.all([ + publishRun(first), + publishRun(second), + ]) + for (const [index, entry] of runs.entries()) { + const id = `turn-${index + 1}` + entry.child.peer.send( + { id: entry.turnStart.id, result: { turn: { id } } }, + agentMessage(`answer-${index + 1}`, 'final_answer', id), + turnCompleted('completed', id), + ) + } + const results = await Promise.all(runs.map(entry => entry.run.result)) + expect(results.map(result => result.output)).toEqual([ + [{ type: 'text', text: 'answer-1' }], + [{ type: 'text', text: 'answer-2' }], + ]) + expect(runs[0].run.id).not.toBe(runs[1].run.id) + await Promise.all(runs.map(entry => entry.run.dispose())) + }) + + it('uses the registered provider config and logs flattened errors', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { + warnings.push(String(message)) + }) as typeof ctx.logger.warn + await ctx.plugin(codex, { + env: { OPENAI_API_KEY: 'fake' }, + disposeGraceMs: 25, + }) + const starting = ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent, + signal: new AbortController().signal, + }) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + await child.peer.nextMethod('turn/start') + child.settle({ exitCode: 1, signal: null }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + env: { OPENAI_API_KEY: 'fake' }, + graceMs: 25, + cwd: process.cwd(), + })) + expect(warnings).toEqual([ + expect.stringContaining('subagent-codex: child run failed (error):'), + ]) + await run.dispose().catch(() => {}) + await ctx.fiber.dispose() + }) +}) + +describe('disposeCodexChild', () => { + it('closes stdin, terminates, and waits for the managed tree', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const end = vi.spyOn(child.toChild, 'end') + await disposeCodexChild(wire, child.handle, 100) + expect(end).toHaveBeenCalled() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('contains a concurrently closed stdin error', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + vi.spyOn(child.toChild, 'end').mockImplementation(() => { + throw new Error('already closed') + }) + await expect(disposeCodexChild(wire, child.handle, 100)) + .resolves.toBeUndefined() + }) + + it('handles a spawn-level failure with no process tree', async () => { + const child = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 100)) + .resolves.toBeUndefined() + expect(child.terminate).not.toHaveBeenCalled() + expect(child.waitForExit).not.toHaveBeenCalled() + }) + + it('fails when the tree misses the release window or done rejects', async () => { + { + const child = fakeChild({ + exitOnTerminate: false, + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 1)) + .rejects.toThrow('did not exit within its dispose window') + } + { + const child = fakeChild({ + doneError: new Error('close observer failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 1)) + .rejects.toThrow('close observer failed') + } + { + const child = fakeChild() + const handle = { ...child.handle, stdin: undefined } + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + } + }) +}) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json new file mode 100644 index 0000000000..6034bf5fbe --- /dev/null +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sdk/sdk-protocol" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index ceac4245a5..15873129cd 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: e54f0b98ec3649cec428a47026e6657a9749608b -README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 +README.md: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7 +README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e54f0b98ec..4040f9a48b 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -14,6 +14,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. | | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | +| `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | | `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 1624fa5985..5f6a041887 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -14,6 +14,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | +| `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | | `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ca290fd6..237b8296c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:* + version: link:../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -721,6 +724,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../packages/subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local @@ -4948,6 +4954,43 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-codex: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@openai/codex': + specifier: 0.146.0 + version: 0.146.0 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -7796,6 +7839,47 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@openai/codex@0.146.0': + resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.146.0-darwin-arm64': + resolution: {integrity: sha512-nb61yX4r5L6Z0dlC4o3u0GAK1YCd4TUvjaB382bajDoh84V+uv2hTBIVZ++fgXWV9yoeuNrNnNcn7GoTGOe2Tg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.146.0-darwin-x64': + resolution: {integrity: sha512-hTQR5jy/ObfTf1MDnuJCZJAe+SljKE8DDwQWN6lDFgjsPhMQz852U2tILt8Ei+G5GkQSzemHYKl2AYPwW0Y5xw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.146.0-linux-arm64': + resolution: {integrity: sha512-qiYDxkkEFnXG7joadJW6Q+XcgyDXCpGdpa9nk/c+i0gEomur1j7bHvx12NfWWCF/y8Tqri6ay+FLuC2MjdehtA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.146.0-linux-x64': + resolution: {integrity: sha512-fswvyGprAPCMiOEue/7MKMk7pCjh9kZIJfJX5i9atmfnmGYbYCcUhZsEH9LEP0+0t5xyPqDbfNXY7NSxIVuXxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.146.0-win32-arm64': + resolution: {integrity: sha512-EW6zdjDe+SLX2Iw+xymJ5+Pz2+DGexdstfFHXh4Ub+TfJsQPiMjGfZfNaoWgdJ2FsqSIzVKu2+G0KCMGYz2W8g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.146.0-win32-x64': + resolution: {integrity: sha512-b3lxMYeR0+IhstNo4JjX1P9cPc1xwVcCVkPd1lD1wpWPJ0SBhpIkPczwbu3ZRkJcdyl342+rgyf4DUrbZLdrGA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -13131,6 +13215,33 @@ snapshots: '@nodable/entities@2.2.0': {} + '@openai/codex@0.146.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.146.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.146.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.146.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.146.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.146.0-win32-x64' + + '@openai/codex@0.146.0-darwin-arm64': + optional: true + + '@openai/codex@0.146.0-darwin-x64': + optional: true + + '@openai/codex@0.146.0-linux-arm64': + optional: true + + '@openai/codex@0.146.0-linux-x64': + optional: true + + '@openai/codex@0.146.0-win32-arm64': + optional: true + + '@openai/codex@0.146.0-win32-x64': + optional: true + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 36677e120e..804dbbddf4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'], - note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], + note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..06af7e5871 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-codex" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index ddf7741716..eac84d8d20 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,6 +54,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts',