refactor(agent): unify agent-scoped event signatures as payload objects
All agent/* and agent-loop/config-start-failed events take one payload object carrying the agent subject; waterfall/serial payloads require a signal and keep next as the final argument. PreStepContext and RequestFailureContext are unfolded into payloads and retired. goal/changed follows the same shape so agentEvents keeps its listener error containment. ReactLoopAgent builds its scope carrier once in the constructor. Regenerates scope resolvers, tool-cordis api catalog, and docs catalogs; updates all affected listeners, tests, and the core-data-structures docs (en + zh).
This commit is contained in:
@@ -243,20 +243,20 @@ export function apply(ctx: Context): void {
|
||||
// One composite effect keeps the step fence installed until this
|
||||
// plugin's own scheduling tasks settle.
|
||||
ctx.effect(function* () {
|
||||
ctx.on('agent/error', (agent) => {
|
||||
ctx.on('agent/error', ({ agent }) => {
|
||||
const state = stateFor(agent)
|
||||
disarm(state)
|
||||
})
|
||||
|
||||
ctx.on('agent/created', (agent) => { stateFor(agent) })
|
||||
ctx.on('agent/disposed', (agent) => { states.delete(agent) })
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
ctx.on('agent/created', ({ agent }) => { stateFor(agent) })
|
||||
ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) })
|
||||
ctx.on('agent/session-start', ({ agent }) => {
|
||||
const state = stateFor(agent)
|
||||
state.attempt = undefined
|
||||
state.competingQueued = false
|
||||
state.needsCheckpoint = false
|
||||
})
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
const state = stateFor(agent)
|
||||
if (status === 'idle') {
|
||||
state.competingQueued = false
|
||||
@@ -275,13 +275,13 @@ export function apply(ctx: Context): void {
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('goal/changed', (agent) => {
|
||||
ctx.on('goal/changed', ({ agent }) => {
|
||||
const state = stateFor(agent)
|
||||
state.needsCheckpoint = true
|
||||
requestDrive(state)
|
||||
})
|
||||
|
||||
ctx.on('agent/inbox/inserted', (agent, { message }) => {
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
@@ -289,14 +289,14 @@ export function apply(ctx: Context): void {
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
ctx.on('agent/inbox/claimed', (agent, { message }) => {
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
|
||||
attempt.phase = 'claimed'
|
||||
}
|
||||
})
|
||||
ctx.on('agent/inbox/discarded', (agent, { message }) => {
|
||||
ctx.on('agent/inbox/discarded', ({ agent, message }) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
|
||||
@@ -346,7 +346,7 @@ export function apply(ctx: Context): void {
|
||||
&& source.round === goal.roundsStarted + 1
|
||||
}
|
||||
|
||||
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
|
||||
const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } =>
|
||||
isGoalRoundSource(message.source))
|
||||
if (submitted === undefined) return next()
|
||||
|
||||
@@ -107,7 +107,7 @@ function onInboxMessage(
|
||||
agent: Agent,
|
||||
listener: (message: UserMessage) => void,
|
||||
): () => void {
|
||||
return ctx.on('agent/inbox/inserted', (subject, { message }) => {
|
||||
return ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
|
||||
if (subject === agent) listener(message)
|
||||
})
|
||||
}
|
||||
@@ -118,7 +118,7 @@ function onClaimedMessage(
|
||||
agent: Agent,
|
||||
listener: (message: UserMessage) => void,
|
||||
): () => void {
|
||||
return ctx.on('agent/inbox/claimed', (subject, { message }) => {
|
||||
return ctx.on('agent/inbox/claimed', ({ agent: subject, message }) => {
|
||||
if (subject === agent) listener(message)
|
||||
})
|
||||
}
|
||||
@@ -247,7 +247,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('maps a downstream step rejection to blocked without entering the round', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
|
||||
test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'reject' as const })
|
||||
: next())
|
||||
test.ctx.goals.create(test.agent, { objective: 'respect policy' })
|
||||
@@ -265,10 +265,10 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => {
|
||||
const test = await harness([textResponse('human follow-up')])
|
||||
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
|
||||
test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'reject' as const })
|
||||
: next())
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
test.ctx.on('goal/changed', ({ agent, change }) => {
|
||||
if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } }))
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
|
||||
@@ -370,7 +370,7 @@ describe('same-session goal driving', () => {
|
||||
it('rechecks revision after downstream prompt hooks before admitting', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', ({ agent, messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && !edited) {
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
@@ -389,7 +389,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('does not block a goal that downstream paused before rejecting its prompt', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
|
||||
test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
|
||||
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) {
|
||||
return next()
|
||||
}
|
||||
@@ -432,7 +432,7 @@ describe('same-session goal driving', () => {
|
||||
test.agent.inbox.prepend('next-step', roundZeroContext)
|
||||
})
|
||||
let edited = false
|
||||
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
|
||||
test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
|
||||
const decision = await next()
|
||||
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision
|
||||
edited = true
|
||||
@@ -513,8 +513,10 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed')))
|
||||
agentEvents(test.ctx, test.agent).emit('goal/changed', {
|
||||
operation: 'clear',
|
||||
ref: { id: GoalId('cleared-goal'), revision: 2 },
|
||||
change: {
|
||||
operation: 'clear',
|
||||
ref: { id: GoalId('cleared-goal'), revision: 2 },
|
||||
},
|
||||
})
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
|
||||
@@ -529,7 +531,7 @@ describe('same-session goal driving', () => {
|
||||
])
|
||||
// The llm-retry shape: schedule one retry for the failed goal-round request.
|
||||
let retried = false
|
||||
test.ctx.on('agent/request-error', async (_subject) => {
|
||||
test.ctx.on('agent/request-error', async (_payload) => {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
return { kind: 'retry' }
|
||||
@@ -552,7 +554,7 @@ describe('same-session goal driving', () => {
|
||||
// attempt through cancel-requested) and THEN throws: the catch finds no
|
||||
// matching reservation and must not reschedule a paused goal.
|
||||
let fired = false
|
||||
test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && !fired) {
|
||||
fired = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -576,7 +578,7 @@ describe('same-session goal driving', () => {
|
||||
// Registered after goal-session's own listener: the throw propagates back
|
||||
// through goal-session's next() await, dropping the whole step proposal.
|
||||
let threw = false
|
||||
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', async ({ messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && !threw) {
|
||||
threw = true
|
||||
throw new Error('downstream pre-step hook exploded')
|
||||
@@ -598,7 +600,7 @@ describe('same-session goal driving', () => {
|
||||
textResponse('goal round ran'),
|
||||
])
|
||||
let retried = false
|
||||
test.ctx.on('agent/request-error', async (_subject) => {
|
||||
test.ctx.on('agent/request-error', async (_payload) => {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
return { kind: 'retry' }
|
||||
@@ -721,7 +723,7 @@ describe('same-session goal driving', () => {
|
||||
it('fails a post-hook read closed before the prompt can enter history', async () => {
|
||||
const test = await harness([])
|
||||
let armed = true
|
||||
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', ({ messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && armed) {
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
@@ -809,7 +811,7 @@ describe('same-session goal driving', () => {
|
||||
it('rejects the step when downstream cancellation clears the reservation', async () => {
|
||||
const test = await harness([])
|
||||
let cancelled = false
|
||||
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', ({ agent, messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && !cancelled) {
|
||||
cancelled = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -864,7 +866,7 @@ describe('same-session goal driving', () => {
|
||||
it('resets process-local scheduling state at a session-start edge', async () => {
|
||||
const test = await harness([textResponse('after explicit resume')])
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 })
|
||||
agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume')
|
||||
agentEvents(test.ctx, test.agent).emit('agent/session-start', { source: 'resume' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 })
|
||||
@@ -898,7 +900,7 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([textResponse('round one')])
|
||||
test.ctx.on('session/event', (session, event) => {
|
||||
if (session === test.agent.session && event.type === 'turn/end') {
|
||||
agentEvents(test.ctx, test.agent).emit('agent/error', event.data.turn, 1, new Error('post-turn flush failed'))
|
||||
agentEvents(test.ctx, test.agent).emit('agent/error', { turn: event.data.turn, step: 1, error: new Error('post-turn flush failed') })
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop when durability is lost', maxGoalRounds: 8 })
|
||||
@@ -923,7 +925,7 @@ describe('same-session goal driving', () => {
|
||||
await handle.dispose()
|
||||
const warn = vi.spyOn(test.ctx.logger, 'warn')
|
||||
|
||||
agentEvents(test.ctx, handle.agent).emit('agent/error', closed.data.turn, 1, new Error('late flush failure'))
|
||||
agentEvents(test.ctx, handle.agent).emit('agent/error', { turn: closed.data.turn, step: 1, error: new Error('late flush failure') })
|
||||
|
||||
expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined()
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session'))
|
||||
@@ -959,7 +961,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('waits for work queued by a pause observer before considering the next round', async () => {
|
||||
const test = await harness(['hang', textResponse('inspection answer')])
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
test.ctx.on('goal/changed', ({ agent, change }) => {
|
||||
if (agent === test.agent && change.operation === 'pause') {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } }))
|
||||
}
|
||||
@@ -982,7 +984,7 @@ describe('same-session goal driving', () => {
|
||||
it('does not re-block a goal the downstream veto already saw cancelled', async () => {
|
||||
const test = await harness([])
|
||||
let vetoed = false
|
||||
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', ({ agent, messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && !vetoed) {
|
||||
vetoed = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -1007,7 +1009,7 @@ describe('same-session goal driving', () => {
|
||||
it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => {
|
||||
const test = await harness([])
|
||||
let release: (() => void) | undefined
|
||||
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
test.ctx.on('agent/pre-step', async ({ messages }, next) => {
|
||||
if (messages[0]?.source.kind === 'goal' && release === undefined) {
|
||||
await new Promise<void>((resolve) => { release = resolve })
|
||||
}
|
||||
|
||||
@@ -134,10 +134,10 @@ declare module 'cordis' {
|
||||
* Goal mutation accepted by one live agent. The matching `goal/change`
|
||||
* session event has already committed. Listener failures are contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - agent whose session owns the goal.
|
||||
* @param change - fresh current projection or clear tombstone.
|
||||
* @param payload.agent - agent whose session owns the goal.
|
||||
* @param payload.change - fresh current projection or clear tombstone.
|
||||
* @mode emit
|
||||
*/
|
||||
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
|
||||
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export class GoalService extends Service {
|
||||
this.resolved = {
|
||||
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
|
||||
}
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
ctx.on('agent/session-start', ({ agent }) => {
|
||||
this.cache(agent.session).activation = 'disarmed'
|
||||
})
|
||||
// The `goal` projection unit: last-wins fold of goal/change whole values
|
||||
@@ -547,7 +547,7 @@ export class GoalService extends Service {
|
||||
ref: { ...ref },
|
||||
...goal === undefined ? {} : { goal },
|
||||
}
|
||||
agentEvents(this.ctx, agent).emit('goal/changed', notification)
|
||||
agentEvents(this.ctx, agent).emit('goal/changed', { change: notification })
|
||||
}
|
||||
|
||||
/** Build a detached current view. */
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('GoalService creation and replay', () => {
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
|
||||
const seen: string[] = []
|
||||
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
|
||||
ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) })
|
||||
|
||||
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('GoalService creation and replay', () => {
|
||||
const { ctx, agent, session } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
|
||||
expect(goal.activation).toBe('armed')
|
||||
agentEvents(ctx, agent).emit('agent/session-start', 'resume')
|
||||
agentEvents(ctx, agent).emit('agent/session-start', { source: 'resume' })
|
||||
expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
|
||||
@@ -223,7 +223,7 @@ describe('GoalService creation and replay', () => {
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume')
|
||||
agentEvents(ctx, stub.agent).emit('agent/session-start', { source: 'resume' })
|
||||
expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
|
||||
|
||||
await ctx.plugin(GoalService)
|
||||
@@ -384,7 +384,7 @@ describe('GoalService mutations', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: string[] = []
|
||||
ctx.on('goal/changed', () => { throw new Error('broken observer') })
|
||||
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
|
||||
ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) })
|
||||
expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active')
|
||||
expect(seen).toEqual(['create'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('goal tool state transitions', () => {
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'continue later' })
|
||||
closeTurn(root, turn)
|
||||
agentEvents(ctx, root.agent).emit('agent/session-start', 'resume')
|
||||
agentEvents(ctx, root.agent).emit('agent/session-start', { source: 'resume' })
|
||||
expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
|
||||
turn = openTurn(root, { kind: 'user' }, '继续')
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
|
||||
Reference in New Issue
Block a user