fix(subagent): finalize Codex provider composition

This commit is contained in:
pku-xht
2026-08-04 18:38:05 +08:00
parent 8a24a68333
commit c09b20f96b
31 changed files with 270 additions and 405 deletions
@@ -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-codex/README.md
README.md: ca92f935539812351dd578dca700c9a0113dcd46
README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467
README.md: ce1c66427b562c08af06320f012f28b9e125ac45
README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e
+5 -3
View File
@@ -10,9 +10,9 @@ This package registers the fixed `codex` subagent provider. Each accepted run st
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.
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
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.
Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they 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
@@ -27,6 +27,8 @@ The provider advertises no optional start-time capabilities and reports `inherit
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.
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default.
```yaml
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
@@ -45,7 +47,7 @@ Production resolves `codex` from `PATH` and uses the host's native Codex configu
## 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`.
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`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`.
## Model Experience
@@ -10,9 +10,9 @@
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。
本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
## 能力与上下文
@@ -27,6 +27,8 @@
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。
```yaml
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
@@ -45,7 +47,7 @@
## 产品兼容性与证据
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。
## 模型体验
@@ -42,6 +42,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
+27 -2
View File
@@ -39,6 +39,25 @@ function string(value: unknown, label: string): string {
return value
}
function unattendedDecision(params: JsonObject): 'cancel' | 'decline' {
const available = params.availableDecisions
if (available === undefined || available === null) return 'decline'
if (Array.isArray(available)) {
if (available.includes('cancel')) return 'cancel'
if (available.includes('decline')) return 'decline'
}
throw new Error('subagent-codex: app-server offered no unattended approval decision')
}
function isContextWindowExceeded(turn: JsonObject): boolean {
if (turn.status !== 'failed') return false
const error = turn.error
return error !== null
&& typeof error === 'object'
&& !Array.isArray(error)
&& (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
}
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))
@@ -160,7 +179,7 @@ export class CodexAppServerWire {
* @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.
* @returns the shared subagent result.
*/
async runTurn(
texts: readonly string[],
@@ -182,6 +201,9 @@ export class CodexAppServerWire {
const terminal = object(completed.turn, 'turn/completed turn')
const status = terminal.status
if (isContextWindowExceeded(terminal)) {
return { output: this.collectOutput(), stopReason: 'max-tokens' }
}
if (status !== 'completed') {
const detail = status === 'failed'
? `: ${JSON.stringify(terminal.error)}`
@@ -292,10 +314,13 @@ export class CodexAppServerWire {
case 'item/commandExecution/requestApproval':
case 'item/fileChange/requestApproval':
this.validateRunIds(params)
return Promise.resolve({ decision: 'decline' })
return Promise.resolve({ decision: unattendedDecision(params) })
case 'item/permissions/requestApproval':
this.validateRunIds(params)
return Promise.resolve({ permissions: {}, scope: 'turn' })
case 'item/tool/requestUserInput':
this.validateRunIds(params)
return Promise.resolve({ answers: {} })
case 'mcpServer/elicitation/request':
this.validateRunIds(params, true)
return Promise.resolve({ action: 'decline', content: null, _meta: null })
@@ -0,0 +1,53 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
LOADER_SMOKE_TEST_TIMEOUT_MS,
runLoaderSmoke,
} from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-codex/',
import.meta.url,
))
const driver = join(fixtureDir, 'driver.ts')
const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('Codex provider public Loader composition', () => {
it('loads the opt-in package and foreground tool without starting Codex', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'subagent-codex Loader composition',
tempDirPrefix: 'dsh-subagent-codex-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: {
// Loading the optional package must not probe or start a Codex binary.
PATH: '',
},
})
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
providers: ['codex'],
provider: {
name: 'codex',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
tool: {
name: 'subagent_codex',
parameterNames: ['description', 'prompt'],
required: ['description', 'prompt'],
},
starts: 0,
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -172,8 +172,7 @@ describe('real @openai/codex 0.146.0 product', () => {
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'
it('cancels a real app-server command approval without executing the command', async () => {
const { harness, fixture } = await realHarness([
{
kind: 'functionCall',
@@ -184,7 +183,6 @@ describe('real @openai/codex 0.146.0 product', () => {
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', {
@@ -193,20 +191,17 @@ describe('real @openai/codex 0.146.0 product', () => {
signal: new AbortController().signal,
})
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: sentinel }],
stopReason: 'completed',
output: [],
stopReason: 'error',
})
await run.dispose()
expect(existsSync(sideEffect)).toBe(false)
expect(fixture.requests).toHaveLength(2)
expect(fixture.requests).toHaveLength(1)
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)
@@ -417,6 +417,25 @@ describe('CodexAppServerWire', () => {
wire.close()
})
it('maps only an explicit context-window failure to max-tokens', 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('partial answer', null),
turnCompleted('failed', 'turn-1', 'thread-1', {
message: 'too much context',
codexErrorInfo: 'contextWindowExceeded',
}),
)
await expect(result).resolves.toEqual({
output: [{ type: 'text', text: 'partial answer' }],
stopReason: 'max-tokens',
})
wire.close()
})
it('rejects invalid handshake, thread, and turn response shapes', async () => {
{
const child = fakeChild()
@@ -516,7 +535,7 @@ describe('CodexAppServerWire', () => {
wire.close()
})
it('answers all four unattended request classes without granting authority', async () => {
it('answers all five 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')
@@ -524,10 +543,14 @@ describe('CodexAppServerWire', () => {
child.peer.send({
id: 'command',
method: 'item/commandExecution/requestApproval',
params: { threadId: 'thread-1', turnId: 'turn-1' },
params: {
threadId: 'thread-1',
turnId: 'turn-1',
availableDecisions: ['decline', 'cancel'],
},
})
expect(await child.peer.nextResponse('command')).toMatchObject({
result: { decision: 'decline' },
result: { decision: 'cancel' },
})
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
@@ -536,6 +559,16 @@ describe('CodexAppServerWire', () => {
{
id: 'file',
method: 'item/fileChange/requestApproval',
params: {
threadId: 'thread-1',
turnId: 'turn-1',
availableDecisions: ['decline'],
},
result: { decision: 'decline' },
},
{
id: 'file-default',
method: 'item/fileChange/requestApproval',
params: { threadId: 'thread-1', turnId: 'turn-1' },
result: { decision: 'decline' },
},
@@ -545,6 +578,12 @@ describe('CodexAppServerWire', () => {
params: { threadId: 'thread-1', turnId: 'turn-1' },
result: { permissions: {}, scope: 'turn' },
},
{
id: 'user-input',
method: 'item/tool/requestUserInput',
params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
result: { answers: {} },
},
{
id: 'mcp',
method: 'mcpServer/elicitation/request',
@@ -568,9 +607,27 @@ describe('CodexAppServerWire', () => {
for (const serverRequest of [
{
id: 'unknown',
method: 'item/tool/requestUserInput',
method: 'future/request',
params: { threadId: 'thread-1', turnId: 'turn-1' },
},
{
id: 'approval',
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thread-1',
turnId: 'turn-1',
availableDecisions: ['accept'],
},
},
{
id: 'malformed-approval',
method: 'item/fileChange/requestApproval',
params: {
threadId: 'thread-1',
turnId: 'turn-1',
availableDecisions: 'decline',
},
},
{
id: 'thread',
method: 'item/fileChange/requestApproval',
+2 -2
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: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7
README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413
README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7
README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15
+1
View File
@@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing
| `@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-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (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. |
+1
View File
@@ -15,6 +15,7 @@ subagent seam 允许一个 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-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 |
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
| `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 |