feat(subagent): add Codex product provider

This commit is contained in:
pku-xht
2026-08-04 16:02:17 +08:00
parent 4af4c10075
commit 1daa35b6e3
45 changed files with 3170 additions and 126 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -11,11 +11,12 @@ subagent子 agentseam 允许 agent智能体把工作委派给子 age
| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents` |
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACPAgent 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)。

View File

@@ -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

View File

@@ -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.

View File

@@ -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 深度强制约束。
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。

View File

@@ -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"
}
}

View File

@@ -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<string, string>
/** Grace in milliseconds for app-server process-tree termination. */
disposeGraceMs?: number
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
type ResolvedConfig = Required<Config>
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))
}

View File

@@ -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 */

View File

@@ -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<string, string>
/** 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<boolean> {
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<void> {
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<SubagentRun> {
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<void> =>
disposeCodexChild(wire, child, spec.disposeGraceMs)
const processFailure: Promise<never> = 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<void>((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<SubagentResult> = 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,
})
}

View File

@@ -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<string, unknown>
interface Deferred<T> {
readonly promise: Promise<T>
readonly resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
void pending.catch(() => {})
throw abortError(signal)
}
let rejectAbort!: (error: Error) => void
const aborted = new Promise<never>((_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<Error>()
private threadId: string | undefined
private turnId: string | undefined
private pendingTurnId: string | undefined
private turnCompleted: Deferred<JsonObject> | 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<void> {
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<string> {
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<SubagentResult> {
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<JsonObject>()
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<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
const withFatal = Promise.race([
pending,
this.fatal.promise.then((error): Promise<never> => 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<unknown> {
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)
}
}

View File

@@ -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<string, string>
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<void> {
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, unknown>): 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<string, unknown>).content
if (!Array.isArray(content)) return []
return content.flatMap((part): string[] => (
part !== null
&& typeof part === 'object'
&& typeof (part as Record<string, unknown>).text === 'string'
? [(part as Record<string, unknown>).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<Record<string, unknown>>
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)
})

View File

@@ -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<string, unknown>
}
/** Behavior consumed by one Responses request. */
export type ResponsesBehavior =
| { readonly kind: 'complete'; readonly text: string }
| {
readonly kind: 'functionCall'
readonly name: string
readonly arguments: Record<string, unknown>
}
| { readonly kind: 'hold' }
/** Running package-private Responses fixture. */
export interface ResponsesFixture {
readonly baseUrl: string
readonly requests: RecordedResponsesRequest[]
readonly requestStarted: Promise<void>
close(): Promise<void>
}
function responseObject(text: string): Record<string, unknown> {
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<string, unknown>[] {
const completed = responseObject(text)
const message = (completed.output as Record<string, unknown>[])[0]!
const part = (message.content as Record<string, unknown>[])[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<string, unknown>,
): Record<string, unknown>[] {
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<string> {
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<void> {
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<ResponsesFixture> {
const behaviors = [...script]
const requests: RecordedResponsesRequest[] = []
const started = Promise.withResolvers<undefined>()
const openResponses = new Set<ServerResponse>()
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<string, unknown>,
})
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<void>((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<void> {
for (const response of openResponses) response.destroy()
await closeServer(server)
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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. |

View File

@@ -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` | 全新的进程外 ACPAgent 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` | 子级作用域的返回通道,指向直接父级。 |