@roj-ai/sdk 0.1.28 → 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 (127) 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 +12 -2
  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/services/plugin.d.ts.map +1 -1
  77. package/dist/plugins/services/plugin.js +4 -1
  78. package/dist/plugins/services/plugin.js.map +1 -1
  79. package/dist/plugins/services/prompt.js +3 -3
  80. package/dist/plugins/services/prompt.js.map +1 -1
  81. package/dist/plugins/services/schema.d.ts +8 -0
  82. package/dist/plugins/services/schema.d.ts.map +1 -1
  83. package/dist/plugins/services/services.integration.test.js +112 -0
  84. package/dist/plugins/services/services.integration.test.js.map +1 -1
  85. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  86. package/dist/plugins/uploads/plugin.js +10 -1
  87. package/dist/plugins/uploads/plugin.js.map +1 -1
  88. package/dist/plugins/uploads/uploads.integration.test.js +74 -28
  89. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  90. package/dist/testing/test-harness.d.ts +8 -0
  91. package/dist/testing/test-harness.d.ts.map +1 -1
  92. package/dist/testing/test-harness.js +18 -0
  93. package/dist/testing/test-harness.js.map +1 -1
  94. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  95. package/package.json +2 -2
  96. package/src/core/agents/agent.ts +124 -36
  97. package/src/core/agents/config.ts +6 -0
  98. package/src/core/agents/state.ts +9 -0
  99. package/src/core/context/state.ts +86 -27
  100. package/src/core/error-recovery.integration.test.ts +343 -0
  101. package/src/core/llm/anthropic.test.ts +7 -4
  102. package/src/core/llm/anthropic.ts +6 -1
  103. package/src/core/llm/message-sanitization.test.ts +217 -0
  104. package/src/core/llm/message-sanitization.ts +70 -0
  105. package/src/core/llm/middleware.ts +3 -3
  106. package/src/core/llm/openrouter-mapping.test.ts +24 -3
  107. package/src/core/llm/openrouter.ts +7 -8
  108. package/src/core/llm/provider.ts +14 -10
  109. package/src/core/llm/state.ts +10 -0
  110. package/src/core/plugins/plugin-builder.ts +6 -2
  111. package/src/core/sessions/session.ts +13 -3
  112. package/src/core/sessions/state.ts +48 -9
  113. package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
  114. package/src/plugins/context-compact/context-compactor.test.ts +150 -4
  115. package/src/plugins/context-compact/context-compactor.ts +64 -10
  116. package/src/plugins/limits-guard/config.ts +2 -0
  117. package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
  118. package/src/plugins/limits-guard/limit-guard.ts +13 -1
  119. package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
  120. package/src/plugins/limits-guard/plugin.ts +53 -3
  121. package/src/plugins/services/plugin.ts +4 -1
  122. package/src/plugins/services/prompt.ts +3 -3
  123. package/src/plugins/services/schema.ts +8 -0
  124. package/src/plugins/services/services.integration.test.ts +129 -0
  125. package/src/plugins/uploads/plugin.ts +11 -1
  126. package/src/plugins/uploads/uploads.integration.test.ts +82 -28
  127. package/src/testing/test-harness.ts +20 -0
@@ -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
  // ============================================================================
@@ -194,7 +194,7 @@ export class ContextCompactor {
194
194
  count = 0
195
195
  let tokens = 0
196
196
  for (let i = messages.length - 1; i >= 0 && count < maxCount; i--) {
197
- const msgTokens = estimateMessagesTokens([messages[i]])
197
+ const msgTokens = estimateConversationTokens([messages[i]])
198
198
  if (tokens + msgTokens > tokenBudget) break
199
199
  tokens += msgTokens
200
200
  count++
@@ -206,6 +206,13 @@ export class ContextCompactor {
206
206
  count--
207
207
  }
208
208
 
209
+ // An incomplete tool turn at the tail has pending results outside history.
210
+ // Keep its assistant declaration even if count/token limits would drop it.
211
+ const openToolTurnStart = findOpenToolTurnStart(messages)
212
+ if (openToolTurnStart !== undefined) {
213
+ count = Math.max(count, messages.length - openToolTurnStart)
214
+ }
215
+
209
216
  return count
210
217
  }
211
218
 
@@ -220,7 +227,7 @@ export class ContextCompactor {
220
227
  * relying on it alone causes the trigger to never fire in long sessions.
221
228
  */
222
229
  needsCompaction(messages: LLMMessage[], lastActualPromptTokens?: number): boolean {
223
- const tokens = lastActualPromptTokens ?? estimateMessagesTokens(messages)
230
+ const tokens = lastActualPromptTokens ?? estimateConversationTokens(messages)
224
231
  return tokens > this.config.maxTokens
225
232
  }
226
233
 
@@ -256,7 +263,7 @@ export class ContextCompactor {
256
263
  runInference: RunInferenceFn,
257
264
  lastActualPromptTokens?: number,
258
265
  ): Promise<Result<CompactionResult, Error>> {
259
- const originalTokens = lastActualPromptTokens ?? estimateMessagesTokens(messages)
266
+ const originalTokens = lastActualPromptTokens ?? estimateConversationTokens(messages)
260
267
 
261
268
  this.logger.info('Starting context compaction', {
262
269
  sessionId,
@@ -338,7 +345,7 @@ export class ContextCompactor {
338
345
  }
339
346
 
340
347
  const compactedMessages = [summaryMessage, ...toKeep]
341
- const compactedTokens = estimateMessagesTokens(compactedMessages)
348
+ const compactedTokens = estimateConversationTokens(compactedMessages)
342
349
 
343
350
  this.logger.info('Context compaction complete', {
344
351
  sessionId,
@@ -362,6 +369,54 @@ export class ContextCompactor {
362
369
  }
363
370
  }
364
371
 
372
+ function findOpenToolTurnStart(messages: LLMMessage[]): number | undefined {
373
+ let assistantIndex = messages.length - 1
374
+ while (assistantIndex >= 0 && messages[assistantIndex].role === 'tool') {
375
+ assistantIndex--
376
+ }
377
+
378
+ const assistant = messages[assistantIndex]
379
+ if (assistant?.role !== 'assistant' || !assistant.toolCalls?.length) {
380
+ return undefined
381
+ }
382
+
383
+ const completedToolCallIds = new Set<string>()
384
+ for (let i = assistantIndex + 1; i < messages.length; i++) {
385
+ const message = messages[i]
386
+ if (message.role === 'tool') {
387
+ completedToolCallIds.add(message.toolCallId)
388
+ }
389
+ }
390
+ return assistant.toolCalls.some((toolCall) => !completedToolCallIds.has(toolCall.id))
391
+ ? assistantIndex
392
+ : undefined
393
+ }
394
+
395
+ function estimateConversationTokens(messages: LLMMessage[]): number {
396
+ return estimateMessagesTokens(messages.map((message) => ({
397
+ role: message.role,
398
+ content: tokenEstimationContent(message),
399
+ })))
400
+ }
401
+
402
+ function tokenEstimationContent(message: LLMMessage): unknown {
403
+ switch (message.role) {
404
+ case 'assistant':
405
+ return message.toolCalls?.length
406
+ ? { content: message.content, toolCalls: message.toolCalls }
407
+ : message.content
408
+ case 'tool':
409
+ return {
410
+ content: message.content,
411
+ toolCallId: message.toolCallId,
412
+ isError: message.isError,
413
+ }
414
+ case 'user':
415
+ case 'system':
416
+ return message.content
417
+ }
418
+ }
419
+
365
420
  // ============================================================================
366
421
  // Event creation helper
367
422
  // ============================================================================
@@ -371,11 +426,7 @@ export function createContextCompactedEvent(
371
426
  agentId: AgentId,
372
427
  result: CompactionResult,
373
428
  ): ContextCompactedEvent {
374
- // Convert LLMMessage[] to CompactedConversationMessage[]
375
- const newConversationHistory: CompactedConversationMessage[] = result.compactedMessages.map((msg) => ({
376
- role: msg.role === 'tool' ? 'system' : msg.role,
377
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
378
- }))
429
+ const newConversationHistory: CompactedConversationMessage[] = result.compactedMessages
379
430
 
380
431
  return withSessionId(
381
432
  sessionId,
@@ -397,7 +448,10 @@ export function createContextCompactedEvent(
397
448
  * tool-call and tool-result detail in the rendered content. Used for the
398
449
  * compaction "input" snapshot shown in the debug UI — not for reconstruction.
399
450
  */
400
- function toDisplayMessage(msg: LLMMessage): CompactedConversationMessage {
451
+ function toDisplayMessage(msg: LLMMessage): {
452
+ role: 'user' | 'assistant' | 'system'
453
+ content: string
454
+ } {
401
455
  if (msg.role === 'assistant') {
402
456
  const parts: string[] = []
403
457
  if (msg.content) parts.push(msg.content)
@@ -23,6 +23,8 @@ export interface AgentLimits {
23
23
  maxRepeatedToolCalls?: number
24
24
  /** Maximum consecutive identical text-only responses. Default: 3 */
25
25
  maxRepeatedResponses?: number
26
+ /** Maximum consecutive outbound-only turns without newly consumed inbound work. Default: 3 */
27
+ maxConsecutiveNoProgressTurns?: number
26
28
  /**
27
29
  * Maximum cumulative LLM cost (USD) this agent may spend before it is paused.
28
30
  * Spend is summed from `inference_completed` metrics and, unlike the counter
@@ -14,6 +14,7 @@ describe('resolveAgentLimits', () => {
14
14
  expect(limits.softLimitRatio).toBe(0.8)
15
15
  expect(limits.maxRepeatedToolCalls).toBe(3)
16
16
  expect(limits.maxRepeatedResponses).toBe(3)
17
+ expect(limits.maxConsecutiveNoProgressTurns).toBe(3)
17
18
  // Budgets and compaction cap are opt-in (unlimited by default)
18
19
  expect(limits.maxCost).toBe(Number.POSITIVE_INFINITY)
19
20
  expect(limits.maxTokens).toBe(Number.POSITIVE_INFINITY)
@@ -112,6 +113,18 @@ describe('checkLimits', () => {
112
113
  }
113
114
  })
114
115
 
116
+ it('detects consecutive no-progress turns', () => {
117
+ const result = checkLimits(
118
+ makeCounters({ consecutiveNoProgressTurns: 3 }),
119
+ defaultLimits,
120
+ )
121
+ expect(result.status).toBe('hard_limit')
122
+ if (result.status === 'hard_limit') {
123
+ expect(result.limitName).toBe('maxConsecutiveNoProgressTurns')
124
+ expect(result.reason).toContain('used only outbound communication')
125
+ }
126
+ })
127
+
115
128
  it('detects consecutive tool failures', () => {
116
129
  const result = checkLimits(
117
130
  makeCounters({ consecutiveToolFailures: { read_file: { count: 3, lastError: 'file not found' } } }),
@@ -20,6 +20,7 @@ export interface ResolvedAgentLimits {
20
20
  softLimitRatio: number
21
21
  maxRepeatedToolCalls: number
22
22
  maxRepeatedResponses: number
23
+ maxConsecutiveNoProgressTurns: number
23
24
  maxCost: number
24
25
  maxTokens: number
25
26
  maxCompactions: number
@@ -34,6 +35,7 @@ const DEFAULTS: ResolvedAgentLimits = {
34
35
  softLimitRatio: 0.8,
35
36
  maxRepeatedToolCalls: 3,
36
37
  maxRepeatedResponses: 3,
38
+ maxConsecutiveNoProgressTurns: 3,
37
39
  // Budgets and the compaction cap are opt-in: unset means unlimited.
38
40
  maxCost: Number.POSITIVE_INFINITY,
39
41
  maxTokens: Number.POSITIVE_INFINITY,
@@ -51,6 +53,8 @@ export function resolveAgentLimits(config?: AgentLimits): ResolvedAgentLimits {
51
53
  softLimitRatio: config.softLimitRatio ?? DEFAULTS.softLimitRatio,
52
54
  maxRepeatedToolCalls: config.maxRepeatedToolCalls ?? DEFAULTS.maxRepeatedToolCalls,
53
55
  maxRepeatedResponses: config.maxRepeatedResponses ?? DEFAULTS.maxRepeatedResponses,
56
+ maxConsecutiveNoProgressTurns:
57
+ config.maxConsecutiveNoProgressTurns ?? DEFAULTS.maxConsecutiveNoProgressTurns,
54
58
  maxCost: config.maxCost ?? DEFAULTS.maxCost,
55
59
  maxTokens: config.maxTokens ?? DEFAULTS.maxTokens,
56
60
  maxCompactions: config.maxCompactions ?? DEFAULTS.maxCompactions,
@@ -165,16 +169,24 @@ export function checkLimits(counters: AgentCounters, limits: ResolvedAgentLimits
165
169
  { name: 'maxSpawnedAgents', current: counters.spawnedAgentCount, max: limits.maxSpawnedAgents },
166
170
  { name: 'maxMessagesSent', current: counters.messagesSentCount, max: limits.maxMessagesSent },
167
171
  { name: 'maxCompactions', current: counters.compactionCount, max: limits.maxCompactions },
172
+ {
173
+ name: 'maxConsecutiveNoProgressTurns',
174
+ current: counters.consecutiveNoProgressTurns,
175
+ max: limits.maxConsecutiveNoProgressTurns,
176
+ },
168
177
  ]
169
178
 
170
179
  for (const check of hardChecks) {
171
180
  if (check.current >= check.max) {
181
+ const reason = check.name === 'maxConsecutiveNoProgressTurns'
182
+ ? `No progress detected: ${check.current} consecutive turns used only outbound communication without new input`
183
+ : `${check.name} reached: ${check.current}/${check.max}`
172
184
  return {
173
185
  status: 'hard_limit',
174
186
  limitName: check.name,
175
187
  currentValue: check.current,
176
188
  hardLimit: check.max,
177
- reason: `${check.name} reached: ${check.current}/${check.max}`,
189
+ reason,
178
190
  }
179
191
  }
180
192
  }