fix(subagent): publish inherited policy facts to telemetry
Inherited sandbox and approval events were part of the constructor seed. Session.firstLiveSeq classifies every constructor event as replayed history, so telemetry adoption skipped these child-only creation facts even though no parent or prior process had exported them. Capture the parent overrides at the same synchronous delegation boundary, but append the events during the child factory setup while the session is still unpublished. They remain ordered after fork history, persist with the first child batch, and retain last-event-wins behavior while landing on the live side of the telemetry boundary. This uses the existing setup and session append contracts instead of adding another seed category or telemetry special case. Add regression coverage for exporting an unpublished suffix without re-exporting constructor history, assert the spawn and fork firstLiveSeq boundaries, and restore the public seed documentation to replay/fork history only.
This commit is contained in:
@@ -63,12 +63,11 @@ export interface CreateAgentOptions {
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
/**
|
||||
* Initial session events. A fork starts with a balanced completed-turn
|
||||
* prefix of the parent's log; creation-time log facts may follow that
|
||||
* prefix. The complete seed must be contiguous from seq 0, carry only
|
||||
* lossless-JSON data, and contain no open turn/step or dangling tool call.
|
||||
* The factory passes it to the session's durable validator/snapshot
|
||||
* boundary before publication.
|
||||
* Initial replay/fork history. A fork supplies a balanced completed-turn
|
||||
* prefix of the parent's log. The complete seed must be contiguous from seq
|
||||
* 0, carry only lossless-JSON data, and contain no open turn/step or dangling
|
||||
* tool call. The factory passes it to the session's durable
|
||||
* validator/snapshot boundary before publication.
|
||||
*/
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/** Per-agent options (model, …). */
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface SessionHeader {
|
||||
* store folds into a {@link SessionHeader}.
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
/** Initial log events supplied at construction (replay, fork, or creation-time facts). */
|
||||
/** Initial replay or fork history supplied at construction. */
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Storage metadata read once before publication. `seedLength` is explicit
|
||||
|
||||
@@ -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: 02d9bd7d2dc792055a13d51402572313855ff1ff
|
||||
README.zh.md: f55467b37a948ef5a0a34bd45d8a11d7a004ff29
|
||||
README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f
|
||||
README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae
|
||||
|
||||
@@ -18,7 +18,7 @@ The driver follows this sequence:
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and seeds a source-tagged event after any fork prefix. 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).
|
||||
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).
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在任意 fork 前缀之后预置一条带来源标记的事件。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
|
||||
@@ -103,30 +103,19 @@ export async function startInProcessRun(
|
||||
}
|
||||
|
||||
// Capture before the first await: a later parent switch belongs to the
|
||||
// parent's future. Appending after the fork prefix makes the captured
|
||||
// values the child's initial overrides without another storage plane.
|
||||
// parent's future.
|
||||
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
|
||||
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
|
||||
const seed: SessionEvent[] = [...options.seed ?? []]
|
||||
if (inheritedMode !== undefined) {
|
||||
seed.push({
|
||||
type: 'sandbox/mode',
|
||||
seq: seed.length,
|
||||
time: Date.now(),
|
||||
data: { mode: inheritedMode, source: 'delegation' },
|
||||
})
|
||||
}
|
||||
if (inheritedPolicy !== undefined) {
|
||||
seed.push({
|
||||
type: 'approval/policy',
|
||||
seq: seed.length,
|
||||
time: Date.now(),
|
||||
data: { policy: inheritedPolicy, source: 'delegation' },
|
||||
})
|
||||
}
|
||||
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
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' })
|
||||
}
|
||||
if (request.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
||||
}
|
||||
@@ -146,7 +135,7 @@ export async function startInProcessRun(
|
||||
delegationDepth: childDepth,
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...(options.seed !== undefined || seed.length > 0) ? { seed } : {},
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
agentOptions,
|
||||
signal: request.signal,
|
||||
setup,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Policy inheritance through constructor-seeded child session events. */
|
||||
/** Policy inheritance through child session events appended before publication. */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
|
||||
@@ -69,7 +69,7 @@ function toolResultTexts(agent: Agent): string[] {
|
||||
}
|
||||
|
||||
describe('in-process policy inheritance', () => {
|
||||
it('seeds parent overrides into a spawn child before its first request', async () => {
|
||||
it('records parent overrides before publishing a spawn child', async () => {
|
||||
const script: Script = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
const blocked = join(workspace, 'spawn-blocked.txt')
|
||||
@@ -93,7 +93,7 @@ describe('in-process policy inheritance', () => {
|
||||
{ type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
|
||||
{ type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
|
||||
])
|
||||
expect(child.session.firstLiveSeq).toBe(2)
|
||||
expect(child.session.firstLiveSeq).toBe(0)
|
||||
expect(child.session.header.seedLength).toBeUndefined()
|
||||
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
|
||||
expect(ctx.approval.overrideOf(child.session)).toBe('never')
|
||||
@@ -125,6 +125,7 @@ describe('in-process policy inheritance', () => {
|
||||
const child = run.localAgent as Agent
|
||||
|
||||
expect(child.session.header.seedLength).toBe(1)
|
||||
expect(child.session.firstLiveSeq).toBe(seed.length)
|
||||
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
|
||||
{ seq: 0, data: { mode: 'workspace-write' } },
|
||||
{ seq: 1, data: { mode: 'read-only', source: 'delegation' } },
|
||||
|
||||
@@ -168,28 +168,25 @@ describe('TelemetryCoordinator capture', () => {
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator adoption', () => {
|
||||
it('starts export at the construction boundary: seeded history never re-exports', async () => {
|
||||
it('exports an unpublished suffix without re-exporting constructor history', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const parent = liveSession(ctx, 'seed-parent')
|
||||
appendTurn(parent)
|
||||
const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
// The live parent (no constructor seed) replays in full; the child's
|
||||
// inherited prefix already left the process under another identity (the
|
||||
// parent's id here; the same id in a previous process for a resume) and
|
||||
// must not be re-exported — only its live suffix ships.
|
||||
const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
ctx.sessions.enter(child)
|
||||
ctx.sessions.announce(child)
|
||||
|
||||
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
|
||||
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([])
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]))
|
||||
.toEqual(expect.arrayContaining([['seeded', 2]]))
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2]])
|
||||
})
|
||||
|
||||
it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
|
||||
|
||||
Reference in New Issue
Block a user