@roj-ai/sdk 0.1.29 → 0.1.31

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 (121) hide show
  1. package/dist/core/agents/agent.d.ts +28 -0
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js +116 -39
  4. package/dist/core/agents/agent.js.map +1 -1
  5. package/dist/core/agents/config.d.ts +9 -0
  6. package/dist/core/agents/config.d.ts.map +1 -1
  7. package/dist/core/agents/state.d.ts +9 -0
  8. package/dist/core/agents/state.d.ts.map +1 -1
  9. package/dist/core/agents/state.js +4 -0
  10. package/dist/core/agents/state.js.map +1 -1
  11. package/dist/core/context/state.d.ts +22 -13
  12. package/dist/core/context/state.d.ts.map +1 -1
  13. package/dist/core/context/state.js +78 -22
  14. package/dist/core/context/state.js.map +1 -1
  15. package/dist/core/error-recovery.integration.test.d.ts +2 -0
  16. package/dist/core/error-recovery.integration.test.d.ts.map +1 -0
  17. package/dist/core/error-recovery.integration.test.js +288 -0
  18. package/dist/core/error-recovery.integration.test.js.map +1 -0
  19. package/dist/core/llm/anthropic.d.ts.map +1 -1
  20. package/dist/core/llm/anthropic.js +6 -1
  21. package/dist/core/llm/anthropic.js.map +1 -1
  22. package/dist/core/llm/anthropic.test.js +7 -4
  23. package/dist/core/llm/anthropic.test.js.map +1 -1
  24. package/dist/core/llm/message-sanitization.d.ts +10 -0
  25. package/dist/core/llm/message-sanitization.d.ts.map +1 -0
  26. package/dist/core/llm/message-sanitization.js +53 -0
  27. package/dist/core/llm/message-sanitization.js.map +1 -0
  28. package/dist/core/llm/message-sanitization.test.d.ts +2 -0
  29. package/dist/core/llm/message-sanitization.test.d.ts.map +1 -0
  30. package/dist/core/llm/message-sanitization.test.js +181 -0
  31. package/dist/core/llm/message-sanitization.test.js.map +1 -0
  32. package/dist/core/llm/middleware.d.ts +3 -3
  33. package/dist/core/llm/middleware.js +3 -3
  34. package/dist/core/llm/openrouter-mapping.test.js +21 -3
  35. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  36. package/dist/core/llm/openrouter.d.ts.map +1 -1
  37. package/dist/core/llm/openrouter.js +6 -1
  38. package/dist/core/llm/openrouter.js.map +1 -1
  39. package/dist/core/llm/provider.d.ts +13 -10
  40. package/dist/core/llm/provider.d.ts.map +1 -1
  41. package/dist/core/llm/provider.js.map +1 -1
  42. package/dist/core/llm/state.d.ts +10 -0
  43. package/dist/core/llm/state.d.ts.map +1 -1
  44. package/dist/core/llm/state.js +9 -0
  45. package/dist/core/llm/state.js.map +1 -1
  46. package/dist/core/plugins/plugin-builder.d.ts +2 -0
  47. package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
  48. package/dist/core/plugins/plugin-builder.js.map +1 -1
  49. package/dist/core/sessions/session.d.ts.map +1 -1
  50. package/dist/core/sessions/session.js +6 -0
  51. package/dist/core/sessions/session.js.map +1 -1
  52. package/dist/core/sessions/state.d.ts.map +1 -1
  53. package/dist/core/sessions/state.js +43 -5
  54. package/dist/core/sessions/state.js.map +1 -1
  55. package/dist/plugins/agents/agents.integration.test.js +147 -0
  56. package/dist/plugins/agents/agents.integration.test.js.map +1 -1
  57. package/dist/plugins/agents/plugin.d.ts.map +1 -1
  58. package/dist/plugins/agents/plugin.js +35 -2
  59. package/dist/plugins/agents/plugin.js.map +1 -1
  60. package/dist/plugins/context-compact/context-compact.integration.test.js +36 -20
  61. package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
  62. package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
  63. package/dist/plugins/context-compact/context-compactor.js +54 -9
  64. package/dist/plugins/context-compact/context-compactor.js.map +1 -1
  65. package/dist/plugins/context-compact/context-compactor.test.js +127 -4
  66. package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
  67. package/dist/plugins/limits-guard/config.d.ts +2 -0
  68. package/dist/plugins/limits-guard/config.d.ts.map +1 -1
  69. package/dist/plugins/limits-guard/limit-guard.d.ts +1 -0
  70. package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
  71. package/dist/plugins/limits-guard/limit-guard.js +11 -1
  72. package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
  73. package/dist/plugins/limits-guard/limit-guard.test.js +9 -0
  74. package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
  75. package/dist/plugins/limits-guard/limits-guard.integration.test.js +216 -1
  76. package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
  77. package/dist/plugins/limits-guard/plugin.d.ts +3 -1
  78. package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
  79. package/dist/plugins/limits-guard/plugin.js +48 -2
  80. package/dist/plugins/limits-guard/plugin.js.map +1 -1
  81. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  82. package/dist/plugins/uploads/plugin.js +10 -1
  83. package/dist/plugins/uploads/plugin.js.map +1 -1
  84. package/dist/plugins/uploads/uploads.integration.test.js +74 -28
  85. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  86. package/dist/testing/test-harness.d.ts +8 -0
  87. package/dist/testing/test-harness.d.ts.map +1 -1
  88. package/dist/testing/test-harness.js +18 -0
  89. package/dist/testing/test-harness.js.map +1 -1
  90. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  91. package/package.json +2 -2
  92. package/src/core/agents/agent.ts +124 -36
  93. package/src/core/agents/config.ts +6 -0
  94. package/src/core/agents/state.ts +9 -0
  95. package/src/core/context/state.ts +86 -27
  96. package/src/core/error-recovery.integration.test.ts +343 -0
  97. package/src/core/llm/anthropic.test.ts +7 -4
  98. package/src/core/llm/anthropic.ts +6 -1
  99. package/src/core/llm/message-sanitization.test.ts +217 -0
  100. package/src/core/llm/message-sanitization.ts +70 -0
  101. package/src/core/llm/middleware.ts +3 -3
  102. package/src/core/llm/openrouter-mapping.test.ts +24 -3
  103. package/src/core/llm/openrouter.ts +7 -8
  104. package/src/core/llm/provider.ts +14 -10
  105. package/src/core/llm/state.ts +10 -0
  106. package/src/core/plugins/plugin-builder.ts +6 -2
  107. package/src/core/sessions/session.ts +6 -0
  108. package/src/core/sessions/state.ts +48 -9
  109. package/src/plugins/agents/agents.integration.test.ts +168 -0
  110. package/src/plugins/agents/plugin.ts +39 -2
  111. package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
  112. package/src/plugins/context-compact/context-compactor.test.ts +150 -4
  113. package/src/plugins/context-compact/context-compactor.ts +64 -10
  114. package/src/plugins/limits-guard/config.ts +2 -0
  115. package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
  116. package/src/plugins/limits-guard/limit-guard.ts +13 -1
  117. package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
  118. package/src/plugins/limits-guard/plugin.ts +53 -3
  119. package/src/plugins/uploads/plugin.ts +11 -1
  120. package/src/plugins/uploads/uploads.integration.test.ts +82 -28
  121. package/src/testing/test-harness.ts +20 -0
@@ -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
  }
@@ -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({ limits: { maxTurns: 5 } })],
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 type { AgentId } from '~/core/agents/schema.js'
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 as AgentId)
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 as AgentId, {
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,