subagent: capture overrides at delegation; stamp ahead of prompt vetoes

Review fixes (ds-review-bot on #623):

- Capture-at-delegation: the driver now reads overrideOf(parent.session)
  for both knobs synchronously before its first await, and the prompt-submit
  listener stamps those captured values — a parent switch racing the child's
  asynchronous creation belongs to the parent's future, not the child. The
  inheritOverride(parent, child) service method is split into its two halves
  (overrideOf / stampOverride) accordingly.
- Veto safety: the one-shot prompt-submit listener registers with
  prepend: true, so a veto-capable listener (a denying UserPromptSubmit
  hook) cannot close the child's first turn without the durable stamp.

Both regressions are pinned red-first in inheritance.spec.ts: the
delegation-vs-late-switch race (delegate tool flips the caller wider while
the creation transaction is pending) and a blocking prompt-submit listener
(stamp survives a promptless first turn). Service contract tests renamed to
the split API; READMEs and the bilingual Agent Note updated.
This commit is contained in:
kingwl
2026-07-25 10:51:25 +08:00
parent 669771097d
commit 6fa2377e34
12 changed files with 192 additions and 91 deletions

View File

@@ -6,7 +6,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.inheritOverride(parent, child)` stamps a parent session's override (never the configured default) onto a child session through that write path the in-process subagent driver calls it inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` / `ctx.approval.stampOverride(child, policy)` are the two halves of delegation inheritance — the fold alone (never the configured default), and the write of a captured override through that write path; the in-process subagent driver captures at delegation and stamps inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).

View File

@@ -327,21 +327,31 @@ export class ApprovalService extends Service {
}
/**
* Stamp the parent's approval-policy OVERRIDE onto a child session through
* the canonical write path — the delegation-inheritance step: a `'never'`
* A session's approval-policy OVERRIDE — the fold alone, never the
* configured default. The read half of delegation inheritance: the subagent
* driver captures this synchronously at delegation, so a parent switch
* racing the child's asynchronous creation belongs to the parent's future,
* not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @param session - the session whose override chain to fold.
* @returns the last switched policy, or `undefined` for a never-switched session.
*/
overrideOf(session: Session): ApprovalPolicy | undefined {
return effectiveApprovalPolicy(session.events)
}
/**
* Stamp a captured override onto a child session through the canonical
* write path — the write half of delegation inheritance: a `'never'`
* (headless/CI) parent must not mint children that fall back to a prompting
* default. Only the override chain is copied: an unswitched parent stamps
* nothing, so the child keeps following the LIVE configured default. A
* child whose log (e.g. a fork seed) already folds to the inherited policy
* default. A child whose log (e.g. a fork seed) already folds to the policy
* is left untouched. Callers must append inside an open child turn — a bare
* between-turn event is crash-tail garbage on reload.
* @param parent - the delegating session whose effective override is read.
* @param child - the child session the override is appended to.
* @param policy - the captured {@link overrideOf} value to stamp.
*/
inheritOverride(parent: Session, child: Session): void {
const inherited = effectiveApprovalPolicy(parent.events)
if (inherited === undefined || effectiveApprovalPolicy(child.events) === inherited) return
setApprovalPolicy(child, inherited)
stampOverride(child: Session, policy: ApprovalPolicy): void {
if (effectiveApprovalPolicy(child.events) === policy) return
setApprovalPolicy(child, policy)
}
/**

View File

@@ -577,44 +577,39 @@ describe('approval policy (the approval/policy fold)', () => {
})
})
describe('inheritOverride (parent → child stamping)', () => {
describe('delegation inheritance (overrideOf + stampOverride)', () => {
const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy')
function bareSession(id: string): Session {
return new Session(SessionId(id))
}
it('stamps the parent LAST override onto the child through the canonical write path', async () => {
it('overrideOf folds to the LAST override and never falls back to the configured default', async () => {
const ctx = await mounted()
const parent = bareSession('sess-appr-inherit-parent')
const child = bareSession('sess-appr-inherit-child')
setApprovalPolicy(parent, 'never')
ctx.approval.inheritOverride(parent, child)
expect(ctx.approval.overrideOf(parent)).toBe('never')
expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined()
})
it('stampOverride appends the captured policy through the canonical write path', async () => {
const ctx = await mounted()
const child = bareSession('sess-appr-inherit-child')
ctx.approval.stampOverride(child, 'never')
const stamped = policyEvents(child)
expect(stamped).toHaveLength(1)
expect(stamped[0]?.data).toEqual({ policy: 'never' })
})
it('appends NOTHING when the parent never switched (the configured default must stay live)', async () => {
it('stampOverride skips a child already folding to the policy (fork-seed dedup)', async () => {
const ctx = await mounted()
const parent = bareSession('sess-appr-default-parent')
const child = bareSession('sess-appr-default-child')
ctx.approval.inheritOverride(parent, child)
expect(child.events).toHaveLength(0)
})
it('skips the append when the child already folds to the inherited policy (fork-seed dedup)', async () => {
const ctx = await mounted()
const parent = bareSession('sess-appr-dedup-parent')
const child = bareSession('sess-appr-dedup-child')
setApprovalPolicy(parent, 'never')
setApprovalPolicy(child, 'never')
ctx.approval.inheritOverride(parent, child)
ctx.approval.stampOverride(child, 'never')
expect(policyEvents(child)).toHaveLength(1)
})