request-error.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import AgentRegistry from '@deepseek-ai/dsh-agent'
  4. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  5. import LlmService, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm'
  6. import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry from '@deepseek-ai/dsh-tools'
  10. import { MockAdapter, textResponse } from './mock-adapter.ts'
  11. async function harness(adapter: MockAdapter): Promise<Context> {
  12. const ctx = new Context()
  13. await ctx.plugin(LlmService)
  14. await ctx.plugin(SessionStore)
  15. await ctx.plugin(SystemPrompt)
  16. await ctx.plugin(ToolRegistry)
  17. await ctx.plugin(AgentRegistry)
  18. await ctx.plugin(AgentLoop, { agents: [] })
  19. ctx.llm.registerAdapter(['mock'], adapter)
  20. return ctx
  21. }
  22. function fail(message: string, code: string): () => never {
  23. return () => {
  24. throw new LlmError(message, code)
  25. }
  26. }
  27. describe('agent/request-error', () => {
  28. it('does not offer middleware failures to request recovery', async () => {
  29. const adapter = new MockAdapter([textResponse('unused')])
  30. const ctx = await harness(adapter)
  31. const agent = ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' })
  32. let recoveries = 0
  33. ctx.on('agent/request', () => {
  34. throw new LlmError('middleware failed', 'MIDDLEWARE')
  35. })
  36. ctx.on('agent/request-error', async () => {
  37. recoveries += 1
  38. })
  39. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  40. await agent.whenIdle()
  41. expect(recoveries).toBe(0)
  42. expect(adapter.requests).toHaveLength(0)
  43. })
  44. it('lets each failed request return a retry action before its turn closes', async () => {
  45. const adapter = new MockAdapter([
  46. fail('busy', 'RATE_LIMIT'),
  47. fail('unavailable', 'SERVICE_UNAVAILABLE'),
  48. textResponse('ok'),
  49. ])
  50. const ctx = await harness(adapter)
  51. const agent = ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' })
  52. const seen: {
  53. turn: number
  54. step: number
  55. failure: LlmFailure
  56. priorFailures: readonly LlmFailure[]
  57. retryPolicy: ResolvedRetryPolicy | undefined
  58. }[] = []
  59. const statuses: string[] = []
  60. ctx.on('agent/status', (subject, status) => {
  61. if (subject === agent) statuses.push(status)
  62. })
  63. ctx.on('agent/request-error', async (
  64. subject, turn, step, _error, failure, priorFailures, retryPolicy,
  65. ) => {
  66. expect(subject).toBe(agent)
  67. expect(agent.session.events.at(-1)).toMatchObject({
  68. type: 'step/end',
  69. data: { turn, step },
  70. })
  71. seen.push({ turn, step, failure, priorFailures, retryPolicy })
  72. return { kind: 'retry' }
  73. })
  74. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  75. await agent.whenIdle()
  76. expect(seen.map(item => ({
  77. turn: item.turn,
  78. step: item.step,
  79. code: item.failure.code,
  80. }))).toEqual([
  81. {
  82. turn: 1,
  83. step: 1,
  84. code: 'RATE_LIMIT',
  85. },
  86. {
  87. turn: 2,
  88. step: 1,
  89. code: 'SERVICE_UNAVAILABLE',
  90. },
  91. ])
  92. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  93. expect(seen.map(item => item.priorFailures.map(failure => failure.code)))
  94. .toEqual([[], ['RATE_LIMIT']])
  95. expect(seen.map(item => item.retryPolicy)).toEqual([
  96. expect.objectContaining({ mode: 'normal' }),
  97. expect.objectContaining({ mode: 'normal' }),
  98. ])
  99. expect(statuses).toEqual(['running', 'idle'])
  100. })
  101. it('lets cancellation win over a retry action', async () => {
  102. const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
  103. const ctx = await harness(adapter)
  104. const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' })
  105. ctx.on('agent/request-error', async (subject) => {
  106. subject.cancel({ kind: 'user' })
  107. return { kind: 'retry' }
  108. })
  109. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  110. await agent.whenIdle()
  111. expect(adapter.requests).toHaveLength(1)
  112. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  113. expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({
  114. type: 'turn/end',
  115. data: { reason: { kind: 'aborted' } },
  116. })
  117. })
  118. it('does not retry when the recovery listener fails before returning its action', async () => {
  119. const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
  120. const ctx = await harness(adapter)
  121. const agent = ctx.agentLoop.create(SessionId('request-error-recovery-failed'), {
  122. provider: 'mock',
  123. model: 'mock',
  124. })
  125. ctx.on('agent/request-error', async () => {
  126. throw new Error('recovery failed')
  127. })
  128. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  129. await agent.whenIdle()
  130. expect(adapter.requests).toHaveLength(1)
  131. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  132. expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({
  133. type: 'turn/end',
  134. data: { reason: { kind: 'error' } },
  135. })
  136. })
  137. })