mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* CBR-001 regression through the real loop. A replacement checkpoint has a high
|
||||
@@ -165,33 +165,37 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function seedOverflowHistory(agent: Agent): void {
|
||||
function overflowHistorySeed(): SessionEvent[] {
|
||||
const session = new Session(SessionId('overflow-history-seed'))
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
agent.session.append('turn/start', {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
agent.session.append('user/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn, step: 1 })
|
||||
agent.session.append('assistant/message', {
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/end', { turn, step: 1 })
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
return [...session.events]
|
||||
}
|
||||
|
||||
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
|
||||
it('uses the model actually routed by agent/request for post-step pressure', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
@@ -211,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
||||
it('runs automatic pressure between the completed tool step and the next step', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
@@ -225,13 +229,19 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
event.type === 'tool/result' && event.seq < compactStart!.seq,
|
||||
)
|
||||
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
|
||||
const stepEnd = events.find(event =>
|
||||
const precedingStepEnd = events.find(event =>
|
||||
event.type === 'step/end'
|
||||
&& event.data.step === precedingResult.data.step
|
||||
&& event.seq > precedingResult.seq,
|
||||
)
|
||||
const nextStepStart = events.find(event =>
|
||||
event.type === 'step/start'
|
||||
&& event.data.step === precedingResult.data.step + 1
|
||||
&& event.seq > compactStart!.seq,
|
||||
)
|
||||
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(compactStart!.seq).toBeLessThan(stepEnd!.seq)
|
||||
expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -281,7 +291,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
@@ -291,11 +303,14 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId(`overflow-${delivery}`),
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
},
|
||||
})
|
||||
seedOverflowHistory(agent)
|
||||
|
||||
agent.followup([{ type: 'text', text: 'continue from history' }])
|
||||
await agent.whenIdle()
|
||||
@@ -308,11 +323,17 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
expect(retry).not.toContain('OLD HISTORY SENTINEL')
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const failedEnd = events.find(event =>
|
||||
const failedStepEnd = events.find(event =>
|
||||
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
|
||||
)!
|
||||
const failedEnd = events.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === 3,
|
||||
)!
|
||||
const retryStart = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2,
|
||||
event.type === 'turn/start' && event.data.turn === 4,
|
||||
)!
|
||||
const retryStep = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1,
|
||||
)!
|
||||
const compaction = events.filter(event =>
|
||||
event.type === 'compact/start'
|
||||
@@ -324,7 +345,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
'compact/summary',
|
||||
'compact/end',
|
||||
])
|
||||
expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true)
|
||||
expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
|
||||
expect(compaction.every(event =>
|
||||
event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
|
||||
)).toBe(true)
|
||||
expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
@@ -358,17 +383,21 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
|
||||
seedOverflowHistory(agent)
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId('alternating-recovery'),
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup([{ type: 'text', text: 'continue from history' }])
|
||||
await expect.poll(() => adapter.conversationRequests.length).toBe(3)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(3)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
|
||||
.toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
|
||||
.toEqual([1, 2, 3])
|
||||
.toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn))
|
||||
.toEqual([3, 4, 5])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
|
||||
Reference in New Issue
Block a user