refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -34,6 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
steer: () => {},
inject(input) { inbox.append('next-step', input) },
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session }

View File

@@ -155,7 +155,6 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
if (attempt.phase === 'queued' || attempt.phase === 'claimed') return
state.attempt = undefined
state.needsCheckpoint = true
state.requested = true
@@ -346,10 +345,10 @@ export function apply(ctx: Context): void {
}
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
const submitted = messages.find(message => isGoalRoundSource(message.source))
const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } =>
isGoalRoundSource(message.source))
if (submitted === undefined) return next()
const { content, source } = submitted
if (!isGoalRoundSource(source)) return next()
const state = stateFor(agent)
let valid = false
try {
@@ -377,11 +376,8 @@ export function apply(ctx: Context): void {
// returns to idle without a turn, so a still-queued reservation would
// starve every later drive pass. Clear it and let the driver
// reschedule the round.
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'claimed') {
state.attempt = undefined
requestDrive(state)
}
state.attempt = undefined
requestDrive(state)
throw error
}
if (signal.aborted) {
@@ -389,8 +385,7 @@ export function apply(ctx: Context): void {
return decision
}
if (decision.kind === 'reject') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined
state.attempt = undefined
const goal = currentGoal(state)
if (goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
@@ -409,11 +404,7 @@ export function apply(ctx: Context): void {
valid = false
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
state.attempt = undefined
restoreOtherClaimed(agent, decision.messages, submitted.id)
requestDrive(state)
return { kind: 'reject' }
@@ -438,8 +429,8 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
attempt.stale = true
if ((attempt.phase === 'claimed' || attempt.phase === 'admitted')
&& state.agent.status === 'running') {
/* v8 ignore next -- followup reserves the live agent before publishing a queued attempt */
if (state.agent.status === 'running') {
state.agent.cancel({ kind: 'parent' })
waits.push(state.agent.whenIdle())
}

View File

@@ -387,12 +387,35 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
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) => {
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) {
return next()
}
const goal = test.ctx.goals.get(agent)
if (goal === undefined) throw new Error('missing goal before downstream pause')
test.ctx.goals.pause(agent, { id: goal.id, revision: goal.revision })
return { kind: 'reject' as const }
})
test.ctx.goals.create(test.agent, { objective: 'pause before rejection' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
expect(goal).toMatchObject({ phase: 'paused' })
expect(test.adapter.requests).toEqual([])
})
it('restores non-goal step context when a claimed reservation becomes stale', async () => {
const test = await harness([textResponse('side contexts'), textResponse('revised goal')])
const claimedContext = createUserMessage({
content: [{ type: 'text', text: 'claimed context to restore' }],
source: { kind: 'plugin', plugin: 'test' },
})
const roundZeroContext = createUserMessage({
content: [{ type: 'text', text: 'obsolete goal context' }],
source: { kind: 'goal', goalId: GoalId('old-goal'), revision: 1, round: 0 },
})
const queuedStepContext = createUserMessage({
content: [{ type: 'text', text: 'context already queued for the next step' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -406,6 +429,7 @@ describe('same-session goal driving', () => {
if (message.source.kind !== 'goal' || message.source.round <= 0 || staged) return
staged = true
test.agent.inbox.prepend('next-step', claimedContext)
test.agent.inbox.prepend('next-step', roundZeroContext)
})
let edited = false
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
@@ -432,6 +456,7 @@ describe('same-session goal driving', () => {
expect(requestText(test.adapter.requests[0]!)).toContain('claimed context to restore')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next step')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next turn')
expect(requestText(test.adapter.requests[0]!)).not.toContain('obsolete goal context')
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('revised after claim')
expect(requestText(test.adapter.requests[1]!)).not.toContain('stale before admission')
@@ -722,6 +747,19 @@ describe('same-session goal driving', () => {
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('leaves round-zero goal context to the ordinary pre-step chain', async () => {
const test = await harness([textResponse('accepted context')])
test.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'goal context' }],
source: { kind: 'goal', goalId: GoalId('context-goal'), revision: 1, round: 0 },
}))
await test.agent.whenIdle()
expect(test.adapter.requests).toHaveLength(1)
expect(requestText(test.adapter.requests[0]!)).toContain('goal context')
})
it('does not invent goal state when ordinary queued work is cancelled', async () => {
const test = await harness([])
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancel ordinary work' }], source: { kind: 'user' } }))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/goal/README.md
README.md: caf01b3d2a088281749a73b78b839d60ac041316
README.zh.md: de92b8b9d7757f80511fe128644738ebe2458af1
README.md: fc2a672c11c68ad72437251b087a274e1c4388d3
README.zh.md: e84e8ea78039637a4b0bff7e97cef69ae607cda0

View File

@@ -39,7 +39,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
#### What the model sees
Goal mutations do not inject model context. Goal tools return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path.
Goal mutations do not inject model context. Tools such as `get_goal` return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path.
#### Token effect

View File

@@ -39,7 +39,7 @@
#### 模型看到的内容
Goal 变更不会注入模型上下文。Goal 工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
Goal 变更不会注入模型上下文。`get_goal` 等工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
#### Token 影响

View File

@@ -43,6 +43,7 @@ function stubAgentForSession(session: Session): StubAgent {
steer: () => {},
inject(input) { inbox.append('next-step', input) },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return {

View File

@@ -46,6 +46,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
inbox.append('next-step', input)
},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
ctx.agents.register(agent)

View File

@@ -39,6 +39,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
this.inbox.append('next-step', input)
},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session, setStatus(value) { status = value } }