refactor(agent): publish agent/inbox/claimed from Inbox.claim

Move the claimed-message notification loop out of the loop's pre-step
into Inbox.claim(target, turn), so the step-boundary operation publishes
its own claimed notifications like insertions and discards do.
This commit is contained in:
_Kerman
2026-08-05 21:36:51 +08:00
parent 488b8df547
commit d3249f096b
24 changed files with 60 additions and 50 deletions
@@ -40,7 +40,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -96,7 +96,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -175,7 +175,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
id: SessionId('a1'),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
send: () => {},
followup: () => {},
@@ -230,7 +230,7 @@ function baselineEvents(agent: Agent): SessionEvent[] {
async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise<number | undefined> {
await syncedWorkspaceContext(ctx, agent)
let lastSeq: number | undefined
for (const claimed of agent.inbox.claim('next-step')) {
for (const claimed of agent.inbox.claim('next-step', 1)) {
if (claimed.source.kind !== 'workspace-instructions') continue
const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' })
ctx.emit('session/event', agent.session, event)
@@ -249,7 +249,7 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Messag
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const claimed = agent.inbox.claim('next-step')
const claimed = agent.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
claimed,
@@ -979,7 +979,7 @@ describe('workspace context request injection', () => {
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events])
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
const claimed = resumed.inbox.claim('next-step')
const claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step',
claimed,
@@ -1027,7 +1027,7 @@ describe('workspace context request injection', () => {
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events])
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
const staleClaim = resumed.inbox.claim('next-step')
const staleClaim = resumed.inbox.claim('next-step', 1)
const staleDecision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step',
staleClaim,
@@ -1082,7 +1082,7 @@ describe('workspace context request injection', () => {
await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
const resumed = stubAgent(root, [...original.session.events])
agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume')
const claimed = resumed.inbox.claim('next-step')
const claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(resumedCtx, resumed).waterfall(
'agent/pre-step',
claimed,
@@ -3966,7 +3966,7 @@ describe('workspace context inbox synchronization', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(join(root, 'pkg'))
await syncedWorkspaceContext(ctx, agent)
const claimed = agent.inbox.claim('next-step')
const claimed = agent.inbox.claim('next-step', 1)
await write(join(root, 'pkg/AGENTS.md'), 'new claimed rule with more detail')
const downstream = { kind: 'enter' as const, messages: claimed }
@@ -2007,11 +2007,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Inbox',
declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}',
declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}',
},
{
name: 'InboxNotifications',
declaration: 'export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n}',
declaration: 'export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n}',
},
{
name: 'InboxTarget',
+2 -4
View File
@@ -81,6 +81,7 @@ export class ReactLoopAgent implements Agent {
this.inbox = new Inbox(session, {
inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) },
discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) },
claimed: (message, turn) => { emitAgentEvent(loopCtx, this, 'agent/inbox/claimed', { message, turn }) },
})
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
this.phase = { kind: 'idle', lastTurn }
@@ -198,10 +199,7 @@ export class ReactLoopAgent implements Agent {
/* v8 ignore next -- private callers establish the running phase before proposing a step */
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = this.inbox.claim(target)
for (const message of claimed) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/claimed', { message, turn: position.turn })
}
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const context = this.runtimeContext.project(renderContextSnapshot(assembly))
+7 -3
View File
@@ -19,6 +19,8 @@ export interface InboxNotifications {
inserted(message: UserMessage): void
/** Publish one discarded message. */
discarded(message: UserMessage): void
/** Publish one claimed message inside its owning turn. */
claimed(message: UserMessage, turn: number): void
}
/** A replay-once projection that incrementally consumes later inbox splices. */
@@ -61,17 +63,19 @@ export class Inbox {
}
/**
* Remove and return the complete batch proposed for one step. The durable
* splices are pure deletions; the caller publishes claimed notifications.
* Remove and return the complete batch proposed for one step, publishing
* each claimed message. The durable splices are pure deletions.
* @param target - whether this boundary also consumes one queued turn.
* @param turn - turn that will own the claimed batch.
* @returns next-step input followed by the queued turn, when requested.
* @internal - the agent loop's step-boundary operation, not a plugin seam.
*/
claim(target: InboxTarget): UserMessage[] {
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
}
for (const message of claimed) this.notifications.claimed(message, turn)
return claimed
}
+5 -3
View File
@@ -22,7 +22,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: new Context(),
send: () => {},
@@ -45,7 +45,7 @@ describe('Inbox', () => {
inserted: [],
})
expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {} }))
expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }))
.toThrow('invalid persisted inbox splice at session seq 0')
})
@@ -54,6 +54,7 @@ describe('Inbox', () => {
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
claimed: () => {},
inserted: message => void inserted.push(message),
discarded: message => void discarded.push(message),
})
@@ -92,7 +93,7 @@ describe('Inbox', () => {
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
const session = Session.create(SessionId('splice-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
@@ -113,6 +114,7 @@ describe('Inbox', () => {
const session = Session.create(SessionId('clear-inbox'))
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
claimed: () => {},
inserted: () => {},
discarded: message => void discarded.push(message),
})
@@ -33,7 +33,7 @@ function agent(ctx: Context, cwd: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
@@ -20,7 +20,7 @@ interface Harness {
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
// Store-created: the command executor durably logs lifecycle events on it.
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
+3 -3
View File
@@ -24,13 +24,13 @@ function nextTurn(session: Session): number {
/** Mirror the public Agent.inject contract for domain tests. */
function appendInjection(session: Session, input: UserMessage): void {
new Inbox(session, { inserted: () => {}, discarded: () => {} }).append('next-step', input)
new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input)
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
const id = session.id
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id,
options: {},
@@ -554,7 +554,7 @@ describe('goal replay validation', () => {
content: [{ type: 'text', text: 'unrelated pending context' }],
source: { kind: 'plugin', plugin: 'test' },
})
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
inbox.append('next-step', message)
expect(inbox.remove(message.id)).toBe(true)
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
+1 -1
View File
@@ -31,7 +31,7 @@ interface Bench {
/** Register a minimal registry-compatible live agent over a store session. */
function liveAgent(ctx: Context, session: Session): Agent {
const status: AgentStatus = 'idle'
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id: session.id,
options: {},
@@ -29,7 +29,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
get status() { return status },
ctx: new Context(),
send: () => {},
@@ -55,7 +55,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
source,
})
stub.agent.inbox.append('next-turn', message)
const claimed = stub.agent.inbox.claim('next-turn')
const claimed = stub.agent.inbox.claim('next-turn', turn)
if (claimed.length === 0) throw new Error('expected queued turn input')
stub.session.append('turn/start', { turn })
for (const admitted of claimed) {
@@ -63,7 +63,7 @@ async function harness(options: { commands?: boolean; skills?: boolean } = {}):
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
const session = ctx.sessions.create(sessionId)
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent = {
id: session.id,
session,
@@ -54,7 +54,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
// The gateway reads both the session and durable inbox baseline.
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx } as Agent)
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
return { ctx, session }
}
@@ -44,7 +44,7 @@ function stubAgent(session: Session): Agent {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: new Context(),
send: () => {},
+9 -6
View File
@@ -42,8 +42,9 @@ function agent(ctx: Context, cwd?: string): Agent {
const id = SessionId('agent')
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
return {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx,
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
@@ -259,8 +260,9 @@ describe('pty-local plugin shape', () => {
const session = ctx.sessions.create(SessionId('mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: ownerFiber.ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
@@ -306,8 +308,9 @@ describe('pty-local plugin shape', () => {
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: ownerFiber.ctx,
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
+3 -2
View File
@@ -35,8 +35,9 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
const session = Session.create(id)
return {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: scope.ctx,
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
+1 -1
View File
@@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scopeFiber.ctx,
send: () => {},
@@ -43,7 +43,7 @@ function agent(ctx: Context, cwd: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
@@ -39,7 +39,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
@@ -40,8 +40,9 @@ function agent(ctx: Context): Agent {
const id = SessionId('pty-loader-agent')
const session = Session.create(id)
const value: Agent = {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: scope.ctx,
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
+3 -2
View File
@@ -18,8 +18,9 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const session = Session.create(id)
const agent: Agent = {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', ctx: scope.ctx,
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
@@ -44,7 +44,7 @@ function agentForCwd(cwd: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
send: () => {},
followup: () => {},
@@ -61,7 +61,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -23,7 +23,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle' as const,
ctx: scopeFiber.ctx,
send: () => {},