refactor(agent): complete inbox lifecycle migration
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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' } }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Goal 变更不会注入模型上下文。Goal 工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
|
||||
Goal 变更不会注入模型上下文。`get_goal` 等工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
Reference in New Issue
Block a user