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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: b9757bb04609d3cdd1459d5845e5c59388b269f6
|
||||
README.zh.md: 9d28d057e08ddb12530ba2894eda8046a5c0ff5a
|
||||
README.md: 28f649ef54bbf88feda24a9ce197c2c366f8349b
|
||||
README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79
|
||||
|
||||
@@ -56,6 +56,10 @@ 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
|
||||
|
||||
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
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
|
||||
@@ -96,11 +100,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
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
|
||||
|
||||
## 委派策略
|
||||
|
||||
两条进程内委派路径都会通过共享的子 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。
|
||||
|
||||
## 一次性所有权与生命周期
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。
|
||||
@@ -96,11 +100,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 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
@@ -45,12 +47,19 @@
|
||||
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^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-agent-presets": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-sandbox": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-sandbox-policy": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -62,6 +71,9 @@
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-user-approval": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,6 +82,8 @@
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
@@ -79,6 +93,7 @@
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
/**
|
||||
* Shared in-process child composition: the delegation-depth budget, the
|
||||
* durable session metadata, the resolved child `AgentOptions`, 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 and
|
||||
* lineage stamping have one home.
|
||||
* durable session metadata, the resolved child `AgentOptions`, the delegated
|
||||
* 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 delegation policy have one home.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/child-agent
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
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'
|
||||
// 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 —
|
||||
// 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'
|
||||
// Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when
|
||||
// composed — a child inherits its parent's composition opportunistically (the
|
||||
// documented `ctx.get` pattern), never as a hard dep. A rosterless deployment
|
||||
@@ -121,21 +128,34 @@ export interface ChildComposition {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one child inside its creation window: join its parent's preset, then
|
||||
* apply the child's own shadowing persona section and tool restriction, both
|
||||
* owned by the child's scope and therefore invisible to its parent and
|
||||
* siblings.
|
||||
* Model-facing delegation-scope statement for every in-process child. A
|
||||
* runtime-context contribution rather than a system-prompt section, so the
|
||||
* deployment's system prompt stays uniform across parents and children.
|
||||
*/
|
||||
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.'
|
||||
|
||||
/**
|
||||
* Compose one child inside its creation window: join its parent's preset,
|
||||
* register the fixed delegation-scope statement, then apply the child's own
|
||||
* shadowing persona section and tool restriction, all owned by the child's
|
||||
* scope and therefore invisible to its parent and siblings. Creation and cold
|
||||
* resume both pass through here.
|
||||
*
|
||||
* The join comes first and the child's own registrations second, which is the
|
||||
* order the layering already implies — the nearest scope wins a name, and a
|
||||
* per-child restriction intersects with everything its chain admits — but
|
||||
* stating it here keeps the two steps from being read as independent.
|
||||
*
|
||||
* Both steps live in ONE call because a child composed with only the second is
|
||||
* exactly the defect this function exists to prevent: with every model-facing
|
||||
* row on the agent plane, a child that joins no preset sees an empty tool
|
||||
* registry and none of its parent's prompt sections. Taking the parent as a
|
||||
* parameter is what makes that omission unrepresentable at the call sites.
|
||||
* The join and the per-child registrations live in ONE call because a child
|
||||
* composed without the join is exactly the defect this function exists to
|
||||
* prevent: with every model-facing row on the agent plane, a child that joins
|
||||
* no preset sees an empty tool registry and none of its parent's prompt
|
||||
* sections. Taking the parent as a parameter is what makes that omission
|
||||
* unrepresentable at the call sites.
|
||||
* @param childCtx - the child agent's scoped creation context.
|
||||
* @param parent - the delegating parent whose composition the child joins.
|
||||
* @param composition - the per-child persona and tool filter to install.
|
||||
@@ -146,12 +166,64 @@ export function applyChildComposition(
|
||||
composition: ChildComposition,
|
||||
): void {
|
||||
childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx)
|
||||
// Order 120: after the sandbox:policy (110) and approval:policy (115) sentences.
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* `'never'` whenever the approval capability is composed, `undefined`
|
||||
* otherwise: a delegated child acts only within the sandbox scope fixed at
|
||||
* delegation, so its asks are rejected deterministically.
|
||||
*/
|
||||
readonly approvalPolicy: 'never' | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Only the parent session's explicit
|
||||
* sandbox override is captured — never deployment defaults or one-shot
|
||||
* grants — and the approval policy is pinned to `'never'` regardless of the
|
||||
* parent's own policy.
|
||||
* @param parent - the delegating parent agent.
|
||||
* @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') === undefined ? undefined : 'never',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 policy captured at delegation.
|
||||
*/
|
||||
export function appendDelegatedPolicyOverrides(
|
||||
childSession: Session,
|
||||
overrides: DelegatedPolicyOverrides,
|
||||
): void {
|
||||
if (overrides.sandboxMode !== undefined) {
|
||||
childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
|
||||
}
|
||||
if (overrides.approvalPolicy !== undefined) {
|
||||
childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity and lineage inputs shared by every in-process child creation. */
|
||||
export interface ChildCreateInputs {
|
||||
/** The child's reserved session id. */
|
||||
|
||||
@@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import type { SubagentDescriptorData } from './descriptor.ts'
|
||||
import {
|
||||
appendDelegatedPolicyOverrides,
|
||||
applyChildComposition,
|
||||
captureDelegatedPolicyOverrides,
|
||||
childSessionMeta,
|
||||
resolveChildAgentOptions,
|
||||
resolveChildDepth,
|
||||
} from './child-agent.ts'
|
||||
import type { DelegatedPolicyOverrides } from './child-agent.ts'
|
||||
import { assertSubagentMaxDepth } from './depth.ts'
|
||||
import { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
|
||||
@@ -203,8 +206,17 @@ interface MaterializeInputs {
|
||||
childId: SessionId
|
||||
provider: string
|
||||
parent: Agent
|
||||
/** Creation inputs; absent for a cold resume, which loads the persisted session. */
|
||||
create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
|
||||
/**
|
||||
* Creation inputs; absent for a cold resume, which loads the persisted
|
||||
* session — including the delegation policy events a fresh creation seeded,
|
||||
* so a resume never re-captures the parent's policy.
|
||||
*/
|
||||
create?: {
|
||||
seed: readonly SessionEvent[]
|
||||
meta: NonNullable<CreateAgentOptions['meta']>
|
||||
/** 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 }
|
||||
signal: AbortSignal
|
||||
@@ -341,6 +353,9 @@ export class SubagentContinuationManager {
|
||||
...request.persona !== undefined ? { persona: request.persona } : {},
|
||||
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
|
||||
})
|
||||
// Capture before the first await: a later parent switch belongs to the
|
||||
// parent's future, not to this child.
|
||||
const delegatedPolicies = captureDelegatedPolicyOverrides(parent)
|
||||
|
||||
const prepared = await this.host.prepareContinuable(spec.provider, {
|
||||
sessionId: childId,
|
||||
@@ -357,7 +372,7 @@ export class SubagentContinuationManager {
|
||||
childId,
|
||||
provider: spec.provider,
|
||||
parent,
|
||||
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) },
|
||||
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies },
|
||||
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
|
||||
composition: { persona: request.persona, toolFilter: request.toolFilter },
|
||||
signal: spec.signal,
|
||||
@@ -878,18 +893,23 @@ export class SubagentContinuationManager {
|
||||
inputs: MaterializeInputs,
|
||||
parentLineage: readonly Agent[],
|
||||
): Promise<Activation> {
|
||||
const { childId, provider, parent } = inputs
|
||||
const { childId, provider, parent, create } = inputs
|
||||
// No id pre-check here: the child lock serializes each durable child, both
|
||||
// callers reach this only after confirming no Activation exists, and
|
||||
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
|
||||
// some other owner holds — a duplicate would reject there with rollback.
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): AgentSetupCommit => {
|
||||
// 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.delegatedPolicies)
|
||||
}
|
||||
applyChildComposition(childCtx, parent, inputs.composition)
|
||||
return this.setupRegistry.apply(childCtx)
|
||||
}
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
const { create } = inputs
|
||||
// Agent creation owns rollback before handle transfer. A rejection leaves
|
||||
// no resident Activation and therefore publishes no lifecycle edge.
|
||||
const handle: AgentHandle = create === undefined
|
||||
|
||||
@@ -101,13 +101,15 @@ export { SubagentError } from './error.ts'
|
||||
export { settleRun } from './run-settlement.ts'
|
||||
export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts'
|
||||
export {
|
||||
appendDelegatedPolicyOverrides,
|
||||
applyChildComposition,
|
||||
captureDelegatedPolicyOverrides,
|
||||
childSessionMeta,
|
||||
resolveChildAgentOptions,
|
||||
resolveChildDepth,
|
||||
SubagentDepthError,
|
||||
} from './child-agent.ts'
|
||||
export type { ChildComposition } from './child-agent.ts'
|
||||
export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts'
|
||||
export type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
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 } from '@deepseek-ai/dsh-user-approval'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentService from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
const roots: string[] = []
|
||||
const contexts: Context[] = []
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root })
|
||||
await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
function startSpec(parent: Agent, provider = 'spawn') {
|
||||
return {
|
||||
provider,
|
||||
label: 'child task',
|
||||
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
|
||||
signal: new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
|
||||
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(childId)).toBeUndefined()
|
||||
}, { timeout: 15_000 })
|
||||
}
|
||||
|
||||
function policyEvents(events: readonly SessionEvent[]) {
|
||||
return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy')
|
||||
}
|
||||
|
||||
describe('continuable policy inheritance', () => {
|
||||
it('seeds the parent sandbox override and pins approval to never', { timeout: 20_000 }, async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child done')])
|
||||
setSandboxMode(parent.session, 'danger-full-access')
|
||||
// No parent approval override: the child pin must not depend on one.
|
||||
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
|
||||
let child: Agent | undefined
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
if (agent !== parent) child = agent
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
// The delegation events are appended in the creation window, so they are
|
||||
// already the child's effective policy at inbox acceptance.
|
||||
if (child === undefined) throw new Error('expected the continuable child to be created')
|
||||
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
|
||||
expect(ctx.approval.overrideOf(child.session)).toBe('never')
|
||||
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(policyEvents(loaded.events)).toMatchObject([
|
||||
{ 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.
|
||||
expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access')
|
||||
expect(effectiveApprovalPolicy(loaded.events)).toBe('never')
|
||||
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
|
||||
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', { timeout: 20_000 }, async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child done')])
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
|
||||
const starting = ctx.subagents.startContinuable(startSpec(parent))
|
||||
// A parent switch after the synchronous capture belongs to the parent's
|
||||
// future, not to this child.
|
||||
setSandboxMode(parent.session, 'danger-full-access')
|
||||
const started = await starting
|
||||
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
|
||||
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
|
||||
})
|
||||
|
||||
it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, 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)).toMatchObject([
|
||||
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
|
||||
])
|
||||
expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pins approval after the fork prefix of an unswitched fork child', { timeout: 20_000 }, async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
|
||||
parent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'parent work' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await parent.whenIdle()
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.meta.seedLength).toBeGreaterThan(0)
|
||||
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', { timeout: 20_000 }, async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child done')])
|
||||
setSandboxMode(parent.session, 'danger-full-access')
|
||||
let child: Agent | undefined
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
if (agent !== parent) child = agent
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
if (child === undefined) throw new Error('expected the continuable child to be created')
|
||||
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
|
||||
// Last event wins: the child's own runtime switch beats the seeded snapshot.
|
||||
setSandboxMode(child.session, 'read-only')
|
||||
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
|
||||
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
|
||||
})
|
||||
|
||||
it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
// The parent widens AFTER the child was created; the resumed child keeps
|
||||
// the delegation-time snapshot from its own log.
|
||||
setSandboxMode(parent.session, 'danger-full-access')
|
||||
await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], {
|
||||
source: { kind: 'user' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
|
||||
{ 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', { timeout: 20_000 }, async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
|
||||
// The stale mode lands inside the completed turn the fork seed replays.
|
||||
setSandboxMode(parent.session, 'workspace-write')
|
||||
parent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'parent work' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await parent.whenIdle()
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.meta.seedLength).toBeGreaterThan(0)
|
||||
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
|
||||
{ data: { mode: 'workspace-write' } },
|
||||
{ data: { mode: 'read-only', source: 'delegation' } },
|
||||
])
|
||||
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
/** Caller-supplied user message texts in log order (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] : [])
|
||||
: [])
|
||||
}
|
||||
|
||||
@@ -26,9 +26,18 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
|
||||
@@ -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 (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