@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.
- package/dist/core/agents/agent.d.ts +28 -0
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +116 -39
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/config.d.ts +9 -0
- package/dist/core/agents/config.d.ts.map +1 -1
- package/dist/core/agents/state.d.ts +9 -0
- package/dist/core/agents/state.d.ts.map +1 -1
- package/dist/core/agents/state.js +4 -0
- package/dist/core/agents/state.js.map +1 -1
- package/dist/core/context/state.d.ts +22 -13
- package/dist/core/context/state.d.ts.map +1 -1
- package/dist/core/context/state.js +78 -22
- package/dist/core/context/state.js.map +1 -1
- package/dist/core/error-recovery.integration.test.d.ts +2 -0
- package/dist/core/error-recovery.integration.test.d.ts.map +1 -0
- package/dist/core/error-recovery.integration.test.js +288 -0
- package/dist/core/error-recovery.integration.test.js.map +1 -0
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +6 -1
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/anthropic.test.js +7 -4
- package/dist/core/llm/anthropic.test.js.map +1 -1
- package/dist/core/llm/message-sanitization.d.ts +10 -0
- package/dist/core/llm/message-sanitization.d.ts.map +1 -0
- package/dist/core/llm/message-sanitization.js +53 -0
- package/dist/core/llm/message-sanitization.js.map +1 -0
- package/dist/core/llm/message-sanitization.test.d.ts +2 -0
- package/dist/core/llm/message-sanitization.test.d.ts.map +1 -0
- package/dist/core/llm/message-sanitization.test.js +181 -0
- package/dist/core/llm/message-sanitization.test.js.map +1 -0
- package/dist/core/llm/middleware.d.ts +3 -3
- package/dist/core/llm/middleware.js +3 -3
- package/dist/core/llm/openrouter-mapping.test.js +21 -3
- package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
- package/dist/core/llm/openrouter.d.ts.map +1 -1
- package/dist/core/llm/openrouter.js +6 -1
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/core/llm/provider.d.ts +13 -10
- package/dist/core/llm/provider.d.ts.map +1 -1
- package/dist/core/llm/provider.js.map +1 -1
- package/dist/core/llm/state.d.ts +10 -0
- package/dist/core/llm/state.d.ts.map +1 -1
- package/dist/core/llm/state.js +9 -0
- package/dist/core/llm/state.js.map +1 -1
- package/dist/core/plugins/plugin-builder.d.ts +2 -0
- package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
- package/dist/core/plugins/plugin-builder.js.map +1 -1
- package/dist/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +12 -2
- package/dist/core/sessions/session.js.map +1 -1
- package/dist/core/sessions/state.d.ts.map +1 -1
- package/dist/core/sessions/state.js +43 -5
- package/dist/core/sessions/state.js.map +1 -1
- package/dist/plugins/context-compact/context-compact.integration.test.js +36 -20
- package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
- package/dist/plugins/context-compact/context-compactor.js +54 -9
- package/dist/plugins/context-compact/context-compactor.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.test.js +127 -4
- package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
- package/dist/plugins/limits-guard/config.d.ts +2 -0
- package/dist/plugins/limits-guard/config.d.ts.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.d.ts +1 -0
- package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.js +11 -1
- package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.test.js +9 -0
- package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
- package/dist/plugins/limits-guard/limits-guard.integration.test.js +216 -1
- package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
- package/dist/plugins/limits-guard/plugin.d.ts +3 -1
- package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
- package/dist/plugins/limits-guard/plugin.js +48 -2
- package/dist/plugins/limits-guard/plugin.js.map +1 -1
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +4 -1
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/prompt.js +3 -3
- package/dist/plugins/services/prompt.js.map +1 -1
- package/dist/plugins/services/schema.d.ts +8 -0
- package/dist/plugins/services/schema.d.ts.map +1 -1
- package/dist/plugins/services/services.integration.test.js +112 -0
- package/dist/plugins/services/services.integration.test.js.map +1 -1
- package/dist/plugins/uploads/plugin.d.ts.map +1 -1
- package/dist/plugins/uploads/plugin.js +10 -1
- package/dist/plugins/uploads/plugin.js.map +1 -1
- package/dist/plugins/uploads/uploads.integration.test.js +74 -28
- package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
- package/dist/testing/test-harness.d.ts +8 -0
- package/dist/testing/test-harness.d.ts.map +1 -1
- package/dist/testing/test-harness.js +18 -0
- package/dist/testing/test-harness.js.map +1 -1
- package/dist/transport/http/middleware/error-handler.d.ts +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +124 -36
- package/src/core/agents/config.ts +6 -0
- package/src/core/agents/state.ts +9 -0
- package/src/core/context/state.ts +86 -27
- package/src/core/error-recovery.integration.test.ts +343 -0
- package/src/core/llm/anthropic.test.ts +7 -4
- package/src/core/llm/anthropic.ts +6 -1
- package/src/core/llm/message-sanitization.test.ts +217 -0
- package/src/core/llm/message-sanitization.ts +70 -0
- package/src/core/llm/middleware.ts +3 -3
- package/src/core/llm/openrouter-mapping.test.ts +24 -3
- package/src/core/llm/openrouter.ts +7 -8
- package/src/core/llm/provider.ts +14 -10
- package/src/core/llm/state.ts +10 -0
- package/src/core/plugins/plugin-builder.ts +6 -2
- package/src/core/sessions/session.ts +13 -3
- package/src/core/sessions/state.ts +48 -9
- package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
- package/src/plugins/context-compact/context-compactor.test.ts +150 -4
- package/src/plugins/context-compact/context-compactor.ts +64 -10
- package/src/plugins/limits-guard/config.ts +2 -0
- package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
- package/src/plugins/limits-guard/limit-guard.ts +13 -1
- package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
- package/src/plugins/limits-guard/plugin.ts +53 -3
- package/src/plugins/services/plugin.ts +4 -1
- package/src/plugins/services/prompt.ts +3 -3
- package/src/plugins/services/schema.ts +8 -0
- package/src/plugins/services/services.integration.test.ts +129 -0
- package/src/plugins/uploads/plugin.ts +11 -1
- package/src/plugins/uploads/uploads.integration.test.ts +82 -28
- 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
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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('
|
|
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: [
|
|
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
|
-
|
|
334
|
-
|
|
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 =
|
|
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 ??
|
|
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 ??
|
|
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 =
|
|
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
|
-
|
|
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):
|
|
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
|
|
189
|
+
reason,
|
|
178
190
|
}
|
|
179
191
|
}
|
|
180
192
|
}
|