fix(subagent): pin delegated child approvals to 'never' within the inherited sandbox scope

A delegated in-process child now acts only within the sandbox scope fixed
at delegation: captureDelegatedPolicyOverrides still snapshots the parent's
explicit sandbox override but pins the child approval policy to 'never'
(instead of inheriting the parent's), so every child ask — sandbox_permissions
escalations included — is rejected deterministically by ApprovalService
before any answerer, with the audit pair still logged. Every in-process
child additionally receives the scoped subagent:delegation runtime-context
statement telling it to report a scope limitation instead of retrying.

Supersedes the approval half of the policy-inheritance decision (new Agent
Note cross-linked from both prior notes and the approval-seam Q&A); refreshed
child snapshot fixtures carry the pinned event, and
subagent-published-run-failure now persists a one-event child log.
This commit is contained in:
Hypatia May
2026-08-10 19:17:37 +08:00
parent 1ac58714d1
commit 501c3a8ab6
61 changed files with 781 additions and 550 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/subagent-inprocess/README.md
README.md: 4189979806ccd4e7dcfee231ab3e9e2331550b0d
README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32
README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0
README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14

View File

@@ -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.
The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. 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

View File

@@ -20,7 +20,7 @@
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.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)。
## 取消与所有权

View File

@@ -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,14 @@ 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')
// The parent keeps the interactive deployment default: the child pin must
// not depend on any parent approval override.
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 +125,11 @@ describe('in-process policy inheritance', () => {
.join('\n')
expect(contextText).toContain('Current DSH file policy: read-only')
expect(contextText).toContain('Approval prompts are disabled')
// The delegation-scope statement is a runtime-context fact, so the
// deployment system prompt stays uniform across parents and children.
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 +188,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 +202,58 @@ 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 root answerer that would GRANT: the pinned 'never' must resolve
// before any answerer is consulted, so this never runs for the child.
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"')
// The deterministic rejection still leaves the full audit pair on the child log.
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()
}
})
})

View File

@@ -247,10 +247,11 @@ 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 (the
// delegation runtime-context snapshot aside): 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()
})

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: 6cea175de3d07290b293ad55ccbe60d7d918d37c
README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c
README.md: 30ecd187b08cd3d098ce791535914f80e4be9aed
README.zh.md: 5e32a74469a67e02a0eb927c368080768e27d508

View File

@@ -52,9 +52,9 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority.
## Delegated policy inheritance
## Delegated policy
Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes.
Both in-process delegation paths fix the child's permission scope at the delegation boundary through the shared child-agent helpers. `captureDelegatedPolicyOverrides(parent)` snapshots the parent session's explicit sandbox override (`sandboxPolicy.overrideOf()`) and pins the child's approval policy to `'never'` whenever the approval capability is composed — regardless of the parent's own policy — so a delegated child acts only within its inherited sandbox scope and every ask (for example a `sandbox_permissions` escalation) is rejected deterministically instead of waiting on a prompt no one is watching (both services are optional `ctx.get` consumers). `appendDelegatedPolicyOverrides()` writes each value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state and the child's effective policy stays reconstructable from its log alone. The sandbox deployment default is never copied: an unswitched parent stamps no `sandbox/mode` and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. Every in-process child also receives a scoped runtime-context statement (`subagent:delegation`) telling it the scope is fixed and that a task needing wider access ends with a reported limitation, not retries. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) delegation-policy Agent Notes.
## One-shot ownership and lifecycle
@@ -96,11 +96,25 @@ Continuable Activations await a best-effort final session flush without treating
## Model Experience
Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes.
### Child delegation-scope statement
#### What the model sees
Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`).
##### The delegation-scope statement
```markdown
You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.
```
#### Token effect
One fixed statement in each child's runtime-context snapshot; none in the parent's requests.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
Prefix-stable within a child: the statement never changes during the child's lifetime, so it is written once into the first runtime-context snapshot. Parent-side, no direct invalidation; the named tool consumers own any request-prefix changes.
## Known Limitations and Deferred Work

View File

@@ -52,9 +52,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
## 委派策略继承
## 委派策略
两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent:`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()` 与 `approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。
两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 对父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)获取快照,并在审批能力已组合时把子 agent 的审批策略钉定为 `'never'`——无论父级自身的策略是什么——因此被委派的子 agent 只在其继承的沙箱范围内行动,每次请求(例如一次 `sandbox_permissions` 升级)都被确定性拒绝,而不是等待一个无人在看的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇委派策略 Agent Note。
## 一次性所有权与生命周期
@@ -96,11 +96,25 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 模型体验
通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。
### 子级委派范围声明
#### 模型看到的内容
每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。
##### 委派范围声明
```markdown
You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.
```
#### Token 影响
每个子 agent 的运行时上下文快照中一条固定声明;父级请求中没有任何新增。
#### KV Cache 影响
不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。
子级内部前缀稳定:该声明在子 agent 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。
## 已知限制与暂缓事项

View File

@@ -1,9 +1,9 @@
/**
* Shared in-process child composition: the delegation-depth budget, the
* durable session metadata, the resolved child `AgentOptions`, the delegated
* policy snapshot, and the scoped setup a child agent needs. Both the one-shot
* policy seed, and the scoped setup a child agent needs. Both the one-shot
* provider driver and the continuation manager compose children this way, so
* depth accounting, lineage stamping, and policy inheritance have one home.
* depth accounting, lineage stamping, and delegation policy have one home.
*
* @module @deepseek-ai/dsh-subagent/child-agent
*/
@@ -13,12 +13,10 @@ import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-a
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — delegation consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
// The user-approval side stays an explicit empty import so its augmentation
// does not ride the `ApprovalPolicy` import above.
// opportunistically (the documented `ctx.get` pattern), never as a hard dep —
// and merge the `sandbox/mode` / `approval/policy` session-event payloads.
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import { delegationDepthOf } from './depth.ts'
@@ -115,51 +113,80 @@ export interface ChildComposition {
}
/**
* Apply one child's scoped composition inside its creation window: a shadowing
* persona section and a tool restriction, both owned by the child's scope and
* therefore invisible to its parent and siblings.
* Model-facing statement every in-process child receives: the permission
* scope is fixed at delegation and approval prompts are unavailable, so the
* child reports a scope limitation instead of retrying denied operations.
* A runtime-context contribution (not a system-prompt section) because it is
* a per-session fact: the deployment's system prompt stays uniform across
* parents and children, and the statement joins the same durable snapshot
* that carries the sandbox-policy and approval-policy sentences.
*/
export const SUBAGENT_DELEGATION_CONTEXT
= 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be '
+ 'widened from inside this session — operations that require approval are rejected automatically. '
+ 'When the task needs access beyond that scope, do not retry the denied operation; state the '
+ 'limitation in your reply so the delegating agent can handle it.'
/**
* Apply one child's scoped composition inside its creation window: the fixed
* delegation-scope statement, a shadowing persona section, and a tool
* restriction, all owned by the child's scope and therefore invisible to its
* parent and siblings. Both creation and cold resume pass through here, so a
* resumed child keeps the same statement.
* @param childCtx - the child agent's scoped creation context.
* @param composition - the persona and tool filter to install.
*/
export function applyChildComposition(childCtx: Context, composition: ChildComposition): void {
// After sandbox:policy (110) and approval:policy (115): scope, then policy,
// then what a delegated child does about a denial.
childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT })
if (composition.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona })
}
if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
}
/** Parent-session policy overrides captured at the delegation boundary. */
/** Policy seeded onto a child session's log at the delegation boundary. */
export interface DelegatedPolicyOverrides {
/** The parent session's explicit sandbox-mode override, or `undefined` without one. */
readonly sandboxMode: SandboxMode | undefined
/** The parent session's explicit approval-policy override, or `undefined` without one. */
readonly approvalPolicy: ApprovalPolicy | undefined
/**
* The child's pinned approval policy, or `undefined` when no approval
* capability is composed. Always `'never'` with one composed: a delegated
* child acts only within the sandbox scope fixed at delegation, so the
* composed `ApprovalService` rejects every child ask deterministically
* instead of waiting on a prompt no one is watching.
*/
readonly approvalPolicy: 'never' | undefined
}
/**
* Capture the parent session's explicit policy overrides for one delegation.
* Call synchronously before the child start's first await: a later parent
* switch belongs to the parent's future, not to this child. Deployment
* defaults and one-shot grants are never captured, so an unswitched parent
* leaves the child following the deployment default dynamically.
* Capture the policy to seed into one delegation. Call synchronously before
* the child start's first await: a later parent switch belongs to the
* parent's future, not to this child. The sandbox scope is the parent
* session's explicit override — deployment defaults and one-shot grants are
* never captured, so an unswitched parent leaves the child following the
* deployment default dynamically. The approval policy is never inherited: it
* is pinned to `'never'` whenever the approval capability is composed,
* regardless of the parent's own policy.
* @param parent - the delegating parent agent.
* @returns the overrides to seed into the child, each `undefined` without one.
* @returns the sandbox override (or `undefined` without one) and the approval pin.
*/
export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
return {
sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session),
approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',
}
}
/**
* Append captured parent overrides onto the child's own log as
* Append the captured delegation policy onto the child's own log as
* `source: 'delegation'` events inside the unpublished creation window, so the
* child's effective policy is reconstructable from its log alone. Appends land
* after any fork seed, so fresh policy wins stale seed state; later child
* switches still win over these events.
* @param childSession - the unpublished child's session.
* @param overrides - the overrides captured at delegation.
* @param overrides - the policy captured at delegation.
*/
export function appendDelegatedPolicyOverrides(
childSession: Session,

View File

@@ -214,8 +214,8 @@ interface MaterializeInputs {
create?: {
seed: readonly SessionEvent[]
meta: NonNullable<CreateAgentOptions['meta']>
/** Parent policy overrides captured at the delegation boundary. */
inheritedPolicies: DelegatedPolicyOverrides
/** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */
delegatedPolicies: DelegatedPolicyOverrides
}
agentOptions: AgentOptions
composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
@@ -355,7 +355,7 @@ export class SubagentContinuationManager {
})
// Capture before the first await: a later parent switch belongs to the
// parent's future, not to this child.
const inheritedPolicies = captureDelegatedPolicyOverrides(parent)
const delegatedPolicies = captureDelegatedPolicyOverrides(parent)
const prepared = await this.host.prepareContinuable(spec.provider, {
sessionId: childId,
@@ -372,7 +372,7 @@ export class SubagentContinuationManager {
childId,
provider: spec.provider,
parent,
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies },
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies },
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
composition: { persona: request.persona, toolFilter: request.toolFilter },
signal: spec.signal,
@@ -900,11 +900,11 @@ export class SubagentContinuationManager {
// some other owner holds — a duplicate would reject there with rollback.
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): AgentSetupCommit => {
// Only fresh creation seeds captured parent policy onto the child's own
// Only fresh creation seeds the delegation policy onto the child's own
// log (after any fork seed, so fresh policy wins stale seed state); a
// cold resume replays those persisted events instead.
if (create !== undefined) {
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies)
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies)
}
applyChildComposition(childCtx, inputs.composition)
return this.setupRegistry.apply(childCtx)

View File

@@ -1,9 +1,9 @@
/**
* Continuable-child policy inheritance: a fresh continuable start seeds the
* parent's explicit sandbox/approval overrides onto the child's own log as
* `source: 'delegation'` events, and a cold resume replays that persisted
* snapshot instead of re-capturing the parent (the one-shot
* `subagent-inprocess/tests/inheritance.spec.ts` counterpart).
* Continuable-child delegation policy: a fresh continuable start seeds the
* parent's explicit sandbox override and the pinned `approval/policy: never`
* onto the child's own log as `source: 'delegation'` events, and a cold
* resume replays that persisted snapshot instead of re-capturing the parent
* (the one-shot `subagent-inprocess/tests/inheritance.spec.ts` counterpart).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -21,7 +21,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService from '../src/index.ts'
@@ -71,10 +71,12 @@ function policyEvents(events: readonly SessionEvent[]) {
}
describe('continuable policy inheritance', () => {
it('seeds parent overrides into a fresh continuable child', async () => {
it('seeds the parent sandbox override and pins approval to never', async () => {
const { ctx, parent } = await setup([textResponse('child done')])
setSandboxMode(parent.session, 'danger-full-access')
setApprovalPolicy(parent.session, 'never')
// The parent keeps the interactive deployment default: the child pin must
// not depend on any parent approval override.
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
let child: Agent | undefined
ctx.on('agent/created', ({ agent }) => {
if (agent !== parent) child = agent
@@ -93,9 +95,20 @@ describe('continuable policy inheritance', () => {
{ type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } },
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
// Durable: a reload folds the same effective policy.
// Durable: a reload folds the same effective policy; the parent keeps its own.
expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access')
expect(effectiveApprovalPolicy(loaded.events)).toBe('never')
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
// The child's runtime-context snapshot states the fixed delegation scope.
const runtimeContext = loaded.events.find(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
)
const contextText = runtimeContext?.data.content
.flatMap(block => block.type === 'text' ? [block.text] : [])
.join('\n')
expect(contextText).toContain('You are a delegated subagent')
})
it('captures policy at delegation before asynchronous child creation', async () => {
@@ -114,17 +127,20 @@ describe('continuable policy inheritance', () => {
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
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 { ctx, parent } = await setup([textResponse('child done')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(policyEvents(loaded.events)).toEqual([])
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
})
it('does not freeze deployment defaults into an unswitched fork child either', async () => {
it('pins approval after the fork prefix of an unswitched fork child', async () => {
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
parent.followup(createUserMessage({
content: [{ type: 'text', text: 'parent work' }],
@@ -137,7 +153,10 @@ describe('continuable policy inheritance', () => {
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.seedLength).toBeGreaterThan(0)
expect(policyEvents(loaded.events)).toEqual([])
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
})
it('lets a later child-side switch win over the delegation snapshot', async () => {
@@ -180,6 +199,10 @@ describe('continuable policy inheritance', () => {
{ data: { mode: 'read-only', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
// The approval pin is seeded once at creation, never re-appended on resume.
expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ data: { policy: 'never', source: 'delegation' } },
])
})
it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {

View File

@@ -103,9 +103,9 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean {
&& event.data.content.some(block => block.type === 'text' && block.text === text))
}
/** Every user-role message text in log order, for FIFO assertions. */
/** Every caller-supplied user-role message text in log order, for FIFO assertions (framework runtime-context snapshots excluded). */
function userTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'user/message'
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}

View File

@@ -158,7 +158,7 @@ describe('dsh-tool-subagent-control', () => {
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
// A follow-up is its own later turn, never steering inside the first one.
@@ -274,7 +274,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => {
expect(waking.isError).toBe(false)
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])

View File

@@ -411,9 +411,9 @@ describe('dsh-tool-subagent-report', () => {
})
})
/** Prove report delivery uses ordinary logged user messages. */
/** Prove report delivery uses ordinary logged user messages (framework runtime-context snapshots excluded). */
function userTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'user/message'
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}