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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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] : [])
|
||||
: [])
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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] : [])
|
||||
: [])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user