@roj-ai/sdk 0.1.29 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/core/agents/agent.d.ts +28 -0
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js +116 -39
  4. package/dist/core/agents/agent.js.map +1 -1
  5. package/dist/core/agents/config.d.ts +9 -0
  6. package/dist/core/agents/config.d.ts.map +1 -1
  7. package/dist/core/agents/state.d.ts +9 -0
  8. package/dist/core/agents/state.d.ts.map +1 -1
  9. package/dist/core/agents/state.js +4 -0
  10. package/dist/core/agents/state.js.map +1 -1
  11. package/dist/core/context/state.d.ts +22 -13
  12. package/dist/core/context/state.d.ts.map +1 -1
  13. package/dist/core/context/state.js +78 -22
  14. package/dist/core/context/state.js.map +1 -1
  15. package/dist/core/error-recovery.integration.test.d.ts +2 -0
  16. package/dist/core/error-recovery.integration.test.d.ts.map +1 -0
  17. package/dist/core/error-recovery.integration.test.js +288 -0
  18. package/dist/core/error-recovery.integration.test.js.map +1 -0
  19. package/dist/core/llm/anthropic.d.ts.map +1 -1
  20. package/dist/core/llm/anthropic.js +6 -1
  21. package/dist/core/llm/anthropic.js.map +1 -1
  22. package/dist/core/llm/anthropic.test.js +7 -4
  23. package/dist/core/llm/anthropic.test.js.map +1 -1
  24. package/dist/core/llm/message-sanitization.d.ts +10 -0
  25. package/dist/core/llm/message-sanitization.d.ts.map +1 -0
  26. package/dist/core/llm/message-sanitization.js +53 -0
  27. package/dist/core/llm/message-sanitization.js.map +1 -0
  28. package/dist/core/llm/message-sanitization.test.d.ts +2 -0
  29. package/dist/core/llm/message-sanitization.test.d.ts.map +1 -0
  30. package/dist/core/llm/message-sanitization.test.js +181 -0
  31. package/dist/core/llm/message-sanitization.test.js.map +1 -0
  32. package/dist/core/llm/middleware.d.ts +3 -3
  33. package/dist/core/llm/middleware.js +3 -3
  34. package/dist/core/llm/openrouter-mapping.test.js +21 -3
  35. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  36. package/dist/core/llm/openrouter.d.ts.map +1 -1
  37. package/dist/core/llm/openrouter.js +6 -1
  38. package/dist/core/llm/openrouter.js.map +1 -1
  39. package/dist/core/llm/provider.d.ts +13 -10
  40. package/dist/core/llm/provider.d.ts.map +1 -1
  41. package/dist/core/llm/provider.js.map +1 -1
  42. package/dist/core/llm/state.d.ts +10 -0
  43. package/dist/core/llm/state.d.ts.map +1 -1
  44. package/dist/core/llm/state.js +9 -0
  45. package/dist/core/llm/state.js.map +1 -1
  46. package/dist/core/plugins/plugin-builder.d.ts +2 -0
  47. package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
  48. package/dist/core/plugins/plugin-builder.js.map +1 -1
  49. package/dist/core/sessions/session.d.ts.map +1 -1
  50. package/dist/core/sessions/session.js +6 -0
  51. package/dist/core/sessions/session.js.map +1 -1
  52. package/dist/core/sessions/state.d.ts.map +1 -1
  53. package/dist/core/sessions/state.js +43 -5
  54. package/dist/core/sessions/state.js.map +1 -1
  55. package/dist/plugins/context-compact/context-compact.integration.test.js +36 -20
  56. package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
  57. package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
  58. package/dist/plugins/context-compact/context-compactor.js +54 -9
  59. package/dist/plugins/context-compact/context-compactor.js.map +1 -1
  60. package/dist/plugins/context-compact/context-compactor.test.js +127 -4
  61. package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
  62. package/dist/plugins/limits-guard/config.d.ts +2 -0
  63. package/dist/plugins/limits-guard/config.d.ts.map +1 -1
  64. package/dist/plugins/limits-guard/limit-guard.d.ts +1 -0
  65. package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
  66. package/dist/plugins/limits-guard/limit-guard.js +11 -1
  67. package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
  68. package/dist/plugins/limits-guard/limit-guard.test.js +9 -0
  69. package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
  70. package/dist/plugins/limits-guard/limits-guard.integration.test.js +216 -1
  71. package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
  72. package/dist/plugins/limits-guard/plugin.d.ts +3 -1
  73. package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
  74. package/dist/plugins/limits-guard/plugin.js +48 -2
  75. package/dist/plugins/limits-guard/plugin.js.map +1 -1
  76. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  77. package/dist/plugins/uploads/plugin.js +10 -1
  78. package/dist/plugins/uploads/plugin.js.map +1 -1
  79. package/dist/plugins/uploads/uploads.integration.test.js +74 -28
  80. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  81. package/dist/testing/test-harness.d.ts +8 -0
  82. package/dist/testing/test-harness.d.ts.map +1 -1
  83. package/dist/testing/test-harness.js +18 -0
  84. package/dist/testing/test-harness.js.map +1 -1
  85. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  86. package/package.json +2 -2
  87. package/src/core/agents/agent.ts +124 -36
  88. package/src/core/agents/config.ts +6 -0
  89. package/src/core/agents/state.ts +9 -0
  90. package/src/core/context/state.ts +86 -27
  91. package/src/core/error-recovery.integration.test.ts +343 -0
  92. package/src/core/llm/anthropic.test.ts +7 -4
  93. package/src/core/llm/anthropic.ts +6 -1
  94. package/src/core/llm/message-sanitization.test.ts +217 -0
  95. package/src/core/llm/message-sanitization.ts +70 -0
  96. package/src/core/llm/middleware.ts +3 -3
  97. package/src/core/llm/openrouter-mapping.test.ts +24 -3
  98. package/src/core/llm/openrouter.ts +7 -8
  99. package/src/core/llm/provider.ts +14 -10
  100. package/src/core/llm/state.ts +10 -0
  101. package/src/core/plugins/plugin-builder.ts +6 -2
  102. package/src/core/sessions/session.ts +6 -0
  103. package/src/core/sessions/state.ts +48 -9
  104. package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
  105. package/src/plugins/context-compact/context-compactor.test.ts +150 -4
  106. package/src/plugins/context-compact/context-compactor.ts +64 -10
  107. package/src/plugins/limits-guard/config.ts +2 -0
  108. package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
  109. package/src/plugins/limits-guard/limit-guard.ts +13 -1
  110. package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
  111. package/src/plugins/limits-guard/plugin.ts +53 -3
  112. package/src/plugins/uploads/plugin.ts +11 -1
  113. package/src/plugins/uploads/uploads.integration.test.ts +82 -28
  114. package/src/testing/test-harness.ts +20 -0
@@ -0,0 +1,343 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import z from 'zod/v4'
3
+ import type { AgentStatus } from '~/core/agents/schema.js'
4
+ import { agentEvents } from '~/core/agents/state.js'
5
+ import { MockLLMProvider } from '~/core/llm/mock.js'
6
+ import { llmEvents } from '~/core/llm/state.js'
7
+ import { definePlugin } from '~/core/plugins/plugin-builder.js'
8
+ import { createTool } from '~/core/tools/definition.js'
9
+ import { ToolCallId } from '~/core/tools/schema.js'
10
+ import { toolEvents } from '~/core/tools/state.js'
11
+ import { Ok } from '~/lib/utils/result.js'
12
+ import type { TestSession } from '~/testing/index.js'
13
+ import { createTestPreset, TestHarness } from '~/testing/index.js'
14
+
15
+ /** Poll until the entry agent reaches the given status (or time out). */
16
+ async function waitForAgentStatus(session: TestSession, status: AgentStatus, timeoutMs = 5000): Promise<void> {
17
+ const agentId = session.getEntryAgentId()!
18
+ const deadline = Date.now() + timeoutMs
19
+ while (Date.now() < deadline) {
20
+ if (session.state.agents.get(agentId)?.status === status) return
21
+ await new Promise((r) => setTimeout(r, 10))
22
+ }
23
+ throw new Error(`Agent did not reach status '${status}' within ${timeoutMs}ms`)
24
+ }
25
+
26
+ /** Count conversation-history messages whose content mentions the given text. */
27
+ function countHistoryMentions(session: TestSession, text: string): number {
28
+ const agentId = session.getEntryAgentId()!
29
+ const history = session.state.agents.get(agentId)!.conversationHistory
30
+ return history.filter((m) => JSON.stringify(m.content).includes(text)).length
31
+ }
32
+
33
+ describe('core: error recovery', () => {
34
+ // =========================================================================
35
+ // Retryable LLM errors — outer error-resume backoff
36
+ // =========================================================================
37
+
38
+ describe('retryable LLM error exhaustion', () => {
39
+ it('backs off between error-resume cycles, notifies parent hook once, and recovers without losing the message', async () => {
40
+ // Fail 10 LLM calls (2 inference cycles × 5 withLLMRetry attempts), then succeed.
41
+ let llmCalls = 0
42
+ const llmProvider = new MockLLMProvider(() => {
43
+ llmCalls++
44
+ if (llmCalls <= 10) {
45
+ throw { type: 'server_error', message: 'boom', retryAfterMs: 1 }
46
+ }
47
+ return {
48
+ content: 'Recovered',
49
+ toolCalls: [],
50
+ finishReason: 'stop' as const,
51
+ metrics: MockLLMProvider.defaultMetrics(),
52
+ }
53
+ })
54
+
55
+ let onErrorCalls = 0
56
+ const errorTracker = definePlugin('error-tracker')
57
+ .hook('onError', async () => {
58
+ onErrorCalls++
59
+ return null
60
+ })
61
+ .build()
62
+
63
+ const preset = createTestPreset()
64
+ preset.orchestrator.errorResumeBackoff = { baseDelayMs: 50, maxDelayMs: 100 }
65
+
66
+ const harness = new TestHarness({
67
+ presets: [preset],
68
+ llmProvider,
69
+ systemPlugins: [errorTracker],
70
+ })
71
+
72
+ const session = await harness.createSession('test')
73
+ await session.sendAndWaitForIdle('Trigger outage', { timeoutMs: 10_000 })
74
+
75
+ // The message survived the outage: delivered to the LLM exactly once, answered.
76
+ expect(countHistoryMentions(session, 'Trigger outage')).toBe(1)
77
+ expect(countHistoryMentions(session, 'Recovered')).toBe(1)
78
+ expect(llmCalls).toBe(11)
79
+
80
+ // Two failed cycles, each resumed exactly once — no unbounded spin.
81
+ const failed = await session.getEventsByType(llmEvents, 'inference_failed')
82
+ expect(failed).toHaveLength(2)
83
+ const resumed = await session.getEventsByType(agentEvents, 'agent_resumed')
84
+ expect(resumed).toHaveLength(2)
85
+
86
+ // The resume waited for the backoff: 50ms after the 1st failure, 100ms after the 2nd.
87
+ expect(resumed[0].timestamp - failed[0].timestamp).toBeGreaterThanOrEqual(45)
88
+ expect(resumed[1].timestamp - failed[1].timestamp).toBeGreaterThanOrEqual(90)
89
+
90
+ // onError hooks fired once per episode, not once per cycle.
91
+ expect(onErrorCalls).toBe(1)
92
+
93
+ await harness.shutdown()
94
+ })
95
+ })
96
+
97
+ // =========================================================================
98
+ // afterInference retry — token consumption deferred to commit
99
+ // =========================================================================
100
+
101
+ describe('afterInference retry', () => {
102
+ it('pure-message turn: retry re-delivers the message instead of stranding the agent', async () => {
103
+ const retryOnV1 = definePlugin('retry-on-v1')
104
+ .hook('afterInference', async (ctx) => {
105
+ if (ctx.response.content === 'v1') return { action: 'retry' as const }
106
+ return null
107
+ })
108
+ .build()
109
+
110
+ const harness = new TestHarness({
111
+ presets: [createTestPreset()],
112
+ llmProvider: MockLLMProvider.withSequence([
113
+ { content: 'v1' },
114
+ { content: 'v2' },
115
+ ]),
116
+ systemPlugins: [retryOnV1],
117
+ })
118
+
119
+ const session = await harness.createSession('test')
120
+ // Before the fix this stranded the agent in 'inferring' (mapped to idle) forever.
121
+ await session.sendAndWaitForIdle('Retry me')
122
+
123
+ expect(harness.llmProvider.getCallCount()).toBe(2)
124
+ const retried = await session.getEventsByType(llmEvents, 'inference_retried')
125
+ expect(retried).toHaveLength(1)
126
+
127
+ // The user message was staged exactly once and the retried response won.
128
+ expect(countHistoryMentions(session, 'Retry me')).toBe(1)
129
+ expect(countHistoryMentions(session, 'v2')).toBe(1)
130
+ expect(countHistoryMentions(session, 'v1')).toBe(0)
131
+
132
+ await harness.shutdown()
133
+ })
134
+
135
+ it('tool-result turn: retry does not duplicate tool results in history or in the LLM request', async () => {
136
+ const retryOnV1 = definePlugin('retry-on-v1')
137
+ .hook('afterInference', async (ctx) => {
138
+ if (ctx.response.content === 'v1') return { action: 'retry' as const }
139
+ return null
140
+ })
141
+ .build()
142
+
143
+ const harness = new TestHarness({
144
+ presets: [createTestPreset()],
145
+ llmProvider: MockLLMProvider.withSequence([
146
+ { toolCalls: [{ id: ToolCallId('tc1'), name: 'tell_user', input: { message: 'Hi!' } }] },
147
+ { content: 'v1' },
148
+ { content: 'v2' },
149
+ ]),
150
+ systemPlugins: [retryOnV1],
151
+ })
152
+
153
+ const session = await harness.createSession('test')
154
+ await session.sendAndWaitForIdle('Run a tool')
155
+
156
+ expect(harness.llmProvider.getCallCount()).toBe(3)
157
+
158
+ // The retried request carries the tool result exactly once.
159
+ const lastRequest = harness.llmProvider.getLastRequest()!
160
+ expect(lastRequest.messages.filter((m) => m.role === 'tool')).toHaveLength(1)
161
+
162
+ // And so does the committed history — duplicates would 400 on the next turn.
163
+ const agentId = session.getEntryAgentId()!
164
+ const history = session.state.agents.get(agentId)!.conversationHistory
165
+ expect(history.filter((m) => m.role === 'tool')).toHaveLength(1)
166
+ expect(countHistoryMentions(session, 'Run a tool')).toBe(1)
167
+
168
+ await harness.shutdown()
169
+ })
170
+ })
171
+
172
+ // =========================================================================
173
+ // Pause from tool hooks — turn stops, nothing re-executes
174
+ // =========================================================================
175
+
176
+ describe('pause from tool hooks', () => {
177
+ const makeCountingTool = (name: string, counter: { count: number }) =>
178
+ createTool({
179
+ name,
180
+ description: `Counting tool ${name}`,
181
+ input: z.unknown(),
182
+ execute: async () => {
183
+ counter.count++
184
+ return Ok(`${name} done`)
185
+ },
186
+ })
187
+
188
+ it('beforeToolCall pause stops the multi-tool turn; resume runs every tool exactly once', async () => {
189
+ const aRuns = { count: 0 }
190
+ const bRuns = { count: 0 }
191
+
192
+ let pausedOnce = false
193
+ const pauser = definePlugin('pauser')
194
+ .hook('beforeToolCall', async (ctx) => {
195
+ if (ctx.toolCall.name === 'tool_a' && !pausedOnce) {
196
+ pausedOnce = true
197
+ return { action: 'pause' as const, reason: 'gate' }
198
+ }
199
+ return null
200
+ })
201
+ .build()
202
+
203
+ const preset = createTestPreset()
204
+ preset.orchestrator.tools = [makeCountingTool('tool_a', aRuns), makeCountingTool('tool_b', bRuns)]
205
+
206
+ const harness = new TestHarness({
207
+ presets: [preset],
208
+ llmProvider: MockLLMProvider.withSequence([
209
+ {
210
+ toolCalls: [
211
+ { id: ToolCallId('ta'), name: 'tool_a', input: {} },
212
+ { id: ToolCallId('tb'), name: 'tool_b', input: {} },
213
+ ],
214
+ },
215
+ { content: 'Done', toolCalls: [] },
216
+ ]),
217
+ systemPlugins: [pauser],
218
+ })
219
+
220
+ const session = await harness.createSession('test')
221
+ await session.sendMessage('Run tools')
222
+ await waitForAgentStatus(session, 'paused')
223
+
224
+ // Neither tool ran: tool_a was gated pre-start, tool_b must not run while paused.
225
+ const agentId = session.getEntryAgentId()!
226
+ expect(aRuns.count).toBe(0)
227
+ expect(bRuns.count).toBe(0)
228
+ expect(session.state.agents.get(agentId)!.pendingToolCalls).toHaveLength(2)
229
+
230
+ await session.resumeAgent(agentId)
231
+ await session.waitForIdle()
232
+
233
+ // After resume the whole turn ran exactly once per tool.
234
+ expect(aRuns.count).toBe(1)
235
+ expect(bRuns.count).toBe(1)
236
+ const completed = await session.getEventsByType(toolEvents, 'tool_completed')
237
+ expect(completed).toHaveLength(2)
238
+
239
+ await harness.shutdown()
240
+ })
241
+
242
+ it('afterToolCall pause commits the executed tool result and does not re-run it on resume', async () => {
243
+ const aRuns = { count: 0 }
244
+ const bRuns = { count: 0 }
245
+
246
+ let pausedOnce = false
247
+ const pauser = definePlugin('pauser')
248
+ .hook('afterToolCall', async (ctx) => {
249
+ if (ctx.toolCall.name === 'tool_a' && !pausedOnce) {
250
+ pausedOnce = true
251
+ return { action: 'pause' as const, reason: 'checkpoint' }
252
+ }
253
+ return null
254
+ })
255
+ .build()
256
+
257
+ const preset = createTestPreset()
258
+ preset.orchestrator.tools = [makeCountingTool('tool_a', aRuns), makeCountingTool('tool_b', bRuns)]
259
+
260
+ const harness = new TestHarness({
261
+ presets: [preset],
262
+ llmProvider: MockLLMProvider.withSequence([
263
+ {
264
+ toolCalls: [
265
+ { id: ToolCallId('ta'), name: 'tool_a', input: {} },
266
+ { id: ToolCallId('tb'), name: 'tool_b', input: {} },
267
+ ],
268
+ },
269
+ { content: 'Done', toolCalls: [] },
270
+ ]),
271
+ systemPlugins: [pauser],
272
+ })
273
+
274
+ const session = await harness.createSession('test')
275
+ await session.sendMessage('Run tools')
276
+ await waitForAgentStatus(session, 'paused')
277
+
278
+ // tool_a ran and its result committed (paired tool_use); tool_b is still pending.
279
+ const agentId = session.getEntryAgentId()!
280
+ const agentState = session.state.agents.get(agentId)!
281
+ expect(aRuns.count).toBe(1)
282
+ expect(bRuns.count).toBe(0)
283
+ expect(agentState.pendingToolCalls.map((tc) => tc.name)).toEqual(['tool_b'])
284
+ expect(agentState.pendingToolResults.map((r) => r.toolName)).toEqual(['tool_a'])
285
+
286
+ await session.resumeAgent(agentId)
287
+ await session.waitForIdle()
288
+
289
+ // tool_a's side effects were NOT repeated after resume.
290
+ expect(aRuns.count).toBe(1)
291
+ expect(bRuns.count).toBe(1)
292
+
293
+ await harness.shutdown()
294
+ })
295
+ })
296
+
297
+ // =========================================================================
298
+ // Manual pause during an in-flight inference
299
+ // =========================================================================
300
+
301
+ describe('manual pause during inference', () => {
302
+ it('pause landing mid-inference is preserved after the turn commits', async () => {
303
+ let firstCall = true
304
+ const llmProvider = new MockLLMProvider(async () => {
305
+ if (firstCall) {
306
+ firstCall = false
307
+ await new Promise((r) => setTimeout(r, 150))
308
+ return { content: 'Late reply', toolCalls: [], finishReason: 'stop' as const, metrics: MockLLMProvider.defaultMetrics() }
309
+ }
310
+ return { content: 'After resume', toolCalls: [], finishReason: 'stop' as const, metrics: MockLLMProvider.defaultMetrics() }
311
+ })
312
+
313
+ const harness = new TestHarness({
314
+ presets: [createTestPreset()],
315
+ llmProvider,
316
+ })
317
+
318
+ const session = await harness.createSession('test')
319
+ await session.sendMessage('Slow question')
320
+ await waitForAgentStatus(session, 'inferring')
321
+
322
+ const agentId = session.getEntryAgentId()!
323
+ await session.pauseAgent(agentId, 'user hit pause')
324
+
325
+ // Let the in-flight LLM call resolve and the turn commit.
326
+ await new Promise((r) => setTimeout(r, 300))
327
+
328
+ // The pause survived the inference_completed reducer — previously it was
329
+ // silently clobbered and the agent kept running.
330
+ const agentState = session.state.agents.get(agentId)!
331
+ expect(agentState.status).toBe('paused')
332
+ // The turn itself committed (the response is in history, message consumed).
333
+ expect(countHistoryMentions(session, 'Late reply')).toBe(1)
334
+ expect(countHistoryMentions(session, 'Slow question')).toBe(1)
335
+
336
+ await session.resumeAgent(agentId)
337
+ await session.waitForIdle()
338
+ expect(session.state.agents.get(agentId)!.status).toBe('pending')
339
+
340
+ await harness.shutdown()
341
+ })
342
+ })
343
+ })
@@ -354,12 +354,14 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
354
354
  { id: ToolCallId('call_2'), name: 'search', input: { query: 'weather' } },
355
355
  ],
356
356
  },
357
- ], 0)
357
+ { role: 'tool', toolCallId: ToolCallId('call_1'), content: 'Sunny' },
358
+ { role: 'tool', toolCallId: ToolCallId('call_2'), content: 'Found results' },
359
+ ], 2)
358
360
 
359
361
  const http = await createProvider().buildHttpRequest(buildRequest(messages))
360
362
  const msgs = getBodyMessages(http.body)
361
363
 
362
- const assistant = msgs[msgs.length - 1]
364
+ const assistant = msgs[msgs.length - 2]
363
365
  expect(assistant.role).toBe('assistant')
364
366
  expect(assistant.content.length).toBe(3) // text + 2 tool_use
365
367
  expect(assistant.content[0].type).toBe('text')
@@ -411,8 +413,9 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
411
413
  { role: 'assistant', content: 'preamble part B' }, // last preamble msg → prefix breakpoint
412
414
  { role: 'user', content: 'conversation turn' },
413
415
  { role: 'assistant', content: 'tail reply' }, // tail breakpoint
416
+ { role: 'user', content: 'tail continuation' },
414
417
  ],
415
- 0, // no uncached suffix → tailIdx = 3
418
+ 1, // one uncached user suffix → tailIdx = 3
416
419
  undefined,
417
420
  2, // cachedPrefixCount → prefixIdx = 1
418
421
  )
@@ -420,7 +423,7 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
420
423
  const http = await createProvider().buildHttpRequest(buildRequest(messages))
421
424
  const msgs = getBodyMessages(http.body)
422
425
 
423
- expect(msgs.length).toBe(4)
426
+ expect(msgs.length).toBe(5)
424
427
  // Prefix breakpoint on the last preamble message
425
428
  const prefix = msgs[1]
426
429
  expect(prefix.role).toBe('assistant')
@@ -17,6 +17,7 @@ import type {
17
17
  RawInferenceRequest,
18
18
  RawToolSpec,
19
19
  } from './provider.js'
20
+ import { ProviderMessageValidationError, sanitizeProviderMessages } from './message-sanitization.js'
20
21
  import type { RoutableLLMProvider } from './routing-provider.js'
21
22
 
22
23
  // ============================================================================
@@ -321,7 +322,8 @@ export class AnthropicProvider implements RoutableLLMProvider {
321
322
  }
322
323
 
323
324
  async buildHttpRequest(request: RawInferenceRequest, context?: InferenceContext): Promise<ProviderHttpRequest> {
324
- const mappedMessages = await Promise.all(request.messages.map((m) => this.mapMessage(m, context)))
325
+ const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
326
+ const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
325
327
  const mergedMessages = this.mergeConsecutiveMessages(mappedMessages)
326
328
 
327
329
  const body: AnthropicRequestBody = {
@@ -574,6 +576,9 @@ export class AnthropicProvider implements RoutableLLMProvider {
574
576
  }
575
577
 
576
578
  private mapError(err: unknown): LLMError {
579
+ if (err instanceof ProviderMessageValidationError) {
580
+ return { type: 'invalid_request', message: err.message }
581
+ }
577
582
  if (err instanceof Error && err.name === 'AbortError') {
578
583
  return { type: 'aborted', message: 'Request was aborted' }
579
584
  }
@@ -0,0 +1,217 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { LLMMessage } from '~/core/agents/state.js'
3
+ import { ToolCallId } from '~/core/tools/schema.js'
4
+ import type { LogContext, Logger } from '~/lib/logger/logger.js'
5
+ import { AnthropicProvider } from './anthropic.js'
6
+ import { sanitizeProviderMessages } from './message-sanitization.js'
7
+ import { OpenRouterProvider } from './openrouter.js'
8
+ import type { RawInferenceRequest } from './provider.js'
9
+ import { ModelId } from './schema.js'
10
+
11
+ type CapturedWarning = {
12
+ message: string
13
+ context?: LogContext
14
+ }
15
+
16
+ const createCapturingLogger = (): { logger: Logger; warnings: CapturedWarning[] } => {
17
+ const warnings: CapturedWarning[] = []
18
+ const logger: Logger = {
19
+ debug: () => {},
20
+ info: () => {},
21
+ warn: (message, context) => warnings.push({ message, context }),
22
+ error: () => {},
23
+ child: () => logger,
24
+ level: 'debug',
25
+ }
26
+ return { logger, warnings }
27
+ }
28
+
29
+ const createOpenRouterProvider = (logger?: Logger) =>
30
+ new OpenRouterProvider({
31
+ apiKey: 'test-key',
32
+ imageProcessor: { resolveContent: async (content) => content },
33
+ logger,
34
+ })
35
+
36
+ const createAnthropicProvider = (logger?: Logger) =>
37
+ new AnthropicProvider({
38
+ apiKey: 'test-key',
39
+ imageProcessor: { resolveContent: async (content) => content },
40
+ logger,
41
+ })
42
+
43
+ const buildRequest = (messages: LLMMessage[]): RawInferenceRequest => ({
44
+ model: ModelId('test-model'),
45
+ systemPrompt: 'You are helpful.',
46
+ messages,
47
+ })
48
+
49
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
50
+ typeof value === 'object' && value !== null
51
+
52
+ const getBodyMessages = (body: unknown): Record<string, unknown>[] => {
53
+ if (!isRecord(body) || !Array.isArray(body.messages)) {
54
+ throw new Error('Expected a request body with messages')
55
+ }
56
+ return body.messages.map((message) => {
57
+ if (!isRecord(message)) {
58
+ throw new Error('Expected each request message to be an object')
59
+ }
60
+ return message
61
+ })
62
+ }
63
+
64
+ const getContentBlocks = (message: Record<string, unknown>): Record<string, unknown>[] => {
65
+ if (!Array.isArray(message.content)) {
66
+ throw new Error('Expected message content blocks')
67
+ }
68
+ return message.content.map((block) => {
69
+ if (!isRecord(block)) {
70
+ throw new Error('Expected each content block to be an object')
71
+ }
72
+ return block
73
+ })
74
+ }
75
+
76
+ const wellFormedToolHistory = (): LLMMessage[] => [
77
+ { role: 'user', content: 'Check the weather' },
78
+ {
79
+ role: 'assistant',
80
+ content: '',
81
+ toolCalls: [{ id: ToolCallId('call_1'), name: 'get_weather', input: { city: 'Prague' } }],
82
+ },
83
+ {
84
+ role: 'tool',
85
+ toolCallId: ToolCallId('call_1'),
86
+ toolName: 'get_weather',
87
+ content: 'Sunny',
88
+ },
89
+ ]
90
+
91
+ const orphanedToolHistory = (): LLMMessage[] => [
92
+ { role: 'user', content: 'Check the weather' },
93
+ { role: 'assistant', content: 'I will check.' },
94
+ {
95
+ role: 'tool',
96
+ toolCallId: ToolCallId('orphan_1'),
97
+ toolName: 'get_weather',
98
+ content: 'Sunny',
99
+ cacheControl: { type: 'ephemeral' },
100
+ },
101
+ ]
102
+
103
+ describe('sanitizeProviderMessages', () => {
104
+ test('returns a well-formed history unchanged', () => {
105
+ const messages = wellFormedToolHistory()
106
+ const result = sanitizeProviderMessages(messages, 'test')
107
+
108
+ expect(result).toBe(messages)
109
+ expect(result).toEqual(messages)
110
+ })
111
+
112
+ test('preserves an orphaned tool result as a marked user message and warns', () => {
113
+ const { logger, warnings } = createCapturingLogger()
114
+ const result = sanitizeProviderMessages(orphanedToolHistory(), 'test', logger)
115
+
116
+ expect(result[result.length - 1]).toEqual({
117
+ role: 'user',
118
+ content: '[Orphaned tool result from "get_weather"; tool call ID: orphan_1]\nSunny',
119
+ cacheControl: { type: 'ephemeral' },
120
+ })
121
+ expect(warnings).toEqual([{
122
+ message: 'Orphaned tool result converted to user message',
123
+ context: {
124
+ provider: 'test',
125
+ messageIndex: 2,
126
+ toolCallId: 'orphan_1',
127
+ toolName: 'get_weather',
128
+ },
129
+ }])
130
+ })
131
+
132
+ test('rejects a history ending in an assistant message', () => {
133
+ expect(() => sanitizeProviderMessages([
134
+ { role: 'user', content: 'Hello' },
135
+ { role: 'assistant', content: 'Hello back' },
136
+ ], 'test')).toThrow('test request history ends with an assistant message after sanitation')
137
+ })
138
+ })
139
+
140
+ describe('OpenRouterProvider message sanitation', () => {
141
+ test('converts a trailing orphan to user without moving its cache breakpoint', async () => {
142
+ const { logger, warnings } = createCapturingLogger()
143
+ const request = await createOpenRouterProvider(logger).buildHttpRequest(buildRequest(orphanedToolHistory()))
144
+ const messages = getBodyMessages(request.body)
145
+ const last = messages[messages.length - 1]
146
+ const blocks = getContentBlocks(last)
147
+
148
+ expect(last.role).toBe('user')
149
+ expect(blocks).toEqual([{
150
+ type: 'text',
151
+ text: '[Orphaned tool result from "get_weather"; tool call ID: orphan_1]\nSunny',
152
+ cache_control: { type: 'ephemeral' },
153
+ }])
154
+ expect(warnings).toHaveLength(1)
155
+ })
156
+
157
+ test('keeps a matched tool result as a tool message', async () => {
158
+ const request = await createOpenRouterProvider().buildHttpRequest(buildRequest(wellFormedToolHistory()))
159
+ const messages = getBodyMessages(request.body)
160
+ const last = messages[messages.length - 1]
161
+
162
+ expect(last).toEqual({
163
+ role: 'tool',
164
+ content: 'Sunny',
165
+ tool_call_id: 'call_1',
166
+ })
167
+ })
168
+
169
+ test('rejects an assistant tail before issuing a request', async () => {
170
+ const promise = createOpenRouterProvider().buildHttpRequest(buildRequest([
171
+ { role: 'user', content: 'Hello' },
172
+ { role: 'assistant', content: 'Hello back' },
173
+ ]))
174
+
175
+ await expect(promise).rejects.toThrow('openrouter request history ends with an assistant message after sanitation')
176
+ })
177
+ })
178
+
179
+ describe('AnthropicProvider message sanitation', () => {
180
+ test('converts a trailing orphan to user without moving its cache breakpoint', async () => {
181
+ const { logger, warnings } = createCapturingLogger()
182
+ const request = await createAnthropicProvider(logger).buildHttpRequest(buildRequest(orphanedToolHistory()))
183
+ const messages = getBodyMessages(request.body)
184
+ const last = messages[messages.length - 1]
185
+ const blocks = getContentBlocks(last)
186
+
187
+ expect(last.role).toBe('user')
188
+ expect(blocks).toEqual([{
189
+ type: 'text',
190
+ text: '[Orphaned tool result from "get_weather"; tool call ID: orphan_1]\nSunny',
191
+ cache_control: { type: 'ephemeral' },
192
+ }])
193
+ expect(warnings).toHaveLength(1)
194
+ })
195
+
196
+ test('keeps a matched tool result as a tool_result block', async () => {
197
+ const request = await createAnthropicProvider().buildHttpRequest(buildRequest(wellFormedToolHistory()))
198
+ const messages = getBodyMessages(request.body)
199
+ const last = messages[messages.length - 1]
200
+
201
+ expect(last.role).toBe('user')
202
+ expect(getContentBlocks(last)).toEqual([{
203
+ type: 'tool_result',
204
+ tool_use_id: 'call_1',
205
+ content: 'Sunny',
206
+ }])
207
+ })
208
+
209
+ test('rejects an assistant tail before issuing a request', async () => {
210
+ const promise = createAnthropicProvider().buildHttpRequest(buildRequest([
211
+ { role: 'user', content: 'Hello' },
212
+ { role: 'assistant', content: 'Hello back' },
213
+ ]))
214
+
215
+ await expect(promise).rejects.toThrow('anthropic request history ends with an assistant message after sanitation')
216
+ })
217
+ })
@@ -0,0 +1,70 @@
1
+ import type { LLMMessage, ToolLLMMessage, UserLLMMessage } from '~/core/agents/state.js'
2
+ import type { ChatMessageContentItem } from '~/core/llm/llm-log-types.js'
3
+ import type { Logger } from '~/lib/logger/logger.js'
4
+
5
+ export class ProviderMessageValidationError extends Error {
6
+ constructor(message: string) {
7
+ super(message)
8
+ this.name = 'ProviderMessageValidationError'
9
+ }
10
+ }
11
+
12
+ const convertOrphanedToolResult = (message: ToolLLMMessage): UserLLMMessage => {
13
+ const toolName = message.toolName ? ` from "${message.toolName}"` : ''
14
+ const errorMarker = message.isError ? ' (error)' : ''
15
+ const marker = `[Orphaned tool result${toolName}; tool call ID: ${message.toolCallId}${errorMarker}]`
16
+ const markerItem: ChatMessageContentItem = { type: 'text', text: marker }
17
+ const content = typeof message.content === 'string'
18
+ ? `${marker}\n${message.content}`
19
+ : [markerItem, ...message.content]
20
+
21
+ return {
22
+ role: 'user',
23
+ content,
24
+ cacheControl: message.cacheControl,
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Preserve orphaned tool results as user messages and reject assistant-prefill requests.
30
+ */
31
+ export const sanitizeProviderMessages = (
32
+ messages: LLMMessage[],
33
+ provider: string,
34
+ logger?: Logger,
35
+ ): LLMMessage[] => {
36
+ const declaredToolCallIds = new Set<string>()
37
+ let sanitizedMessages: LLMMessage[] | undefined
38
+
39
+ for (const [messageIndex, message] of messages.entries()) {
40
+ if (message.role === 'assistant') {
41
+ for (const toolCall of message.toolCalls ?? []) {
42
+ declaredToolCallIds.add(toolCall.id)
43
+ }
44
+ }
45
+
46
+ if (message.role === 'tool' && !declaredToolCallIds.has(message.toolCallId)) {
47
+ sanitizedMessages ??= messages.slice(0, messageIndex)
48
+ sanitizedMessages.push(convertOrphanedToolResult(message))
49
+ logger?.warn('Orphaned tool result converted to user message', {
50
+ provider,
51
+ messageIndex,
52
+ toolCallId: message.toolCallId,
53
+ toolName: message.toolName,
54
+ })
55
+ continue
56
+ }
57
+
58
+ sanitizedMessages?.push(message)
59
+ }
60
+
61
+ const result = sanitizedMessages ?? messages
62
+ const lastMessage = result[result.length - 1]
63
+ if (lastMessage?.role === 'assistant') {
64
+ throw new ProviderMessageValidationError(
65
+ `${provider} request history ends with an assistant message after sanitation`,
66
+ )
67
+ }
68
+
69
+ return result
70
+ }