fix(subagent-claude-code): tighten provider evidence
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md
|
||||||
2026-06-21-subagent-capability-seam.md: 35fe7b7aaf02d9d55012e3285b3f5a58bc76cde8
|
2026-06-21-subagent-capability-seam.md: 752e639b09ea2ac0ba19841ddfe38b15b44d22e9
|
||||||
2026-06-21-subagent-capability-seam.zh.md: 221335859cec104a55136201e4923d783d616e86
|
2026-06-21-subagent-capability-seam.zh.md: 6009aeda6773357a4217f8956fb510c2116bbe3f
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag
|
|||||||
|
|
||||||
### Child isolation and the parent log
|
### Child isolation and the parent log
|
||||||
|
|
||||||
Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic.
|
Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log.
|
||||||
|
|
||||||
### Synchronous collect (first cut)
|
### Synchronous collect (first cut)
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在
|
|||||||
|
|
||||||
### 子 agent 隔离与父日志
|
### 子 agent 隔离与父日志
|
||||||
|
|
||||||
每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,绝不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关。
|
每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。
|
||||||
|
|
||||||
### 同步收集(首版)
|
### 同步收集(首版)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
# Third-Party Notices
|
# Third-Party Notices
|
||||||
|
|
||||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||||
|
|
||||||
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
|
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md
|
# pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md
|
||||||
README.md: 5bfc8ff3ba539b6caf891577cee5820d983106cd
|
README.md: e62f60fceea16749296a91377785b81d94d751ca
|
||||||
README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5
|
README.zh.md: e171524157b2b1696df31753816210d41637a911
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
|
|||||||
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
|
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
|
||||||
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
|
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
|
||||||
|
|
||||||
Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
|
Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited 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_claude_code` by default.
|
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
|||||||
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
|
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
|
||||||
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
|
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
|
||||||
|
|
||||||
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。
|
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。
|
||||||
|
|
||||||
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。
|
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。
|
||||||
|
|
||||||
|
|||||||
@@ -60,11 +60,17 @@ class ClaudeCodeProvider implements SubagentProvider {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
start(request: ResolvedSubagentStartRequest) {
|
start(request: ResolvedSubagentStartRequest) {
|
||||||
|
const parentCwd = request.parent.session.header.cwd
|
||||||
|
if (parentCwd === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
|
||||||
|
)
|
||||||
|
}
|
||||||
const spec: ClaudeCodeRunSpec = {
|
const spec: ClaudeCodeRunSpec = {
|
||||||
cwd: resolveChildCwd(
|
cwd: resolveChildCwd(
|
||||||
'subagent-claude-code',
|
'subagent-claude-code',
|
||||||
undefined,
|
undefined,
|
||||||
request.parent.session.header.cwd,
|
parentCwd,
|
||||||
),
|
),
|
||||||
env: this.config.env,
|
env: this.config.env,
|
||||||
disposeGraceMs: this.config.disposeGraceMs,
|
disposeGraceMs: this.config.disposeGraceMs,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function startMessagesFixture(
|
|||||||
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||||
request.on('end', () => {
|
request.on('end', () => {
|
||||||
const path = request.url ?? ''
|
const path = request.url ?? ''
|
||||||
if (!path.startsWith('/v1/messages')) {
|
if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) {
|
||||||
response.writeHead(404, { 'content-type': 'application/json' })
|
response.writeHead(404, { 'content-type': 'application/json' })
|
||||||
response.end(JSON.stringify({
|
response.end(JSON.stringify({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { tmpdir } from 'node:os'
|
|||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
|
import type {
|
||||||
|
Query,
|
||||||
|
SDKMessage,
|
||||||
|
SDKSystemMessage,
|
||||||
|
} from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
@@ -23,6 +28,39 @@ import {
|
|||||||
type MessagesFixture,
|
type MessagesFixture,
|
||||||
} from './messages-fixture.ts'
|
} from './messages-fixture.ts'
|
||||||
|
|
||||||
|
const observedSdkMessages = vi.hoisted((): SDKMessage[] => [])
|
||||||
|
|
||||||
|
vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<
|
||||||
|
typeof import('@anthropic-ai/claude-agent-sdk')
|
||||||
|
>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
query(options: Parameters<typeof actual.query>[0]): Query {
|
||||||
|
const query = actual.query(options)
|
||||||
|
// Observe the real SDK stream without replacing its protocol or CLI.
|
||||||
|
return new Proxy(query, {
|
||||||
|
get(target, property) {
|
||||||
|
if (property === Symbol.asyncIterator) {
|
||||||
|
return async function* (): AsyncGenerator<SDKMessage, void> {
|
||||||
|
for await (const message of target) {
|
||||||
|
observedSdkMessages.push(message)
|
||||||
|
yield message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const value: unknown = Reflect.get(target, property, target)
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
const method = value as (...args: unknown[]) => unknown
|
||||||
|
return method.bind(target)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
const sdkRoot = dirname(fileURLToPath(
|
const sdkRoot = dirname(fileURLToPath(
|
||||||
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
|
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
|
||||||
@@ -54,6 +92,7 @@ afterEach(async () => {
|
|||||||
for (const root of roots.splice(0)) {
|
for (const root of roots.splice(0)) {
|
||||||
rmSync(root, { recursive: true, force: true })
|
rmSync(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
observedSdkMessages.length = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
interface RealHarness {
|
interface RealHarness {
|
||||||
@@ -168,10 +207,16 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
|
|||||||
})
|
})
|
||||||
await run.dispose()
|
await run.dispose()
|
||||||
|
|
||||||
|
const initMessage = observedSdkMessages.find(
|
||||||
|
(message): message is SDKSystemMessage =>
|
||||||
|
message.type === 'system' && message.subtype === 'init',
|
||||||
|
)
|
||||||
|
expect(initMessage?.claude_code_version).toBe('2.1.220')
|
||||||
|
|
||||||
expect(fixture.requests).toHaveLength(1)
|
expect(fixture.requests).toHaveLength(1)
|
||||||
const recorded = fixture.requests[0]!
|
const recorded = fixture.requests[0]!
|
||||||
expect(recorded.method).toBe('POST')
|
expect(recorded.method).toBe('POST')
|
||||||
expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/)
|
expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/)
|
||||||
expect(recorded.headers['x-api-key']).toBe(fakeKey)
|
expect(recorded.headers['x-api-key']).toBe(fakeKey)
|
||||||
expect(recorded.body.model).toBe(settingsModel)
|
expect(recorded.body.model).toBe(settingsModel)
|
||||||
expect(Array.isArray(recorded.body.messages)).toBe(true)
|
expect(Array.isArray(recorded.body.messages)).toBe(true)
|
||||||
|
|||||||
@@ -341,6 +341,17 @@ describe('task admission and package contracts', () => {
|
|||||||
disposeGraceMs: 29,
|
disposeGraceMs: 29,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await expect(ctx.subagents.start('claude-code', {
|
||||||
|
...request(),
|
||||||
|
parent: {
|
||||||
|
id: 'parent-without-cwd',
|
||||||
|
session: { header: {} },
|
||||||
|
} as unknown as Agent,
|
||||||
|
})).rejects.toThrow(
|
||||||
|
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
|
||||||
|
)
|
||||||
|
expect(queryMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
const run = await ctx.subagents.start('claude-code', request())
|
const run = await ctx.subagents.start('claude-code', request())
|
||||||
child.settle({ exitCode: 9, signal: null })
|
child.settle({ exitCode: 9, signal: null })
|
||||||
child.stdout.end()
|
child.stdout.end()
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ describe('THIRD_PARTY_NOTICES.md', () => {
|
|||||||
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
|
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
|
||||||
// this assertion means the notices were committed without that hook.
|
// this assertion means the notices were committed without that hook.
|
||||||
it('matches what the generator produces from the current manifests', () => {
|
it('matches what the generator produces from the current manifests', () => {
|
||||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
|
const generated = render()
|
||||||
|
expect(generated).toContain('It depends on the third-party software listed below.')
|
||||||
|
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export function render(): string {
|
|||||||
|
|
||||||
# Third-Party Notices
|
# Third-Party Notices
|
||||||
|
|
||||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||||
|
|
||||||
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user