refactor(agent): trim obsolete loop surfaces
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user