feat(invariants): scoped-dispatch carrier/subject checks and the setup-drives tripwire

Three dev-mode invariants close the leak-by-default regression class at
runtime: (1) every scope-filtered event family must dispatch with a scope
carrier — a bare dispatch throws at the call site naming the carrier rule;
(2) where the subject is recoverable from the arguments (agent/*, the tool
pipeline, prompt assembly) the carrier's key must BE that subject, and an
assembly context must never carry agent without scope (use
assembleContextFor); (3) a turn/start logged before the owning agent's
agent/session-start is the setup-drives teaching error (setup registers the
scoped world, it never drives the agent).
This commit is contained in:
Tianyi Cui
2026-07-09 02:11:21 +08:00
parent 15f4d1cd03
commit 1ac7857349
5 changed files with 196 additions and 18 deletions

View File

@@ -20,6 +20,9 @@
*/
import type { Context } from 'cordis'
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -362,6 +365,93 @@ export function apply(ctx: Context, config: Config = {}): void {
lastStatus.set(agent, status)
})
// --- Scoped-dispatch invariants (the agent-scoping seam) ---------------
//
// Every scope-filtered event family must dispatch with a scope carrier
// (scopeTarget) whose key IS the subject the event's arguments name —
// a dispatch without one silently reverts that event to global delivery
// (agent-scoped listeners over-hear foreign agents), and a mis-keyed one
// delivers to the wrong agent's listeners. `internal/dispatch` fires
// synchronously before listener delivery, so a violation throws at the
// dispatching call site. The table maps each family to how its subject is
// read from the event arguments; `null` = the subject is not recoverable
// from the arguments (session events key by the OWNING agent; subagent
// lifecycle events key by the delegating parent), so only carrier
// PRESENCE is asserted there.
const scopedSubject: Record<string, ((args: unknown[]) => unknown) | null> = {
'agent/created': args => args[0],
'agent/disposed': args => args[0],
'agent/status': args => args[0],
'agent/queued': args => args[0],
'agent/session-start': args => args[0],
'agent/pre-step': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/request': args => args[0],
'agent/step-result': args => args[0],
'agent/turn-continuation': args => args[0],
'agent/error': args => args[0],
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
'tools/post-execute': args => (args[0] as ToolExecution).agent,
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
'session/created': null,
'session/event': null,
'session/flush': null,
'subagent/start': null,
'subagent/end': null,
}
ctx.on('internal/dispatch', (_mode, name, args, thisArg) => {
const subjectOf = scopedSubject[name]
if (subjectOf === undefined) return
if (!isScopeCarrier(thisArg)) {
throw new InvariantError(
`"${name}" is a scope-filtered event but was dispatched without a scope carrier — `
+ 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))')
}
if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) {
throw new InvariantError(
`"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
+ 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))')
}
// The assembly context must never carry the agent DX field without the
// scope layer selector: the assembly would silently miss the agent's
// scoped sections/tools (use assembleContextFor(agent)).
if (name === 'system-prompt/assemble') {
const context = args[1] as AssembleContext
if (context.agent !== undefined && context.scope !== context.agent) {
throw new InvariantError(
'an assembly context carries `agent` without `scope` (or with a mismatched scope) — '
+ 'use assembleContextFor(agent) so the assembly resolves the agent\'s scoped layer')
}
}
}, { global: true })
// --- Setup-drives invariant ---------------------------------------------
//
// CreateAgentOptions.setup REGISTERS the agent's scoped world; it must not
// DRIVE the agent — an inject() there opens a turn before
// `agent/session-start`, inverting the "session-start fires before the
// first turn" contract every bridge keys on. A turn/start appended to a
// live agent's session before its agent/session-start fired is therefore a
// creation-time misuse, reported at the appending call site. Sessions of
// agents that exist BEFORE this plugin applies are marked started (their
// ordering is unknowable after the fact — never a false positive on HMR).
// `agents` is read via ctx.get (a strict, optional store lookup) rather
// than injected: the invariants plugin must load in harnesses that carry
// no agent registry at all (bare session tests), where this check simply
// never trips.
const sessionStarted = new WeakSet<Session>()
for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session)
ctx.on('agent/session-start', (agent) => { sessionStarted.add(agent.session) })
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/start' || sessionStarted.has(session)) return
const owner = ctx.get('agents')?.list().find(agent => agent.session === session)
if (owner === undefined) return
throw new InvariantError(
`agent "${owner.id}": a turn opened before agent/session-start fired — `
+ 'CreateAgentOptions.setup registers the scoped world, it must not drive the agent '
+ '(send/steer/inject belong after creation returns)')
})
// Request-reconstruction cross-check (the reconstructability RFC): a
// loop-built request — frozen envelope + live sessionId is the marker; a
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must