refactor(agent): trim obsolete loop surfaces

This commit is contained in:
_Kerman
2026-07-24 21:58:07 +08:00
parent 194b18a32f
commit 009d113e0e
40 changed files with 252 additions and 288 deletions
+8
View File
@@ -176,6 +176,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
retries.delete(agent)
})
// A completed model response ends the consecutive-failure sequence even
// when its tool calls keep the turn running into another request.
ctx.on('session/event', (session, event) => {
if (event.type !== 'assistant/message') return
const agent = ctx.agents.get(session.id)
if (agent?.session === session) retries.delete(agent)
})
const disposeListener = ctx.on('agent/request-error', (
agent: Agent,
turn: number,
+3 -1
View File
@@ -88,7 +88,9 @@ function validateRetry(
}
const chainStart = retryChainStart(history, turn)
const chainRetries = history.slice(Math.max(chainStart, 0))
const chain = history.slice(Math.max(chainStart, 0))
const lastSuccess = chain.findLastIndex(prior => prior.type === 'assistant/message')
const chainRetries = chain.slice(lastSuccess + 1)
.filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry')
if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) {
fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`)
@@ -102,7 +102,6 @@ describe('real Loader composition', () => {
loaded.llm.registerAdapter(['mock'], adapter)
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'recover' }])
await expect.poll(() => adapter.requests).toBe(2)
await agent.whenIdle()
expect(adapter.requests).toBe(2)
@@ -50,6 +50,16 @@ function textResponse(text: string): StreamChunk[] {
]
}
function toolResponse(callId: string, name: string): StreamChunk[] {
const id = CallId(callId)
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id, name, argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
async function harness(
adapter: LlmAdapter,
config: retry.Config = {},
@@ -263,6 +273,43 @@ describe('bounded transient retry policy', () => {
expect(adapter.requests).toHaveLength(4)
})
it('resets the retry budget after a successful tool-call response within the same drain', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('first busy', 'SERVER'),
toolResponse('work-1', 'work'),
new LlmError('second busy', 'SERVER'),
textResponse('done'),
])
;({ ctx: context } = await harness(adapter, { maxTransientRetries: 1 }))
context.tools.register(defineContentToolFixture({
name: 'work',
description: 'continue into another model step',
parameters: {},
async execute() {
return [{ type: 'text', text: 'worked' }]
},
}))
const agent = context.agentLoop.create(SessionId('retry-reset-after-success'), {
provider: 'mock',
model: 'mock',
})
const firstRetry = waitForRetry(context, agent, 1)
agent.followup([{ type: 'text', text: 'go' }])
await firstRetry
const secondRetry = waitForRetry(context, agent, 1)
await vi.advanceTimersByTimeAsync(500)
await secondRetry
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(500)
await idle
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.retry))
.toEqual([1, 1])
expect(adapter.requests).toHaveLength(4)
})
it('accepts the zero-delay lower jitter bound', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
+1 -2
View File
@@ -204,8 +204,7 @@ export interface GenerateOptions {
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
* `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a
* hand-built one-shot passes any list.
* the derived history (dsh-agent-loop); a hand-built one-shot passes any list.
*/
messages: Message[]
/** System prompt text (adapters map to the provider's system slot). */
-1
View File
@@ -378,7 +378,6 @@ export class TokenMeterService extends Service {
private _estimateHeader(header: EpochHeader | undefined): number {
if (header === undefined) return 0
let tokens = 0
for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message)
if (header.system !== undefined) {
tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
}