refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions
@@ -66,7 +66,11 @@ function createContext(contextWindow = 1_000): Context {
}
function agent(session: Session, model?: string): Agent {
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
return {
session,
options: model === undefined ? {} : { provider: model, model },
retry() {},
} as Agent
}
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
@@ -1263,22 +1267,23 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => {
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal)
return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal)
}
function recover(
ctx: Context,
owner: Agent,
error: Error & { code?: string },
retryAttempt = 0,
signal = SIGNAL,
next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
): Promise<{ action: 'fail' | 'retry' }> {
next: () => Promise<void> = () => Promise.resolve(),
): Promise<boolean> {
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
let retried = false
owner.retry = () => { retried = true }
return agentEvents(ctx, owner).waterfall(
'agent/request-error', 1, 1, error, failure, priorFailures, signal, next,
)
'agent/request-error', turn, 1, error, failure, signal, next,
).then(() => retried)
}
function overflow(message = 'provider overflow'): Error & { code: string } {
@@ -1383,7 +1388,7 @@ describe('automatic listener and loader composition', () => {
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
expect(decision).toEqual({ action: 'retry' })
expect(decision).toBe(true)
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.surface.nodes).toContain(retainedSeq)
@@ -1402,7 +1407,7 @@ describe('automatic listener and loader composition', () => {
})
const session = oversizedToolResult()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
expect(compact.calls).toHaveLength(0)
@@ -1421,7 +1426,7 @@ describe('automatic listener and loader composition', () => {
})
const session = toolConversation()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(compact.calls).toHaveLength(1)
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
@@ -1443,7 +1448,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
@@ -1467,8 +1472,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary cancelled after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(1)
})
@@ -1482,7 +1486,7 @@ describe('automatic listener and loader composition', () => {
const newestAssistant = session.surface.nodes.at(-2)!
const newestResult = session.surface.nodes.at(-1)!
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
const currentResult = session.surface.nodes.find(node => node === newestResult)
expect(currentAssistant).toBeDefined()
@@ -1506,7 +1510,7 @@ describe('automatic listener and loader composition', () => {
}
vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.surface.replaceGeneration).toBe(0)
})
@@ -1521,7 +1525,6 @@ describe('automatic listener and loader composition', () => {
ctx,
agent(conversation(2), MODEL),
overflow(),
0,
SIGNAL,
() => {
calls += 1
@@ -1539,7 +1542,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable')
const original = overflow('original provider overflow')
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
expect(original).toMatchObject({
message: 'original provider overflow',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
@@ -1558,12 +1561,12 @@ describe('automatic listener and loader composition', () => {
const original = overflow('original provider failure')
let delegations = 0
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => {
delegations += 1
return Promise.resolve({ action: 'fail' })
return Promise.resolve()
})
expect(decision).toEqual({ action: 'fail' })
expect(decision).toBe(false)
expect(delegations).toBe(1)
expect(session.surface.replaceGeneration).toBe(generation)
expect(original).toMatchObject({
@@ -1582,7 +1585,7 @@ describe('automatic listener and loader composition', () => {
reason: 'resume',
})
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
.toEqual({ action: 'retry' })
.toBe(true)
})
it('delegates canonical overflow when no durable routed target exists', async () => {
@@ -1594,21 +1597,19 @@ describe('automatic listener and loader composition', () => {
trigger: { kind: 'message', source: { kind: 'user' } },
})
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' })
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
})
it('honors retry caps, non-context failures, and cancellation', async () => {
it('honors retry caps and ignores non-context failures', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
.toEqual({ action: 'fail' })
expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' })
const controller = new AbortController()
controller.abort('cancelled')
expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' })
.toBe(false)
expect(await recover(ctx, owner, overflow())).toBe(true)
compactSpy.mockClear()
expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1623,9 +1624,11 @@ describe('automatic listener and loader composition', () => {
}],
})
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1))
.toEqual({ action: 'fail' })
expect(await recover(ctx, owner, overflow())).toBe(true)
compactSpy.mockClear()
expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1637,8 +1640,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(3)
const generation = session.surface.replaceGeneration
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(generation + 1)
})
@@ -1653,7 +1655,7 @@ describe('automatic listener and loader composition', () => {
await postStep(ctx, agent(session, MODEL))
const summaries = session.events.filter(event => event.type === 'compact/summary').length
expect(summaries).toBe(1)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries)
})
@@ -1667,7 +1669,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
it('loads and disposes the real zero-config service stack', async () => {
@@ -1696,6 +1698,6 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
})