fix(core): enforce agent-scoped ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 22:55:26 +08:00
parent 850796bb35
commit 3263dab822
62 changed files with 3982 additions and 857 deletions

View File

@@ -390,10 +390,12 @@ export function apply(ctx: Context, config: Config = {}): void {
'agent/session-prefix': args => args[0],
'agent/step-result': args => args[0],
'agent/turn-continuation': args => args[0],
'agent/turn-stop': args => args[0],
'agent/error': args => args[0],
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
'tools/execute': args => (args[0] as ToolExecution).agent,
'tools/post-execute': args => (args[0] as ToolExecution).agent,
'tools/result': args => (args[0] as ToolExecution).agent,
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
'session/created': null,
'session/event': null,
@@ -429,11 +431,11 @@ export function apply(ctx: Context, config: Config = {}): void {
// --- 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
// CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not
// DRIVE the agent. ReactLoopAgent rejects every driving verb structurally
// until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the
// cross-implementation backstop for alternate Agent implementations and raw
// session writes. A turn/start appended before agent/session-start is 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).
@@ -450,7 +452,7 @@ export function apply(ctx: Context, config: Config = {}): void {
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 '
+ 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent '
+ '(send/steer/inject belong after creation returns)')
})

View File

@@ -828,11 +828,15 @@ describe('scoped-dispatch invariants', () => {
['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]],
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]],
['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
['agent/turn-stop', [agent, 1]],
['agent/error', [agent, 1, 0, new Error('x')]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
]
for (const [event, args] of rows) {
const subject = event.startsWith('tools/') ? agent : agent
@@ -870,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
}).not.toThrow()
})
it('rejects a turn opened before agent/session-start (setup drives the agent)', async () => {
it('backstops alternate agents that open a turn before agent/session-start', async () => {
const ctx = await scopedCtx()
// A live agent whose session is in the store but whose session-start has
// not fired: appending turn/start must throw the teaching error.