@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
@@ -109,11 +109,11 @@ export function useProvider(name: string): LLMMiddleware {
109
109
  *
110
110
  * @example
111
111
  * ```ts
112
- * // Exclude Google providers:
113
- * llmMiddleware: [withOpenRouter({ providers: { deny: ['Google'] } })]
112
+ * // Exclude Amazon Bedrock while retaining provider fallbacks:
113
+ * llmMiddleware: [withOpenRouter({ providers: { ignore: ['amazon-bedrock'], allow_fallbacks: true } })]
114
114
  *
115
115
  * // Prefer specific providers:
116
- * llmMiddleware: [withOpenRouter({ providers: { order: ['Anthropic', 'AWS Bedrock'] } })]
116
+ * llmMiddleware: [withOpenRouter({ providers: { order: ['anthropic', 'google-vertex'] } })]
117
117
  * ```
118
118
  */
119
119
  export function withOpenRouter(opts: OpenRouterRequestOptions): LLMMiddleware {
@@ -47,7 +47,26 @@ const asBlocks = (content: string | AnyBlock[]): AnyBlock[] => {
47
47
  return content
48
48
  }
49
49
 
50
- describe('OpenRouterProvider buildHttpRequest cache placement', () => {
50
+ describe('OpenRouterProvider buildHttpRequest', () => {
51
+ test('includes inline provider routing preferences', async () => {
52
+ const request = buildRequest([{ role: 'user', content: 'Hello' }])
53
+ request.openrouter = {
54
+ providers: {
55
+ ignore: ['amazon-bedrock'],
56
+ allow_fallbacks: true,
57
+ },
58
+ }
59
+
60
+ const http = await createProvider().buildHttpRequest(request)
61
+
62
+ expect(http.body).toEqual(expect.objectContaining({
63
+ provider: {
64
+ ignore: ['amazon-bedrock'],
65
+ allow_fallbacks: true,
66
+ },
67
+ }))
68
+ })
69
+
51
70
  test('pure tool_result target: cache_control lands on the tool role message content', async () => {
52
71
  const messages = applyCacheBreakpoint([
53
72
  { role: 'user', content: 'What is the weather?' },
@@ -87,12 +106,14 @@ describe('OpenRouterProvider buildHttpRequest cache placement', () => {
87
106
  { id: ToolCallId('call_2'), name: 'search', input: { query: 'weather' } },
88
107
  ],
89
108
  },
90
- ], 0)
109
+ { role: 'tool', toolCallId: ToolCallId('call_1'), content: 'Sunny' },
110
+ { role: 'tool', toolCallId: ToolCallId('call_2'), content: 'Found results' },
111
+ ], 2)
91
112
 
92
113
  const http = await createProvider().buildHttpRequest(buildRequest(messages))
93
114
  const msgs = getBodyMessages(http.body)
94
115
 
95
- const assistant = msgs[msgs.length - 1]
116
+ const assistant = msgs[msgs.length - 3]
96
117
  expect(assistant.role).toBe('assistant')
97
118
  expect(assistant.tool_calls).toBeDefined()
98
119
  // String content got wrapped so cache_control has a place to live
@@ -13,9 +13,11 @@ import type {
13
13
  LLMMessage,
14
14
  LLMMetrics,
15
15
  LLMProvider,
16
+ OpenRouterProviderRouting,
16
17
  ProviderHttpRequest,
17
18
  RawInferenceRequest,
18
19
  } from './provider.js'
20
+ import { ProviderMessageValidationError, sanitizeProviderMessages } from './message-sanitization.js'
19
21
 
20
22
  // ============================================================================
21
23
  // Configuration
@@ -125,13 +127,6 @@ interface OpenRouterToolDefinition {
125
127
  }
126
128
  }
127
129
 
128
- interface OpenRouterProviderRouting {
129
- order?: string[]
130
- allow?: string[]
131
- deny?: string[]
132
- quantizations?: string[]
133
- }
134
-
135
130
  interface OpenRouterRequestBody {
136
131
  model: string
137
132
  messages: OpenRouterMessage[]
@@ -263,7 +258,8 @@ export class OpenRouterProvider implements LLMProvider {
263
258
  }
264
259
 
265
260
  async buildHttpRequest(request: RawInferenceRequest, context?: InferenceContext): Promise<ProviderHttpRequest> {
266
- const mappedMessages = await Promise.all(request.messages.map((m) => this.mapMessage(m, context)))
261
+ const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
262
+ const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
267
263
 
268
264
  const messages: OpenRouterMessage[] = [
269
265
  { role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }] },
@@ -447,6 +443,9 @@ export class OpenRouterProvider implements LLMProvider {
447
443
  }
448
444
 
449
445
  private mapError(err: unknown): LLMError {
446
+ if (err instanceof ProviderMessageValidationError) {
447
+ return { type: 'invalid_request', message: err.message }
448
+ }
450
449
  if (err instanceof Error && err.name === 'AbortError') {
451
450
  return { type: 'aborted', message: 'Request was aborted' }
452
451
  }
@@ -28,18 +28,22 @@ export interface RawToolSpec {
28
28
  *
29
29
  * @see https://openrouter.ai/docs/api-reference/overview
30
30
  */
31
+ export interface OpenRouterProviderRouting {
32
+ /** Preferred provider ordering */
33
+ order?: string[]
34
+ /** Only allow these providers */
35
+ only?: string[]
36
+ /** Exclude these providers */
37
+ ignore?: string[]
38
+ /** Whether to allow backup providers */
39
+ allow_fallbacks?: boolean
40
+ /** Allowed quantizations (e.g. "bf16", "int8") */
41
+ quantizations?: string[]
42
+ }
43
+
31
44
  export interface OpenRouterRequestOptions {
32
45
  /** Provider routing preferences */
33
- providers?: {
34
- /** Preferred provider ordering */
35
- order?: string[]
36
- /** Only allow these providers */
37
- allow?: string[]
38
- /** Exclude these providers */
39
- deny?: string[]
40
- /** Allowed quantizations (e.g. "bf16", "int8") */
41
- quantizations?: string[]
42
- }
46
+ providers?: OpenRouterProviderRouting
43
47
  /** Routing strategy */
44
48
  route?: 'fallback'
45
49
  /** Model transforms */
@@ -97,6 +97,15 @@ export const llmEvents = createEventsFactory({
97
97
  error: z4.string(),
98
98
  llmCallId: llmCallIdSchema.optional(),
99
99
  }),
100
+ /**
101
+ * A plugin's afterInference hook requested a retry of the turn. Rolls back
102
+ * the staged turn (pendingMessages from inference_started) so the retried
103
+ * inference can restage the same messages without double-appending; dequeue
104
+ * tokens stay unconsumed until the turn actually commits.
105
+ */
106
+ inference_retried: z4.object({
107
+ agentId: agentIdSchema,
108
+ }),
100
109
  },
101
110
  })
102
111
 
@@ -104,3 +113,4 @@ export type InferenceStartedEvent = (typeof llmEvents)['Events']['inference_star
104
113
  export type InferenceCompletedEvent = (typeof llmEvents)['Events']['inference_completed']
105
114
  export type AuxiliaryInferenceCompletedEvent = (typeof llmEvents)['Events']['auxiliary_inference_completed']
106
115
  export type InferenceFailedEvent = (typeof llmEvents)['Events']['inference_failed']
116
+ export type InferenceRetriedEvent = (typeof llmEvents)['Events']['inference_retried']
@@ -228,7 +228,9 @@ type PluginSelf<TMethods extends Record<string, MethodEntry>> = {
228
228
  type HookMap<TCtx> = {
229
229
  onStart: (ctx: TCtx) => Promise<OnStartResult>
230
230
  beforeInference: (ctx: TCtx & { pendingMessages: MailboxMessage[]; turnNumber: number }) => Promise<BeforeInferenceResult>
231
- afterInference: (ctx: TCtx & { response: LLMResponse; turnNumber: number }) => Promise<AfterInferenceResult>
231
+ afterInference: (
232
+ ctx: TCtx & { response: LLMResponse; turnNumber: number; dequeuedSourcePlugins: readonly string[] },
233
+ ) => Promise<AfterInferenceResult>
232
234
  beforeToolCall: (ctx: TCtx & { toolCall: ToolCall }) => Promise<BeforeToolCallResult>
233
235
  afterToolCall: (ctx: TCtx & { toolCall: ToolCall; result: { isError: boolean; content: ToolResultContent } }) => Promise<AfterToolCallResult>
234
236
  onComplete: (ctx: TCtx) => Promise<OnCompleteResult>
@@ -298,7 +300,9 @@ export type BaseMethodHandlerContext = SessionContext & {
298
300
  type ErasedAgentHookMap = {
299
301
  onStart: (ctx: BasePluginHookContext) => Promise<OnStartResult>
300
302
  beforeInference: (ctx: BasePluginHookContext & { pendingMessages: MailboxMessage[]; turnNumber: number }) => Promise<BeforeInferenceResult>
301
- afterInference: (ctx: BasePluginHookContext & { response: LLMResponse; turnNumber: number }) => Promise<AfterInferenceResult>
303
+ afterInference: (
304
+ ctx: BasePluginHookContext & { response: LLMResponse; turnNumber: number; dequeuedSourcePlugins: readonly string[] },
305
+ ) => Promise<AfterInferenceResult>
302
306
  beforeToolCall: (ctx: BasePluginHookContext & { toolCall: ToolCall }) => Promise<BeforeToolCallResult>
303
307
  afterToolCall: (
304
308
  ctx: BasePluginHookContext & { toolCall: ToolCall; result: { isError: boolean; content: ToolResultContent } },
@@ -234,6 +234,9 @@ export class Session {
234
234
  * Schedule agent processing (with debounce).
235
235
  */
236
236
  scheduleAgent(agentId: AgentId): void {
237
+ const agentState = this.store.getAgentState(agentId)
238
+ if (!agentState || agentState.status === 'paused') return
239
+
237
240
  const agent = this.agents.get(agentId)
238
241
  if (agent) {
239
242
  agent.scheduleProcessing()
@@ -826,6 +829,7 @@ export class Session {
826
829
  input: orch.input,
827
830
  plugins: withServicePluginConfig(orch),
828
831
  cacheTtl: orch.cacheTtl,
832
+ errorResumeBackoff: orch.errorResumeBackoff,
829
833
  }
830
834
  }
831
835
 
@@ -842,6 +846,7 @@ export class Session {
842
846
  input: comm.input,
843
847
  plugins: withServicePluginConfig(comm),
844
848
  cacheTtl: comm.cacheTtl,
849
+ errorResumeBackoff: comm.errorResumeBackoff,
845
850
  }
846
851
  }
847
852
 
@@ -861,6 +866,7 @@ export class Session {
861
866
  input: agentDef.input,
862
867
  plugins: withServicePluginConfig(agentDef),
863
868
  cacheTtl: agentDef.cacheTtl,
869
+ errorResumeBackoff: agentDef.errorResumeBackoff,
864
870
  }
865
871
  }
866
872
  }
@@ -202,12 +202,16 @@ export const coreReducer = createTypedReducer(
202
202
 
203
203
  return {
204
204
  ...agent,
205
- status: hasToolCalls ? 'tool_exec' : 'pending',
205
+ // Preserve a pause that landed during the in-flight inference — the pause
206
+ // defers (tool calls stay pending until resume) instead of being clobbered.
207
+ status: agent.status === 'paused' ? 'paused' : hasToolCalls ? 'tool_exec' : 'pending',
206
208
  conversationHistory: [...agent.conversationHistory, ...agent.pendingMessages, assistantMessage],
207
209
  pendingToolCalls: toolCalls,
208
210
  pendingMessages: [],
209
211
  pendingToolResults: [],
210
212
  lastInferenceMetrics: event.metrics,
213
+ consecutiveInferenceFailures: 0,
214
+ lastInferenceFailureAt: undefined,
211
215
  }
212
216
  })
213
217
  }
@@ -221,6 +225,19 @@ export const coreReducer = createTypedReducer(
221
225
  ...agent,
222
226
  status: 'errored',
223
227
  pendingMessages: [],
228
+ consecutiveInferenceFailures: (agent.consecutiveInferenceFailures ?? 0) + 1,
229
+ lastInferenceFailureAt: event.timestamp,
230
+ }))
231
+
232
+ case 'inference_retried':
233
+ // Plugin-requested retry: roll back the staged turn (like inference_failed)
234
+ // so the retried inference_started doesn't double-append pendingMessages.
235
+ // Dequeue tokens are still unconsumed (consumption happens at commit), so
236
+ // the retry re-collects the same messages.
237
+ return updateAgent(state, event.agentId, (agent) => ({
238
+ ...agent,
239
+ status: agent.status === 'paused' ? 'paused' : 'pending',
240
+ pendingMessages: [],
224
241
  }))
225
242
 
226
243
  case 'tool_completed': {
@@ -242,7 +259,9 @@ export const coreReducer = createTypedReducer(
242
259
  ...agent,
243
260
  pendingToolCalls: remaining,
244
261
  pendingToolResults: [...agent.pendingToolResults, pendingToolResult],
245
- status: remaining.length === 0 ? 'pending' : 'tool_exec',
262
+ // Preserve a pause raised by a tool hook — recomputing the status here
263
+ // would silently un-pause the agent mid-turn.
264
+ status: agent.status === 'paused' ? 'paused' : remaining.length === 0 ? 'pending' : 'tool_exec',
246
265
  executingToolCall: undefined,
247
266
  }
248
267
  })
@@ -267,7 +286,7 @@ export const coreReducer = createTypedReducer(
267
286
  ...agent,
268
287
  pendingToolCalls: remaining,
269
288
  pendingToolResults: [...agent.pendingToolResults, pendingToolResult],
270
- status: remaining.length === 0 ? 'pending' : 'tool_exec',
289
+ status: agent.status === 'paused' ? 'paused' : remaining.length === 0 ? 'pending' : 'tool_exec',
271
290
  executingToolCall: undefined,
272
291
  }
273
292
  })
@@ -276,23 +295,37 @@ export const coreReducer = createTypedReducer(
276
295
  case 'context_compacted':
277
296
  return updateAgent(state, event.agentId, (agent) => ({
278
297
  ...agent,
279
- conversationHistory: event.newConversationHistory.map((m) => {
298
+ conversationHistory: event.newConversationHistory.map((m): LLMMessage => {
280
299
  switch (m.role) {
281
300
  case 'user':
282
301
  return {
283
- role: 'user' as const,
302
+ role: 'user',
284
303
  content: m.content,
285
- sourceMessageIds: [],
304
+ sourceMessageIds: m.sourceMessageIds ?? [],
305
+ cacheControl: m.cacheControl,
286
306
  }
287
307
  case 'assistant':
288
308
  return {
289
- role: 'assistant' as const,
309
+ role: 'assistant',
310
+ content: m.content,
311
+ toolCalls: m.toolCalls,
312
+ cacheControl: m.cacheControl,
313
+ }
314
+ case 'tool':
315
+ return {
316
+ role: 'tool',
290
317
  content: m.content,
318
+ toolCallId: m.toolCallId,
319
+ toolName: m.toolName,
320
+ isError: m.isError,
321
+ timestamp: m.timestamp,
322
+ cacheControl: m.cacheControl,
291
323
  }
292
324
  case 'system':
293
325
  return {
294
- role: 'system' as const,
326
+ role: 'system',
295
327
  content: m.content,
328
+ cacheControl: m.cacheControl,
296
329
  }
297
330
  }
298
331
  }),
@@ -320,6 +353,10 @@ export const coreReducer = createTypedReducer(
320
353
  ...updated,
321
354
  status: 'pending' as const,
322
355
  pendingMessages: [],
356
+ // Restart is an explicit external trigger — start a fresh error episode
357
+ // so the outer error-resume backoff doesn't delay the first attempt.
358
+ consecutiveInferenceFailures: 0,
359
+ lastInferenceFailureAt: undefined,
323
360
  }
324
361
  changed = true
325
362
  }
@@ -403,7 +440,9 @@ export const coreReducer = createTypedReducer(
403
440
  case 'agent_resumed':
404
441
  return updateAgent(state, event.agentId, (agent) => ({
405
442
  ...agent,
406
- status: 'pending',
443
+ // Resume into tool_exec when the pause interrupted a tool turn — going to
444
+ // 'pending' would run inference with unresolved tool calls (unpaired tool_use).
445
+ status: agent.pendingToolCalls.length > 0 ? 'tool_exec' : 'pending',
407
446
  pauseReason: undefined,
408
447
  pauseMessage: undefined,
409
448
  }))
@@ -3,7 +3,7 @@ import z from 'zod/v4'
3
3
  import { contextEvents } from '~/core/context/state.js'
4
4
  import { llmEvents } from '~/core/llm/state.js'
5
5
  import { MockLLMProvider } from '~/core/llm/mock.js'
6
- import type { InferenceRequest } from '~/core/llm/provider.js'
6
+ import type { InferenceRequest, LLMMessage } from '~/core/llm/provider.js'
7
7
  import { ModelId } from '~/core/llm/schema.js'
8
8
  import type { Preset } from '~/core/preset/index.js'
9
9
  import { createTool } from '~/core/tools/definition.js'
@@ -48,6 +48,33 @@ function createCompactHarness(options: Omit<ConstructorParameters<typeof TestHar
48
48
  return new TestHarness({ ...options, systemPlugins: [contextCompactPlugin] })
49
49
  }
50
50
 
51
+ function expectToolPairing(messages: LLMMessage[]): void {
52
+ const declaredToolCallIds = new Set<string>()
53
+
54
+ for (let i = 0; i < messages.length; i++) {
55
+ const message = messages[i]
56
+ if (message.role === 'assistant' && message.toolCalls?.length) {
57
+ const expected = new Set(message.toolCalls.map((toolCall) => toolCall.id))
58
+ const seen = new Set<string>()
59
+ for (const toolCall of message.toolCalls) {
60
+ declaredToolCallIds.add(toolCall.id)
61
+ }
62
+ for (let j = i + 1; j < messages.length; j++) {
63
+ const next = messages[j]
64
+ if (next.role !== 'tool') break
65
+ seen.add(next.toolCallId)
66
+ }
67
+ for (const id of expected) {
68
+ expect(seen.has(id)).toBe(true)
69
+ }
70
+ }
71
+
72
+ if (message.role === 'tool') {
73
+ expect(declaredToolCallIds.has(message.toolCallId)).toBe(true)
74
+ }
75
+ }
76
+ }
77
+
51
78
  // ============================================================================
52
79
  // Tests
53
80
  // ============================================================================
@@ -222,6 +249,7 @@ describe('context-compact plugin', () => {
222
249
  }
223
250
 
224
251
  let capturedAuxRequest: InferenceRequest | undefined
252
+ let capturedToolResultRequest: InferenceRequest | undefined
225
253
 
226
254
  const harness = new TestHarness({
227
255
  systemPlugins: [contextCompactPlugin],
@@ -246,6 +274,7 @@ describe('context-compact plugin', () => {
246
274
  metrics: MockLLMProvider.defaultMetrics(),
247
275
  }
248
276
  }
277
+ capturedToolResultRequest = request
249
278
  return {
250
279
  content: 'Done.',
251
280
  toolCalls: [],
@@ -259,26 +288,14 @@ describe('context-compact plugin', () => {
259
288
  await session.sendAndWaitForIdle('Please call my_tool')
260
289
 
261
290
  expect(capturedAuxRequest).toBeDefined()
262
-
263
- // Every assistant message with toolCalls must be followed by a contiguous
264
- // run of tool messages covering each tool_use id before any further
265
- // user/assistant message. This mirrors Anthropic's API contract.
266
- const msgs = capturedAuxRequest!.messages
267
- for (let i = 0; i < msgs.length; i++) {
268
- const m = msgs[i]
269
- if (m.role !== 'assistant' || !m.toolCalls?.length) continue
270
-
271
- const expected = new Set(m.toolCalls.map((tc) => tc.id))
272
- const seen = new Set<string>()
273
- for (let j = i + 1; j < msgs.length; j++) {
274
- const next = msgs[j]
275
- if (next.role !== 'tool') break
276
- seen.add(next.toolCallId)
277
- }
278
- for (const id of expected) {
279
- expect(seen.has(id)).toBe(true)
280
- }
281
- }
291
+ if (!capturedAuxRequest) return
292
+ expectToolPairing(capturedAuxRequest.messages)
293
+
294
+ // The event reducer runs before pending tool results are appended to the
295
+ // regular inference request. Pairing must survive that reconstruction.
296
+ expect(capturedToolResultRequest).toBeDefined()
297
+ if (!capturedToolResultRequest) return
298
+ expectToolPairing(capturedToolResultRequest.messages)
282
299
 
283
300
  // Compaction must have actually succeeded — pre-fix it would Err-out
284
301
  // in production (mock accepts it but the assertion above already
@@ -1,10 +1,15 @@
1
1
  import { beforeEach, describe, expect, it } from 'bun:test'
2
+ import { agentEvents } from '~/core/agents/state.js'
2
3
  import type { AgentId } from '~/core/agents/schema.js'
3
4
  import { generateTestAgentId } from '~/core/agents/schema.js'
5
+ import { contextCompactedEventSchema, contextEvents } from '~/core/context/state.js'
6
+ import { withSessionId } from '~/core/events/test-helpers.js'
4
7
  import type { InferenceResponse, LLMMessage } from '~/core/llm/provider.js'
5
8
  import { ModelId } from '~/core/llm/schema.js'
9
+ import { estimateMessagesTokens } from '~/core/llm/tokens.js'
6
10
  import type { SessionId } from '~/core/sessions/schema.js'
7
11
  import { generateSessionId } from '~/core/sessions/schema.js'
12
+ import { coreReducer, createSessionState } from '~/core/sessions/state.js'
8
13
  import { generateToolCallId } from '~/core/tools/schema.js'
9
14
  import { Err, Ok } from '~/lib/utils/result.js'
10
15
  import { silentLogger } from '../../lib/logger/logger.js'
@@ -315,12 +320,26 @@ describe('createContextCompactedEvent', () => {
315
320
  expect(event.timestamp).toBeDefined()
316
321
  })
317
322
 
318
- it('converts tool role to system in history', () => {
323
+ it('preserves assistant tool calls and tool results in history', () => {
319
324
  const sessionId = generateSessionId()
320
325
  const agentId = generateTestAgentId()
321
326
  const toolCallId = generateToolCallId()
322
327
  const result: CompactionResult = {
323
- compactedMessages: [{ role: 'tool', content: 'tool result', toolCallId }],
328
+ compactedMessages: [
329
+ {
330
+ role: 'assistant',
331
+ content: 'Checking.',
332
+ toolCalls: [{ id: toolCallId, name: 'read', input: { path: '/src/index.ts' } }],
333
+ },
334
+ {
335
+ role: 'tool',
336
+ content: 'tool result',
337
+ toolCallId,
338
+ toolName: 'read',
339
+ isError: false,
340
+ timestamp: 123,
341
+ },
342
+ ],
324
343
  originalMessages: [],
325
344
  summary: '',
326
345
  originalTokens: 100,
@@ -330,8 +349,44 @@ describe('createContextCompactedEvent', () => {
330
349
 
331
350
  const event = createContextCompactedEvent(sessionId, agentId, result)
332
351
 
333
- // tool role should be converted to system
334
- expect(event.newConversationHistory[0].role).toBe('system')
352
+ expect(event.newConversationHistory).toEqual(result.compactedMessages)
353
+ })
354
+
355
+ it('parses and reduces old-shape events exactly as before', () => {
356
+ const sessionId = generateSessionId()
357
+ const agentId = generateTestAgentId()
358
+ const oldPayload = contextCompactedEventSchema.parse({
359
+ agentId,
360
+ compactedContent: 'Old summary',
361
+ newConversationHistory: [
362
+ { role: 'user', content: 'summary' },
363
+ { role: 'assistant', content: 'recent response' },
364
+ { role: 'system', content: 'legacy context' },
365
+ ],
366
+ originalTokens: 1000,
367
+ compactedTokens: 100,
368
+ messagesRemoved: 12,
369
+ })
370
+
371
+ let state = createSessionState(sessionId, 'test', 0)
372
+ state = coreReducer(state, withSessionId(
373
+ sessionId,
374
+ agentEvents.create('agent_spawned', {
375
+ agentId,
376
+ definitionName: 'test-agent',
377
+ parentId: null,
378
+ }),
379
+ ))
380
+ state = coreReducer(state, withSessionId(
381
+ sessionId,
382
+ contextEvents.create('context_compacted', oldPayload),
383
+ ))
384
+
385
+ expect(state.agents.get(agentId)?.conversationHistory).toEqual([
386
+ { role: 'user', content: 'summary', sourceMessageIds: [], cacheControl: undefined },
387
+ { role: 'assistant', content: 'recent response', toolCalls: undefined, cacheControl: undefined },
388
+ { role: 'system', content: 'legacy context', cacheControl: undefined },
389
+ ])
335
390
  })
336
391
  })
337
392
 
@@ -614,6 +669,97 @@ describe('ContextCompactor with tool calls', () => {
614
669
  // All 5 original messages should be compacted (none kept except summary)
615
670
  expect(result.value.messagesRemoved).toBe(5)
616
671
  })
672
+
673
+ it('round-trips a kept tool turn and accounts for its token structure', async () => {
674
+ inference.setResponses([
675
+ {
676
+ content: 'Short summary',
677
+ toolCalls: [],
678
+ finishReason: 'stop',
679
+ metrics: { promptTokens: 50, completionTokens: 20, totalTokens: 70, latencyMs: 100, model: 'mock' },
680
+ },
681
+ ])
682
+
683
+ const toolCallId = generateToolCallId()
684
+ const toolTurn: LLMMessage[] = [
685
+ {
686
+ role: 'assistant',
687
+ content: 'I will inspect the file.',
688
+ toolCalls: [{
689
+ id: toolCallId,
690
+ name: 'read',
691
+ input: { path: '/src/index.ts', reason: 'x'.repeat(400) },
692
+ }],
693
+ },
694
+ {
695
+ role: 'tool',
696
+ content: 'export const value = 1',
697
+ toolCallId,
698
+ toolName: 'read',
699
+ isError: false,
700
+ timestamp: 123,
701
+ },
702
+ ]
703
+ const messages: LLMMessage[] = [
704
+ { role: 'user', content: 'old context '.repeat(1000) },
705
+ { role: 'assistant', content: 'old response '.repeat(1000) },
706
+ ...toolTurn,
707
+ ]
708
+ const preservingCompactor = new ContextCompactor(silentLogger, {
709
+ maxTokens: 100,
710
+ keepRecentMessages: 2,
711
+ })
712
+
713
+ const result = await preservingCompactor.compact(sessionId, agentId, messages, inference.run)
714
+ expect(result.ok).toBe(true)
715
+ if (!result.ok) return
716
+
717
+ expect(result.value.compactedMessages.slice(1)).toEqual(toolTurn)
718
+ expect(result.value.compactedTokens).toBeLessThan(result.value.originalTokens)
719
+ expect(result.value.compactedTokens).toBeGreaterThan(
720
+ estimateMessagesTokens(result.value.compactedMessages),
721
+ )
722
+
723
+ const event = createContextCompactedEvent(sessionId, agentId, result.value)
724
+ expect(event.newConversationHistory.slice(1)).toEqual(toolTurn)
725
+ })
726
+
727
+ it('keeps an open tool declaration even when keep limits are zero', async () => {
728
+ inference.setResponses([
729
+ {
730
+ content: 'Summary',
731
+ toolCalls: [],
732
+ finishReason: 'stop',
733
+ metrics: { promptTokens: 50, completionTokens: 20, totalTokens: 70, latencyMs: 100, model: 'mock' },
734
+ },
735
+ ])
736
+
737
+ const toolCallId = generateToolCallId()
738
+ const openToolCall: LLMMessage = {
739
+ role: 'assistant',
740
+ content: '',
741
+ toolCalls: [{ id: toolCallId, name: 'read', input: { path: '/src/index.ts' } }],
742
+ }
743
+ const zeroKeepCompactor = new ContextCompactor(silentLogger, {
744
+ maxTokens: 100,
745
+ keepRecentMessages: 0,
746
+ keepRecentTokens: 0,
747
+ })
748
+
749
+ const result = await zeroKeepCompactor.compact(
750
+ sessionId,
751
+ agentId,
752
+ [
753
+ { role: 'user', content: 'old context' },
754
+ openToolCall,
755
+ ],
756
+ inference.run,
757
+ )
758
+ expect(result.ok).toBe(true)
759
+ if (!result.ok) return
760
+
761
+ expect(result.value.compactedMessages.at(-1)).toEqual(openToolCall)
762
+ })
617
763
  })
618
764
 
619
765
  // ============================================================================