@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.
- 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 +6 -0
- 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/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 +6 -0
- 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/uploads/plugin.ts +11 -1
- package/src/plugins/uploads/uploads.integration.test.ts +82 -28
- package/src/testing/test-harness.ts +20 -0
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import z from 'zod/v4'
|
|
2
3
|
import { AgentId } from '~/core/agents/schema.js'
|
|
3
4
|
import { agentEvents } from '~/core/agents/state.js'
|
|
4
5
|
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
@@ -6,7 +7,10 @@ import type { InferenceRequest } from '~/core/llm/provider.js'
|
|
|
6
7
|
import { ModelId } from '~/core/llm/schema.js'
|
|
7
8
|
import { llmEvents } from '~/core/llm/state.js'
|
|
8
9
|
import { selectPluginState } from '~/core/sessions/reducer.js'
|
|
10
|
+
import { createTool } from '~/core/tools/definition.js'
|
|
9
11
|
import { ToolCallId } from '~/core/tools/schema.js'
|
|
12
|
+
import { toolEvents } from '~/core/tools/state.js'
|
|
13
|
+
import { Ok } from '~/lib/utils/result.js'
|
|
10
14
|
import { contextCompactPlugin } from '~/plugins/context-compact/index.js'
|
|
11
15
|
import { getAgentMailbox, selectMailboxState } from '~/plugins/mailbox/query.js'
|
|
12
16
|
import { mailboxEvents } from '~/plugins/mailbox/state.js'
|
|
@@ -34,6 +38,15 @@ async function waitForAgentPaused(session: TestSession, agentId: AgentId, timeou
|
|
|
34
38
|
throw new Error(`waitForAgentPaused timed out after ${timeoutMs}ms for agent ${agentId}`)
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
async function waitUntil(predicate: () => boolean, timeoutMs = 10000): Promise<void> {
|
|
42
|
+
const deadline = Date.now() + timeoutMs
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
if (predicate()) return
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`waitUntil timed out after ${timeoutMs}ms`)
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Wait for all agents to be either idle (pending with no work) or paused.
|
|
39
52
|
*/
|
|
@@ -179,6 +192,236 @@ describe('limits-guard plugin', () => {
|
|
|
179
192
|
|
|
180
193
|
await harness.shutdown()
|
|
181
194
|
})
|
|
195
|
+
|
|
196
|
+
it('communication-only loop without new inbound work → soft-stops and remains wakeable', async () => {
|
|
197
|
+
let inferenceCount = 0
|
|
198
|
+
|
|
199
|
+
const harness = createLimitsHarness({
|
|
200
|
+
presets: [createTestPreset({
|
|
201
|
+
orchestratorSystem: 'Test agent.',
|
|
202
|
+
orchestratorPlugins: [limitsGuardPlugin.configureAgent({
|
|
203
|
+
limits: { maxConsecutiveNoProgressTurns: 3, maxTurns: 100 },
|
|
204
|
+
})],
|
|
205
|
+
})],
|
|
206
|
+
mockHandler: () => {
|
|
207
|
+
inferenceCount++
|
|
208
|
+
if (inferenceCount > 4) {
|
|
209
|
+
return {
|
|
210
|
+
content: 'Recovered for new work',
|
|
211
|
+
toolCalls: [],
|
|
212
|
+
finishReason: 'stop',
|
|
213
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
content: null,
|
|
218
|
+
toolCalls: [{
|
|
219
|
+
id: ToolCallId(`tc${inferenceCount}`),
|
|
220
|
+
name: 'tell_user',
|
|
221
|
+
input: { message: `Still waiting, update ${inferenceCount}` },
|
|
222
|
+
}],
|
|
223
|
+
finishReason: 'stop',
|
|
224
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const session = await harness.createSession('test')
|
|
230
|
+
const entryAgentId = session.getEntryAgentId()!
|
|
231
|
+
await session.sendAndWaitForIdle('Start')
|
|
232
|
+
|
|
233
|
+
// The response to "Start" had inbound work. The next three turns were
|
|
234
|
+
// driven only by the previous tell_user result, so the fourth inference
|
|
235
|
+
// reaches the limit and its redundant tool call is suppressed.
|
|
236
|
+
expect(inferenceCount).toBe(4)
|
|
237
|
+
const toolStarted = await session.getEventsByType(toolEvents, 'tool_started')
|
|
238
|
+
expect(toolStarted).toHaveLength(3)
|
|
239
|
+
|
|
240
|
+
const pauseEvents = await session.getEventsByType(agentEvents, 'agent_paused')
|
|
241
|
+
expect(pauseEvents.some((event) => event.agentId === entryAgentId)).toBe(false)
|
|
242
|
+
|
|
243
|
+
const warningEvents = await session.getEventsByType(limitsEvents, 'limit_warning')
|
|
244
|
+
const noProgressEvent = warningEvents.find((event) =>
|
|
245
|
+
event.agentId === entryAgentId
|
|
246
|
+
&& event.limitName === 'maxConsecutiveNoProgressTurns'
|
|
247
|
+
)
|
|
248
|
+
expect(noProgressEvent?.currentValue).toBe(3)
|
|
249
|
+
expect(noProgressEvent?.message).toContain('until new input arrives')
|
|
250
|
+
expect(session.state.agents.get(entryAgentId)?.status).toBe('pending')
|
|
251
|
+
|
|
252
|
+
// The stop is recoverable without agents.resume or human intervention.
|
|
253
|
+
await session.sendAndWaitForIdle('Now do real work')
|
|
254
|
+
expect(inferenceCount).toBe(5)
|
|
255
|
+
expect(session.state.agents.get(entryAgentId)?.status).toBe('pending')
|
|
256
|
+
|
|
257
|
+
await harness.shutdown()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('communication-only replies to distinct user messages do not count as no progress', async () => {
|
|
261
|
+
let inferenceCount = 0
|
|
262
|
+
let allowProcessing = false
|
|
263
|
+
const preset = createTestPreset({
|
|
264
|
+
orchestratorSystem: 'Test agent.',
|
|
265
|
+
orchestratorPlugins: [limitsGuardPlugin.configureAgent({
|
|
266
|
+
limits: { maxConsecutiveNoProgressTurns: 3, maxTurns: 100 },
|
|
267
|
+
})],
|
|
268
|
+
})
|
|
269
|
+
preset.orchestrator.debounceCallback = () => {
|
|
270
|
+
if (!allowProcessing) return 'wait'
|
|
271
|
+
allowProcessing = false
|
|
272
|
+
return 'process_now'
|
|
273
|
+
}
|
|
274
|
+
preset.orchestrator.checkIntervalMs = 1
|
|
275
|
+
|
|
276
|
+
const harness = createLimitsHarness({
|
|
277
|
+
presets: [preset],
|
|
278
|
+
mockHandler: () => {
|
|
279
|
+
inferenceCount++
|
|
280
|
+
return {
|
|
281
|
+
content: null,
|
|
282
|
+
toolCalls: [{
|
|
283
|
+
id: ToolCallId(`reply-${inferenceCount}`),
|
|
284
|
+
name: 'tell_user',
|
|
285
|
+
input: { message: `Reply ${inferenceCount}` },
|
|
286
|
+
}],
|
|
287
|
+
finishReason: 'stop',
|
|
288
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const session = await harness.createSession('test')
|
|
294
|
+
const entryAgentId = session.getEntryAgentId()!
|
|
295
|
+
|
|
296
|
+
for (let messageNumber = 1; messageNumber <= 3; messageNumber++) {
|
|
297
|
+
await session.sendMessage(`User message ${messageNumber}`)
|
|
298
|
+
allowProcessing = true
|
|
299
|
+
await waitUntil(() => {
|
|
300
|
+
const state = session.state.agents.get(entryAgentId)
|
|
301
|
+
return inferenceCount === messageNumber && state?.pendingToolResults.length === 1
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const counters = selectPluginState<Map<AgentId, AgentCounters>>(
|
|
305
|
+
session.state,
|
|
306
|
+
'agentLimits',
|
|
307
|
+
)?.get(entryAgentId)
|
|
308
|
+
expect(counters?.consecutiveNoProgressTurns).toBe(0)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const consumedEvents = await session.getEventsByType(agentEvents, 'agent_input_consumed')
|
|
312
|
+
expect(consumedEvents).toHaveLength(3)
|
|
313
|
+
expect(consumedEvents.every((event) => event.sourcePlugins.includes('user-chat'))).toBe(true)
|
|
314
|
+
|
|
315
|
+
const pauseEvents = await session.getEventsByType(agentEvents, 'agent_paused')
|
|
316
|
+
expect(pauseEvents.some((event) => event.agentId === entryAgentId)).toBe(false)
|
|
317
|
+
const warningEvents = await session.getEventsByType(limitsEvents, 'limit_warning')
|
|
318
|
+
expect(warningEvents.some((event) => event.limitName === 'maxConsecutiveNoProgressTurns')).toBe(false)
|
|
319
|
+
|
|
320
|
+
await harness.shutdown()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('send_message followed by non-communication work does not trip the no-progress limit', async () => {
|
|
324
|
+
let workerInferenceCount = 0
|
|
325
|
+
let workCount = 0
|
|
326
|
+
|
|
327
|
+
const recordWork = createTool({
|
|
328
|
+
name: 'record_work',
|
|
329
|
+
description: 'Record one unit of real work.',
|
|
330
|
+
input: z.object({}),
|
|
331
|
+
execute: async () => {
|
|
332
|
+
workCount++
|
|
333
|
+
return Ok('work recorded')
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
const harness = createLimitsHarness({
|
|
338
|
+
presets: [createMultiAgentPreset([
|
|
339
|
+
{
|
|
340
|
+
name: 'worker',
|
|
341
|
+
system: 'Worker agent.',
|
|
342
|
+
tools: [recordWork],
|
|
343
|
+
agents: [],
|
|
344
|
+
plugins: [limitsGuardPlugin.configureAgent({
|
|
345
|
+
limits: { maxConsecutiveNoProgressTurns: 2, maxTurns: 100 },
|
|
346
|
+
})],
|
|
347
|
+
},
|
|
348
|
+
], { orchestratorSystem: 'Orchestrator agent.' })],
|
|
349
|
+
mockHandler: (request) => {
|
|
350
|
+
if (request.systemPrompt.endsWith('Orchestrator agent.')) {
|
|
351
|
+
const hasStartedWorker = request.messages.some((message) =>
|
|
352
|
+
message.role === 'assistant'
|
|
353
|
+
&& message.toolCalls?.some((toolCall) => toolCall.name === 'start_worker')
|
|
354
|
+
)
|
|
355
|
+
if (!hasStartedWorker) {
|
|
356
|
+
return {
|
|
357
|
+
content: null,
|
|
358
|
+
toolCalls: [{ id: ToolCallId('start-worker'), name: 'start_worker', input: { message: 'Do the work' } }],
|
|
359
|
+
finishReason: 'stop',
|
|
360
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
content: 'Orchestration complete',
|
|
365
|
+
toolCalls: [],
|
|
366
|
+
finishReason: 'stop',
|
|
367
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
workerInferenceCount++
|
|
372
|
+
switch (workerInferenceCount) {
|
|
373
|
+
case 1:
|
|
374
|
+
return {
|
|
375
|
+
content: null,
|
|
376
|
+
toolCalls: [{
|
|
377
|
+
id: ToolCallId('worker-progress'),
|
|
378
|
+
name: 'send_message',
|
|
379
|
+
input: { to: 'parent', message: 'Starting real work' },
|
|
380
|
+
}],
|
|
381
|
+
finishReason: 'stop',
|
|
382
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
383
|
+
}
|
|
384
|
+
case 2:
|
|
385
|
+
return {
|
|
386
|
+
content: null,
|
|
387
|
+
toolCalls: [{ id: ToolCallId('worker-work'), name: 'record_work', input: {} }],
|
|
388
|
+
finishReason: 'stop',
|
|
389
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
390
|
+
}
|
|
391
|
+
case 3:
|
|
392
|
+
return {
|
|
393
|
+
content: null,
|
|
394
|
+
toolCalls: [{
|
|
395
|
+
id: ToolCallId('worker-result'),
|
|
396
|
+
name: 'send_message',
|
|
397
|
+
input: { to: 'parent', message: 'Real work complete' },
|
|
398
|
+
}],
|
|
399
|
+
finishReason: 'stop',
|
|
400
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
401
|
+
}
|
|
402
|
+
default:
|
|
403
|
+
return {
|
|
404
|
+
content: 'WAITING',
|
|
405
|
+
toolCalls: [],
|
|
406
|
+
finishReason: 'stop',
|
|
407
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const session = await harness.createSession('test')
|
|
414
|
+
await session.sendAndWaitForIdle('Start', { timeoutMs: 10_000 })
|
|
415
|
+
|
|
416
|
+
expect(workCount).toBe(1)
|
|
417
|
+
expect(workerInferenceCount).toBe(4)
|
|
418
|
+
expect(session.state.agents.get(AgentId('worker_1'))?.status).toBe('pending')
|
|
419
|
+
|
|
420
|
+
const pauseEvents = await session.getEventsByType(agentEvents, 'agent_paused')
|
|
421
|
+
expect(pauseEvents.some((event) => event.agentId === AgentId('worker_1'))).toBe(false)
|
|
422
|
+
|
|
423
|
+
await harness.shutdown()
|
|
424
|
+
})
|
|
182
425
|
})
|
|
183
426
|
|
|
184
427
|
// =========================================================================
|
|
@@ -193,7 +436,9 @@ describe('limits-guard plugin', () => {
|
|
|
193
436
|
const harness = createLimitsHarness({
|
|
194
437
|
presets: [createTestPreset({
|
|
195
438
|
orchestratorSystem: 'Test agent.',
|
|
196
|
-
orchestratorPlugins: [limitsGuardPlugin.configureAgent({
|
|
439
|
+
orchestratorPlugins: [limitsGuardPlugin.configureAgent({
|
|
440
|
+
limits: { maxTurns: 5, maxConsecutiveNoProgressTurns: 100 },
|
|
441
|
+
})],
|
|
197
442
|
})],
|
|
198
443
|
mockHandler: (request) => {
|
|
199
444
|
inferenceCount++
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { AgentId } from '~/core/agents/schema.js'
|
|
2
2
|
import { agentEvents } from '~/core/agents/state.js'
|
|
3
3
|
import { contextEvents } from '~/core/context/state.js'
|
|
4
4
|
import { llmEvents } from '~/core/llm/state.js'
|
|
@@ -27,6 +27,8 @@ export interface AgentCounters {
|
|
|
27
27
|
toolCallCount: number
|
|
28
28
|
spawnedAgentCount: number
|
|
29
29
|
messagesSentCount: number
|
|
30
|
+
/** Consecutive outbound-only turns with no newly consumed inbound work. */
|
|
31
|
+
consecutiveNoProgressTurns: number
|
|
30
32
|
/** Number of context compaction events for this agent. */
|
|
31
33
|
compactionCount: number
|
|
32
34
|
/** Cumulative LLM cost (USD) summed from inference metrics. NOT reset on resume. */
|
|
@@ -46,6 +48,7 @@ export const createAgentCounters = (): AgentCounters => ({
|
|
|
46
48
|
toolCallCount: 0,
|
|
47
49
|
spawnedAgentCount: 0,
|
|
48
50
|
messagesSentCount: 0,
|
|
51
|
+
consecutiveNoProgressTurns: 0,
|
|
49
52
|
compactionCount: 0,
|
|
50
53
|
costSpent: 0,
|
|
51
54
|
tokensUsed: 0,
|
|
@@ -65,6 +68,12 @@ export function sumSessionSpend(limits: Map<AgentId, AgentCounters>): BudgetSpen
|
|
|
65
68
|
return { costSpent, tokensUsed }
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
const OUTBOUND_COMMUNICATION_TOOLS = new Set(['send_message', 'tell_user'])
|
|
72
|
+
|
|
73
|
+
function isOutboundCommunicationOnly(toolCalls: Array<{ name: string }>): boolean {
|
|
74
|
+
return toolCalls.length > 0 && toolCalls.every((toolCall) => OUTBOUND_COMMUNICATION_TOOLS.has(toolCall.name))
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
/**
|
|
69
78
|
* Extract agent counters from session state (for external consumers).
|
|
70
79
|
*/
|
|
@@ -166,22 +175,35 @@ export const limitsGuardPlugin = definePlugin('limits-guard')
|
|
|
166
175
|
: null
|
|
167
176
|
if (!senderAgentId) return limits
|
|
168
177
|
|
|
169
|
-
const senderCounters = limits.get(senderAgentId
|
|
178
|
+
const senderCounters = limits.get(AgentId(senderAgentId))
|
|
170
179
|
if (!senderCounters) return limits
|
|
171
180
|
|
|
172
181
|
const newLimits = new Map(limits)
|
|
173
|
-
newLimits.set(senderAgentId
|
|
182
|
+
newLimits.set(AgentId(senderAgentId), {
|
|
174
183
|
...senderCounters,
|
|
175
184
|
messagesSentCount: senderCounters.messagesSentCount + 1,
|
|
176
185
|
})
|
|
177
186
|
return newLimits
|
|
178
187
|
}
|
|
179
188
|
|
|
189
|
+
case 'agent_input_consumed': {
|
|
190
|
+
const counters = limits.get(event.agentId)
|
|
191
|
+
if (!counters?.consecutiveNoProgressTurns) return limits
|
|
192
|
+
|
|
193
|
+
const newLimits = new Map(limits)
|
|
194
|
+
newLimits.set(event.agentId, {
|
|
195
|
+
...counters,
|
|
196
|
+
consecutiveNoProgressTurns: 0,
|
|
197
|
+
})
|
|
198
|
+
return newLimits
|
|
199
|
+
}
|
|
200
|
+
|
|
180
201
|
case 'inference_completed': {
|
|
181
202
|
const counters = limits.get(event.agentId)
|
|
182
203
|
if (!counters) return limits
|
|
183
204
|
|
|
184
205
|
const hasToolCalls = event.response.toolCalls.length > 0
|
|
206
|
+
const communicationOnly = isOutboundCommunicationOnly(event.response.toolCalls)
|
|
185
207
|
let newRecentResponseHashes = counters.recentResponseHashes
|
|
186
208
|
|
|
187
209
|
if (!hasToolCalls) {
|
|
@@ -198,6 +220,9 @@ export const limitsGuardPlugin = definePlugin('limits-guard')
|
|
|
198
220
|
inferenceCount: counters.inferenceCount + 1,
|
|
199
221
|
costSpent: counters.costSpent + (event.metrics.cost ?? 0),
|
|
200
222
|
tokensUsed: counters.tokensUsed + (event.metrics.totalTokens ?? 0),
|
|
223
|
+
consecutiveNoProgressTurns: communicationOnly
|
|
224
|
+
? counters.consecutiveNoProgressTurns + 1
|
|
225
|
+
: 0,
|
|
201
226
|
recentResponseHashes: newRecentResponseHashes,
|
|
202
227
|
})
|
|
203
228
|
return newLimits
|
|
@@ -296,6 +321,7 @@ export const limitsGuardPlugin = definePlugin('limits-guard')
|
|
|
296
321
|
toolCallCount: 0,
|
|
297
322
|
spawnedAgentCount: 0,
|
|
298
323
|
messagesSentCount: 0,
|
|
324
|
+
consecutiveNoProgressTurns: 0,
|
|
299
325
|
compactionCount: 0,
|
|
300
326
|
consecutiveToolFailures: {},
|
|
301
327
|
recentToolCallHashes: [],
|
|
@@ -368,9 +394,14 @@ export const limitsGuardPlugin = definePlugin('limits-guard')
|
|
|
368
394
|
// The inference_completed event hasn't been emitted yet, so project
|
|
369
395
|
// what the counters will be after it's processed by the reducer.
|
|
370
396
|
const hasToolCalls = ctx.response.toolCalls.length > 0
|
|
397
|
+
const communicationOnly = isOutboundCommunicationOnly(ctx.response.toolCalls)
|
|
398
|
+
const hasInboundWork = ctx.dequeuedSourcePlugins.length > 0
|
|
371
399
|
const projected: AgentCounters = {
|
|
372
400
|
...counters,
|
|
373
401
|
inferenceCount: counters.inferenceCount + 1,
|
|
402
|
+
consecutiveNoProgressTurns: communicationOnly && !hasInboundWork
|
|
403
|
+
? counters.consecutiveNoProgressTurns + 1
|
|
404
|
+
: 0,
|
|
374
405
|
recentResponseHashes: hasToolCalls
|
|
375
406
|
? counters.recentResponseHashes
|
|
376
407
|
: [...counters.recentResponseHashes, responseFingerprint(ctx.response.content)].slice(-10),
|
|
@@ -379,6 +410,25 @@ export const limitsGuardPlugin = definePlugin('limits-guard')
|
|
|
379
410
|
const limitCheck = checkLimits(projected, resolvedLimits)
|
|
380
411
|
|
|
381
412
|
if (limitCheck.status === 'hard_limit') {
|
|
413
|
+
if (limitCheck.limitName === 'maxConsecutiveNoProgressTurns') {
|
|
414
|
+
const message = `${limitCheck.reason}; suppressing redundant outbound communication until new input arrives`
|
|
415
|
+
await ctx.emitEvent(limitsEvents.create('limit_warning', {
|
|
416
|
+
agentId: ctx.agentId,
|
|
417
|
+
limitName: limitCheck.limitName,
|
|
418
|
+
currentValue: limitCheck.currentValue,
|
|
419
|
+
hardLimit: limitCheck.hardLimit,
|
|
420
|
+
message,
|
|
421
|
+
}))
|
|
422
|
+
ctx.logger.warn('Communication-only agent loop stopped', {
|
|
423
|
+
agentId: ctx.agentId,
|
|
424
|
+
consecutiveTurns: limitCheck.currentValue,
|
|
425
|
+
dequeuedSourcePlugins: ctx.dequeuedSourcePlugins,
|
|
426
|
+
})
|
|
427
|
+
return {
|
|
428
|
+
action: 'modify',
|
|
429
|
+
response: { content: 'WAITING', toolCalls: [] },
|
|
430
|
+
}
|
|
431
|
+
}
|
|
382
432
|
return { action: 'pause', reason: limitCheck.reason }
|
|
383
433
|
}
|
|
384
434
|
|
|
@@ -3,6 +3,7 @@ import { ValidationErrors } from '~/core/errors.js'
|
|
|
3
3
|
import type { FileStore } from '~/core/file-store/types.js'
|
|
4
4
|
import { definePlugin } from '~/core/plugins/plugin-builder.js'
|
|
5
5
|
import { SessionId } from '~/core/sessions/schema.js'
|
|
6
|
+
import { getEntryAgentId } from '~/core/sessions/state.js'
|
|
6
7
|
import { Err, Ok } from '~/lib/utils/result.js'
|
|
7
8
|
import type { PreprocessorRegistry } from './preprocessor.js'
|
|
8
9
|
import { generateUploadId, type MessageAttachment, UploadId, type UploadMetadata } from './schema.js'
|
|
@@ -430,6 +431,11 @@ export const uploadsPlugin = definePlugin('uploads')
|
|
|
430
431
|
error: result.error,
|
|
431
432
|
}))
|
|
432
433
|
|
|
434
|
+
const entryAgentId = getEntryAgentId(ctx.sessionState)
|
|
435
|
+
if (result.status === 'ready' && entryAgentId) {
|
|
436
|
+
ctx.scheduleAgent(entryAgentId)
|
|
437
|
+
}
|
|
438
|
+
|
|
433
439
|
return Ok({
|
|
434
440
|
uploadId: String(uploadId),
|
|
435
441
|
status: result.status,
|
|
@@ -501,8 +507,9 @@ export const uploadsPlugin = definePlugin('uploads')
|
|
|
501
507
|
// Capture refs from ctx before the handler returns — `notify`/`emitEvent`
|
|
502
508
|
// closures stay valid for the lifetime of the session, which in roj
|
|
503
509
|
// outlives any single handler call.
|
|
504
|
-
const { emitEvent, notify, logger } = ctx
|
|
510
|
+
const { emitEvent, notify, logger, scheduleAgent } = ctx
|
|
505
511
|
const sessionId = ctx.sessionId
|
|
512
|
+
const entryAgentId = getEntryAgentId(ctx.sessionState)
|
|
506
513
|
|
|
507
514
|
void (async () => {
|
|
508
515
|
try {
|
|
@@ -528,6 +535,9 @@ export const uploadsPlugin = definePlugin('uploads')
|
|
|
528
535
|
derivedPaths: result.derivedPaths,
|
|
529
536
|
error: result.error,
|
|
530
537
|
}))
|
|
538
|
+
if (result.status === 'ready' && entryAgentId) {
|
|
539
|
+
scheduleAgent(entryAgentId)
|
|
540
|
+
}
|
|
531
541
|
notify('uploadStatusChanged', {
|
|
532
542
|
sessionId: input.sessionId,
|
|
533
543
|
uploadId: uploadIdStr,
|