Merge remote-tracking branch 'origin/master' into fix/subagent-empty-terminal-message-output
# Conflicts: # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/subsystems/subagent.i18n.yaml # docs/subsystems/subagent.md # docs/subsystems/subagent.zh.md
This commit is contained in:
@@ -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-inprocess/README.md
|
||||
README.md: fd5129b044b3d9008c3ba73645f6de36ddbf35dc
|
||||
README.zh.md: 8f7ff137f04183b50acbe96bcd20a4023dfb86f4
|
||||
README.md: 69def8bf8f41e3685d017ac4b003b26a37f064ef
|
||||
README.zh.md: bf5e7cb5cc8517ee7020695ef10e3b58d613541b
|
||||
|
||||
@@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p
|
||||
|
||||
This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output.
|
||||
|
||||
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
|
||||
The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。
|
||||
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
|
||||
@@ -28,22 +28,12 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-sandbox-policy": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-user-approval": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "^1.0.4",
|
||||
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",
|
||||
|
||||
@@ -17,8 +17,10 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
appendDelegatedPolicyOverrides,
|
||||
applyChildComposition,
|
||||
assertSubagentMaxDepth,
|
||||
captureDelegatedPolicyOverrides,
|
||||
childSessionMeta,
|
||||
finalAssistantOutput,
|
||||
resolveChildAgentOptions,
|
||||
@@ -31,11 +33,6 @@ import type {
|
||||
SubagentRun,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
// to the policy services when composed — the driver consumes both
|
||||
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import {
|
||||
attachStructuredRuntime,
|
||||
type StructuredAttachment,
|
||||
@@ -112,20 +109,11 @@ export async function startInProcessRun(
|
||||
|
||||
// Capture before the first await: a later parent switch belongs to the
|
||||
// parent's future.
|
||||
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
|
||||
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
|
||||
const inherited = captureDelegatedPolicyOverrides(parent)
|
||||
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
// Inherited overrides land on the child's own log, so its effective policy
|
||||
// is reconstructable from that log alone.
|
||||
const childSession = (childCtx.agent as Agent).session
|
||||
if (inheritedMode !== undefined) {
|
||||
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
|
||||
}
|
||||
if (inheritedPolicy !== undefined) {
|
||||
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
|
||||
}
|
||||
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
|
||||
applyChildComposition(childCtx, parent, {
|
||||
persona: request.persona,
|
||||
toolFilter: request.toolFilter,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/** Policy inheritance through child session events appended before publication. */
|
||||
/**
|
||||
* Delegation policy through child session events appended before publication:
|
||||
* the parent's sandbox override plus the pinned `approval/policy: never`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
|
||||
@@ -13,7 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
@@ -76,12 +79,13 @@ function toolResultTexts(agent: Agent): string[] {
|
||||
}
|
||||
|
||||
describe('in-process policy inheritance', () => {
|
||||
it('records parent overrides before publishing a spawn child', async () => {
|
||||
it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
|
||||
const script: Script = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
const blocked = join(workspace, 'spawn-blocked.txt')
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
setApprovalPolicy(parent.session, 'never')
|
||||
// No parent approval override: the child pin must not depend on one.
|
||||
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
|
||||
const parentLogLength = parent.session.events.length
|
||||
script.push(
|
||||
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
|
||||
@@ -120,7 +124,10 @@ describe('in-process policy inheritance', () => {
|
||||
.join('\n')
|
||||
expect(contextText).toContain('Current DSH file policy: read-only')
|
||||
expect(contextText).toContain('Approval prompts are disabled')
|
||||
// The statement rides runtime context; the system prompt stays uniform.
|
||||
expect(contextText).toContain('You are a delegated subagent')
|
||||
expect(request.data.header.system).not.toContain('Approval prompts are disabled')
|
||||
expect(request.data.header.system).not.toContain('You are a delegated subagent')
|
||||
expect(parent.session.events).toHaveLength(parentLogLength)
|
||||
} finally {
|
||||
await run.dispose()
|
||||
@@ -179,7 +186,7 @@ describe('in-process policy inheritance', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not freeze deployment defaults into an unswitched child', async () => {
|
||||
it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
|
||||
const script: Script = []
|
||||
const { parent } = await setupWalled(script)
|
||||
const allowed = join(workspace, 'default-allowed.txt')
|
||||
@@ -193,12 +200,56 @@ describe('in-process policy inheritance', () => {
|
||||
await run.result
|
||||
const child = run.localAgent as Agent
|
||||
expect(await readFile(allowed, 'utf8')).toBe('fine')
|
||||
expect(child.session.events.some(
|
||||
event => event.type === 'sandbox/mode' || event.type === 'approval/policy',
|
||||
)).toBe(false)
|
||||
expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false)
|
||||
expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([
|
||||
{ seq: 0, data: { policy: 'never', source: 'delegation' } },
|
||||
])
|
||||
expect(child.session.firstLiveSeq).toBe(0)
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
|
||||
const script: Script = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
// A granting answerer proves the pin resolves before any answerer runs.
|
||||
let consulted = false
|
||||
ctx.on('approval/request', () => {
|
||||
consulted = true
|
||||
return Promise.resolve('allowed-once' as const)
|
||||
})
|
||||
const blocked = join(workspace, 'escalation-blocked.txt')
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
script.push(
|
||||
toolCallResponse('write', 'write', {
|
||||
file_path: blocked,
|
||||
content: 'escaped',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'test escalation from a delegated child',
|
||||
}),
|
||||
textResponse('child done'),
|
||||
)
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
try {
|
||||
await run.result
|
||||
const child = run.localAgent as Agent
|
||||
|
||||
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(consulted).toBe(false)
|
||||
expect(toolResultTexts(child).join('\n'))
|
||||
.toContain('the user rejected escalating this operation to "workspace-write"')
|
||||
const asked = child.session.events.find(
|
||||
(event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
|
||||
)
|
||||
const decided = child.session.events.find(
|
||||
(event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
|
||||
)
|
||||
expect(asked?.data.toolName).toBe('write')
|
||||
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -247,10 +247,10 @@ describe('in-process structured output', () => {
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.structured).toBeUndefined()
|
||||
// Exactly one model request and one user message: no nudge turn exists.
|
||||
// Exactly one model request and one caller-supplied user message: no nudge turn exists.
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1)
|
||||
expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -32,14 +32,8 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user