Replies: 3 comments 1 reply
|
分析很准确,我在当前 master(c389f96bf3)上核对了源,结论与你的建议完全一致,而且你提的实例级 再确认你这边的判读:
实例级
补充一个可组合性视角(可作后续,不一定本提案范围):你明确把"按调用(request-time)effort、为进程外后端声明部分 与 #4666 的关系:你区分得对——#4666 是 spawn(进程内)后端继承 |
|
我核对了
四项保留的无密钥测试调用真实 DSH provider/service/wire,确认按请求传入 最小实现可以保留 可复跑的表征测试补丁(仅测试,不修改生产源码)--- /dev/null
+++ b/packages/subagent/subagent-codex/tests/contribution-proof.spec.ts
@@ -0,0 +1,225 @@
+/**
+ * Characterization proof for #5939: Codex backend lacks instance-level
+ * reasoningEffort, and per-request agentOptions/modelSelection is rejected
+ * by the service layer. Tests exercise actual source, not implementation
+ * copies.
+ */
+
+import { PassThrough } from 'node:stream'
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it, vi } from 'vitest'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+import type {
+ SubprocessHandle,
+} from '@deepseek-ai/dsh-subprocess'
+import * as codex from '../src/index.ts'
+import {
+ DEFAULT_CODEX_PERMISSION_MODE,
+ DEFAULT_DISPOSE_GRACE_MS,
+ startCodexRun,
+ type CodexRunSpec,
+} from '../src/run.ts'
+
+type JsonObject = Record<string, unknown>
+
+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 }
+}
+
+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 nextMethod(method: string): Promise<JsonObject> {
+ for (;;) {
+ const index = this.frames.findIndex(frame => frame.method === method)
+ if (index >= 0) return this.frames.splice(index, 1)[0]!
+ await new Promise<void>((resolve) => { this.wakeups.add(resolve) })
+ }
+ }
+
+ respond(requestFrame: JsonObject, result: unknown): void {
+ const response = { id: requestFrame.id, result }
+ this.output.write(`${JSON.stringify(response)}\n`)
+ }
+
+ send(...frames: readonly JsonObject[]): void {
+ this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`)
+ }
+}
+
+interface FakeChild {
+ readonly handle: SubprocessHandle
+ readonly peer: ProtocolPeer
+ readonly fromChild: PassThrough
+ readonly toChild: PassThrough
+ readonly stderr: PassThrough
+ readonly settle: (exitCode?: number) => void
+}
+
+function fakeChild(): FakeChild {
+ const fromChild = new PassThrough()
+ const toChild = new PassThrough()
+ const stderr = new PassThrough()
+ const peer = new ProtocolPeer(toChild, fromChild)
+ let exited = false
+ let resolveDone!: (outcome: { exitCode: number; signal: null }) => void
+ const done = new Promise<{ exitCode: number; signal: null }>((resolve) => {
+ resolveDone = resolve
+ })
+ const settle = (exitCode = 0): void => {
+ if (exited) return
+ exited = true
+ resolveDone({ exitCode, signal: null })
+ }
+ const handle: SubprocessHandle = {
+ stdin: toChild,
+ stdout: fromChild,
+ stderr,
+ collected: {},
+ done,
+ terminate: vi.fn(() => { settle() }),
+ waitForExit: vi.fn(async () => {
+ await done
+ return true
+ }),
+ }
+ return { handle, peer, fromChild, toChild, stderr, settle }
+}
+
+function runSpec(
+ child: FakeChild,
+ overrides: Partial<CodexRunSpec> = {},
+): CodexRunSpec {
+ return {
+ cwd: process.cwd(),
+ permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
+ env: {},
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
+ spawn: () => child.handle,
+ ...overrides,
+ }
+}
+
+describe('#5939: Codex backend lacks instance-level reasoningEffort', () => {
+ it('advertises NO_START_CAPABILITIES with agentOptions: false', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SubagentRuntime)
+ await ctx.plugin(LocalSubprocessRuntime)
+ const fiber = await ctx.plugin(codex, {})
+ const provider = ctx.subagents.getProvider('codex')!
+ expect(provider.capabilities).toEqual({
+ agentOptions: false,
+ outputSchema: false,
+ depthLimit: false,
+ toolFilter: false,
+ persona: false,
+ })
+ expect(Object.isFrozen(provider.capabilities)).toBe(true)
+ await fiber.dispose()
+ await ctx.fiber.dispose()
+ })
+
+ it('service layer rejects agentOptions on codex provider with UNSUPPORTED_CAPABILITY', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SubagentRuntime)
+ await ctx.plugin(LocalSubprocessRuntime)
+ await ctx.plugin(codex, {})
+ await expect(ctx.subagents.start('codex', {
+ prompt: [{ type: 'text', text: 'test' }],
+ parent: fakeParent,
+ signal: new AbortController().signal,
+ agentOptions: { reasoningEffort: 'high' },
+ })).rejects.toMatchObject({
+ code: 'UNSUPPORTED_CAPABILITY',
+ message: expect.stringContaining('agentOptions'),
+ })
+ await ctx.fiber.dispose()
+ })
+
+ it('thread/start includes the configured model without a config override', async () => {
+ const child = fakeChild()
+ const signal = new AbortController().signal
+ const model = 'codex-fixed-model'
+ const starting = startCodexRun(
+ request(undefined, signal),
+ runSpec(child, { model }),
+ )
+ const initialize = await child.peer.nextMethod('initialize')
+ child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
+ await child.peer.nextMethod('initialized')
+ const threadStart = await child.peer.nextMethod('thread/start')
+ const params = threadStart.params as JsonObject
+ expect(params).toMatchObject({
+ cwd: process.cwd(),
+ ephemeral: true,
+ model,
+ })
+ // config field is NOT present in the wire request
+ expect(params).not.toHaveProperty('config')
+ child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
+ const run = await starting
+ const turnStart = await child.peer.nextMethod('turn/start')
+ child.peer.respond(turnStart, {
+ turn: { id: 'turn-1', status: 'completed' },
+ })
+ await run.dispose()
+ })
+
+ it('thread/start omits model from request when not configured', async () => {
+ const child = fakeChild()
+ const signal = new AbortController().signal
+ const starting = startCodexRun(
+ request(undefined, signal),
+ runSpec(child),
+ )
+ const initialize = await child.peer.nextMethod('initialize')
+ child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
+ await child.peer.nextMethod('initialized')
+ const threadStart = await child.peer.nextMethod('thread/start')
+ const params = threadStart.params as JsonObject
+ expect(params).not.toHaveProperty('model')
+ expect(params).not.toHaveProperty('config')
+ child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
+ const run = await starting
+ const turnStart = await child.peer.nextMethod('turn/start')
+ child.peer.respond(turnStart, {
+ turn: { id: 'turn-1', status: 'completed' },
+ })
+ await run.dispose()
+ })
+
+}) |
|
这个验证比我上一条更严谨——你是用仓库固定的 ① ② effort 覆盖的更精确落点是
所以如果要走"实例固定 effort"的最小实现,落点应该放在 ③ "feature proposal,非静默丢失 bug" + 最小实现保留 ④ 关于"未验证 effort 对成本/效果的影响":你这个 caveat 很好。建议实现时把"设置值 / 未设置 / 上游拒绝"三路径都测到,尤其上游拒 path(无效 effort 应 fail loud,保持项目"绝不先接受再静默忽略"的保证)。成本/效果层面可以留给用户按部署实测。 另外补充一个旁证:我的 |
Uh oh!
There was an error while loading. Please reload this page.
动机
进程内 subagent 后端(spawn/fork)和 DSH SDK 后端都支持
agentOptions.reasoningEffort:tool-subagent在后端声明agentOptions能力时,还会暴露模型/effort 选择字段。但 Codex 后端(
subagent-codex)没有声明任何 start capability:agentOptions时直接被拒(UNSUPPORTED_CAPABILITY),不会静默忽略;model,没有 effort 字段;thread/start只发送cwd/ephemeral/ 可选model和权限参数。也就是说,目前无法给原生 Codex 子 agent 固定一个 reasoning effort——
尽管上游协议本身是支持的。
受影响用户:通过
subagent_codex委派、想按部署固定 effort/成本的用户(例如 review 任务固定 high effort)。
上游证据
Codex app-server 的
thread/start/thread/resume接受 config override,包括
config.model_reasoning_effort(见 openai/codex app-server README,commit bb30432)。已有第三方集成按此方式传递 effort(例如 K9i-0/ccpocket)。
建议的行为
最小方案:实例级配置,与现有的固定
model字段对称——subagent-codex实例配置新增可选reasoningEffort:对该实例固定生效;省略则继承 Codex 自身设置。
thread/start以config.model_reasoning_effortoverride 发送,与现有可选
model并列。effort),保持现有的“绝不先接受再静默忽略”的保证。
按调用(request-time)effort 则需要为进程外后端声明部分
agentOptions能力,属于能力模型更大的改动,建议不在本提案范围内。
与已有讨论的区别
reasoningEffort的继承 bug;本提案针对 codex(进程外)后端的实例配置缺失,是另一条线。
All reactions