@roj-ai/sdk 0.1.20 → 0.1.22

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 (98) hide show
  1. package/dist/core/agents/agent.d.ts.map +1 -1
  2. package/dist/core/agents/agent.js +13 -3
  3. package/dist/core/agents/agent.js.map +1 -1
  4. package/dist/core/context/state.d.ts +8 -0
  5. package/dist/core/context/state.d.ts.map +1 -1
  6. package/dist/core/context/state.js +10 -0
  7. package/dist/core/context/state.js.map +1 -1
  8. package/dist/core/events/base-event-store.d.ts.map +1 -1
  9. package/dist/core/events/base-event-store.js +2 -0
  10. package/dist/core/events/base-event-store.js.map +1 -1
  11. package/dist/core/events/metadata-utils.d.ts.map +1 -1
  12. package/dist/core/events/metadata-utils.js +2 -0
  13. package/dist/core/events/metadata-utils.js.map +1 -1
  14. package/dist/core/llm/anthropic.test.js +27 -0
  15. package/dist/core/llm/anthropic.test.js.map +1 -1
  16. package/dist/core/llm/cache-breakpoints.d.ts +19 -5
  17. package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
  18. package/dist/core/llm/cache-breakpoints.js +40 -23
  19. package/dist/core/llm/cache-breakpoints.js.map +1 -1
  20. package/dist/core/llm/cache-breakpoints.test.d.ts +2 -0
  21. package/dist/core/llm/cache-breakpoints.test.d.ts.map +1 -0
  22. package/dist/core/llm/cache-breakpoints.test.js +45 -0
  23. package/dist/core/llm/cache-breakpoints.test.js.map +1 -0
  24. package/dist/core/llm/state.d.ts +22 -0
  25. package/dist/core/llm/state.d.ts.map +1 -1
  26. package/dist/core/llm/state.js +23 -11
  27. package/dist/core/llm/state.js.map +1 -1
  28. package/dist/index.d.ts +5 -4
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/plugins/agent-status/agent-status.test.d.ts +2 -0
  33. package/dist/plugins/agent-status/agent-status.test.d.ts.map +1 -0
  34. package/dist/plugins/agent-status/agent-status.test.js +136 -0
  35. package/dist/plugins/agent-status/agent-status.test.js.map +1 -0
  36. package/dist/plugins/agent-status/plugin.d.ts +27 -0
  37. package/dist/plugins/agent-status/plugin.d.ts.map +1 -1
  38. package/dist/plugins/agent-status/plugin.js +46 -0
  39. package/dist/plugins/agent-status/plugin.js.map +1 -1
  40. package/dist/plugins/agents/plugin.d.ts.map +1 -1
  41. package/dist/plugins/agents/plugin.js +7 -1
  42. package/dist/plugins/agents/plugin.js.map +1 -1
  43. package/dist/plugins/context-compact/context-compact.integration.test.js +54 -0
  44. package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
  45. package/dist/plugins/context-compact/context-compactor.d.ts +2 -0
  46. package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
  47. package/dist/plugins/context-compact/context-compactor.js +29 -0
  48. package/dist/plugins/context-compact/context-compactor.js.map +1 -1
  49. package/dist/plugins/context-compact/context-compactor.test.js +6 -0
  50. package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
  51. package/dist/plugins/limits-guard/config.d.ts +30 -0
  52. package/dist/plugins/limits-guard/config.d.ts.map +1 -1
  53. package/dist/plugins/limits-guard/index.d.ts +3 -3
  54. package/dist/plugins/limits-guard/index.d.ts.map +1 -1
  55. package/dist/plugins/limits-guard/index.js +1 -1
  56. package/dist/plugins/limits-guard/index.js.map +1 -1
  57. package/dist/plugins/limits-guard/limit-guard.d.ts +27 -1
  58. package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
  59. package/dist/plugins/limits-guard/limit-guard.js +67 -0
  60. package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
  61. package/dist/plugins/limits-guard/limit-guard.test.js +65 -1
  62. package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
  63. package/dist/plugins/limits-guard/limits-guard.integration.test.js +295 -1
  64. package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
  65. package/dist/plugins/limits-guard/plugin.d.ts +23 -2
  66. package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
  67. package/dist/plugins/limits-guard/plugin.js +107 -2
  68. package/dist/plugins/limits-guard/plugin.js.map +1 -1
  69. package/dist/plugins/mailbox/plugin.d.ts.map +1 -1
  70. package/dist/plugins/mailbox/plugin.js +18 -0
  71. package/dist/plugins/mailbox/plugin.js.map +1 -1
  72. package/dist/plugins/session-stats/plugin.d.ts.map +1 -1
  73. package/dist/plugins/session-stats/plugin.js +5 -1
  74. package/dist/plugins/session-stats/plugin.js.map +1 -1
  75. package/package.json +2 -2
  76. package/src/core/agents/agent.ts +18 -2
  77. package/src/core/context/state.ts +10 -0
  78. package/src/core/events/base-event-store.ts +2 -0
  79. package/src/core/events/metadata-utils.ts +2 -0
  80. package/src/core/llm/anthropic.test.ts +34 -0
  81. package/src/core/llm/cache-breakpoints.test.ts +55 -0
  82. package/src/core/llm/cache-breakpoints.ts +39 -21
  83. package/src/core/llm/state.ts +25 -11
  84. package/src/index.ts +5 -4
  85. package/src/plugins/agent-status/agent-status.test.ts +164 -0
  86. package/src/plugins/agent-status/plugin.ts +49 -0
  87. package/src/plugins/agents/plugin.ts +7 -1
  88. package/src/plugins/context-compact/context-compact.integration.test.ts +62 -0
  89. package/src/plugins/context-compact/context-compactor.test.ts +6 -0
  90. package/src/plugins/context-compact/context-compactor.ts +31 -0
  91. package/src/plugins/limits-guard/config.ts +35 -0
  92. package/src/plugins/limits-guard/index.ts +3 -3
  93. package/src/plugins/limits-guard/limit-guard.test.ts +80 -1
  94. package/src/plugins/limits-guard/limit-guard.ts +98 -1
  95. package/src/plugins/limits-guard/limits-guard.integration.test.ts +331 -1
  96. package/src/plugins/limits-guard/plugin.ts +153 -3
  97. package/src/plugins/mailbox/plugin.ts +18 -0
  98. package/src/plugins/session-stats/plugin.ts +5 -1
@@ -131,6 +131,8 @@ export const DEFAULT_SUMMARY_INSTRUCTION =
131
131
  export interface CompactionResult {
132
132
  /** New messages to use (summary + kept messages) */
133
133
  compactedMessages: LLMMessage[]
134
+ /** The older messages that were summarized away (the compaction input) */
135
+ originalMessages: LLMMessage[]
134
136
  /** Generated summary text */
135
137
  summary: string
136
138
  /** Token count before compaction */
@@ -274,6 +276,7 @@ export class ContextCompactor {
274
276
  this.logger.warn('No messages to compact', { sessionId, agentId })
275
277
  return Ok({
276
278
  compactedMessages: messages,
279
+ originalMessages: [],
277
280
  summary: '',
278
281
  originalTokens,
279
282
  compactedTokens: originalTokens,
@@ -349,6 +352,7 @@ export class ContextCompactor {
349
352
 
350
353
  return Ok({
351
354
  compactedMessages,
355
+ originalMessages: toCompact,
352
356
  summary,
353
357
  originalTokens,
354
358
  compactedTokens,
@@ -378,6 +382,7 @@ export function createContextCompactedEvent(
378
382
  contextEvents.create('context_compacted', {
379
383
  agentId,
380
384
  compactedContent: result.summary,
385
+ originalMessages: result.originalMessages.map(toDisplayMessage),
381
386
  newConversationHistory,
382
387
  originalTokens: result.originalTokens,
383
388
  compactedTokens: result.compactedTokens,
@@ -386,3 +391,29 @@ export function createContextCompactedEvent(
386
391
  }),
387
392
  )
388
393
  }
394
+
395
+ /**
396
+ * Convert an LLM message into a display-only conversation message, preserving
397
+ * tool-call and tool-result detail in the rendered content. Used for the
398
+ * compaction "input" snapshot shown in the debug UI — not for reconstruction.
399
+ */
400
+ function toDisplayMessage(msg: LLMMessage): CompactedConversationMessage {
401
+ if (msg.role === 'assistant') {
402
+ const parts: string[] = []
403
+ if (msg.content) parts.push(msg.content)
404
+ if (msg.toolCalls?.length) {
405
+ for (const tc of msg.toolCalls) {
406
+ parts.push(`[tool call: ${tc.name}(${JSON.stringify(tc.input)})]`)
407
+ }
408
+ }
409
+ return { role: 'assistant', content: parts.join('\n') }
410
+ }
411
+ if (msg.role === 'tool') {
412
+ const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
413
+ return { role: 'system', content: `[tool result${msg.toolName ? `: ${msg.toolName}` : ''}]\n${content}` }
414
+ }
415
+ return {
416
+ role: msg.role,
417
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
418
+ }
419
+ }
@@ -23,4 +23,39 @@ export interface AgentLimits {
23
23
  maxRepeatedToolCalls?: number
24
24
  /** Maximum consecutive identical text-only responses. Default: 3 */
25
25
  maxRepeatedResponses?: number
26
+ /**
27
+ * Maximum cumulative LLM cost (USD) this agent may spend before it is paused.
28
+ * Spend is summed from `inference_completed` metrics and, unlike the counter
29
+ * limits, is NOT reset on resume. Default: unlimited.
30
+ */
31
+ maxCost?: number
32
+ /**
33
+ * Maximum cumulative total tokens (prompt + completion) this agent may consume
34
+ * before it is paused. Useful as a fallback when providers don't report cost.
35
+ * Not reset on resume. Default: unlimited.
36
+ */
37
+ maxTokens?: number
38
+ /**
39
+ * Maximum number of context compaction events for this agent before it is paused.
40
+ * Guards against pathological compaction loops. Reset on resume. Default: unlimited.
41
+ */
42
+ maxCompactions?: number
43
+ }
44
+
45
+ // ============================================================================
46
+ // Session Limits (budget across all agents)
47
+ // ============================================================================
48
+
49
+ /**
50
+ * Session-wide budget, summed across every agent in the session. Configured via
51
+ * the plugin's session-level config (`pluginConfig`), independent of per-agent
52
+ * limits. All fields optional - defaults applied via resolveSessionLimits().
53
+ */
54
+ export interface LimitsSessionConfig {
55
+ /** Maximum cumulative LLM cost (USD) across all agents. Default: unlimited */
56
+ maxSessionCost?: number
57
+ /** Maximum cumulative total tokens across all agents. Default: unlimited */
58
+ maxSessionTokens?: number
59
+ /** Ratio of the session budget at which a soft warning is emitted. Default: 0.8 */
60
+ softLimitRatio?: number
26
61
  }
@@ -1,4 +1,4 @@
1
- export type { AgentLimits } from './config.js'
1
+ export type { AgentLimits, LimitsSessionConfig } from './config.js'
2
2
  export { limitsGuardPlugin } from './plugin.js'
3
- export type { AgentCounters, LimitsAgentConfig, LimitWarningEvent } from './plugin.js'
4
- export { createAgentCounters, limitsEvents } from './plugin.js'
3
+ export type { AgentCounters, BudgetExceededEvent, LimitsAgentConfig, LimitWarningEvent } from './plugin.js'
4
+ export { createAgentCounters, limitsEvents, sumSessionSpend } from './plugin.js'
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { checkLimits, resolveAgentLimits } from './limit-guard.js'
2
+ import { checkBudget, checkLimits, resolveAgentLimits, resolveSessionLimits } from './limit-guard.js'
3
3
  import { createAgentCounters } from './plugin.js'
4
4
  import type { AgentCounters } from './plugin.js'
5
5
 
@@ -14,6 +14,10 @@ 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
+ // Budgets and compaction cap are opt-in (unlimited by default)
18
+ expect(limits.maxCost).toBe(Number.POSITIVE_INFINITY)
19
+ expect(limits.maxTokens).toBe(Number.POSITIVE_INFINITY)
20
+ expect(limits.maxCompactions).toBe(Number.POSITIVE_INFINITY)
17
21
  })
18
22
 
19
23
  it('returns defaults when empty config', () => {
@@ -158,4 +162,79 @@ describe('checkLimits', () => {
158
162
  )
159
163
  expect(result.status).toBe('hard_limit')
160
164
  })
165
+
166
+ // --- Compaction limit ---
167
+
168
+ it('detects maxCompactions hard limit', () => {
169
+ const limits = resolveAgentLimits({ maxCompactions: 5 })
170
+ const result = checkLimits(makeCounters({ compactionCount: 5 }), limits)
171
+ expect(result.status).toBe('hard_limit')
172
+ if (result.status === 'hard_limit') {
173
+ expect(result.limitName).toBe('maxCompactions')
174
+ }
175
+ })
176
+
177
+ it('does not cap compactions by default (unlimited)', () => {
178
+ const result = checkLimits(makeCounters({ compactionCount: 9999 }), defaultLimits)
179
+ expect(result.status).toBe('ok')
180
+ })
181
+ })
182
+
183
+ describe('checkBudget', () => {
184
+ const names = { cost: 'maxCost', tokens: 'maxTokens' }
185
+
186
+ it('returns ok when under budget', () => {
187
+ const result = checkBudget({ costSpent: 1, tokensUsed: 100 }, 5, 1000, 0.8, names)
188
+ expect(result.status).toBe('ok')
189
+ })
190
+
191
+ it('returns ok when unlimited (Infinity)', () => {
192
+ const result = checkBudget(
193
+ { costSpent: 1_000_000, tokensUsed: 1_000_000 },
194
+ Number.POSITIVE_INFINITY,
195
+ Number.POSITIVE_INFINITY,
196
+ 0.8,
197
+ names,
198
+ )
199
+ expect(result.status).toBe('ok')
200
+ })
201
+
202
+ it('detects cost hard limit', () => {
203
+ const result = checkBudget({ costSpent: 5.01, tokensUsed: 0 }, 5, Number.POSITIVE_INFINITY, 0.8, names)
204
+ expect(result.status).toBe('hard_limit')
205
+ if (result.status === 'hard_limit') expect(result.limitName).toBe('maxCost')
206
+ })
207
+
208
+ it('detects token hard limit', () => {
209
+ const result = checkBudget({ costSpent: 0, tokensUsed: 1000 }, Number.POSITIVE_INFINITY, 1000, 0.8, names)
210
+ expect(result.status).toBe('hard_limit')
211
+ if (result.status === 'hard_limit') expect(result.limitName).toBe('maxTokens')
212
+ })
213
+
214
+ it('emits soft warning approaching cost budget', () => {
215
+ const result = checkBudget({ costSpent: 4.2, tokensUsed: 0 }, 5, Number.POSITIVE_INFINITY, 0.8, names)
216
+ expect(result.status).toBe('soft_warning')
217
+ if (result.status === 'soft_warning') expect(result.limitName).toBe('maxCost')
218
+ })
219
+
220
+ it('handles sub-dollar budgets without spurious warnings', () => {
221
+ // floor-based logic would warn at $0 for a $0.50 budget — float-aware must not.
222
+ const result = checkBudget({ costSpent: 0.1, tokensUsed: 0 }, 0.5, Number.POSITIVE_INFINITY, 0.8, names)
223
+ expect(result.status).toBe('ok')
224
+ })
225
+ })
226
+
227
+ describe('resolveSessionLimits', () => {
228
+ it('defaults to unlimited', () => {
229
+ const limits = resolveSessionLimits()
230
+ expect(limits.maxSessionCost).toBe(Number.POSITIVE_INFINITY)
231
+ expect(limits.maxSessionTokens).toBe(Number.POSITIVE_INFINITY)
232
+ expect(limits.softLimitRatio).toBe(0.8)
233
+ })
234
+
235
+ it('overrides specific values', () => {
236
+ const limits = resolveSessionLimits({ maxSessionCost: 10 })
237
+ expect(limits.maxSessionCost).toBe(10)
238
+ expect(limits.maxSessionTokens).toBe(Number.POSITIVE_INFINITY)
239
+ })
161
240
  })
@@ -4,7 +4,7 @@
4
4
  * Returns the worst result: hard_limit > soft_warning > ok.
5
5
  */
6
6
 
7
- import type { AgentLimits } from '~/plugins/limits-guard/config.js'
7
+ import type { AgentLimits, LimitsSessionConfig } from '~/plugins/limits-guard/config.js'
8
8
  import type { AgentCounters } from './plugin.js'
9
9
 
10
10
  // ============================================================================
@@ -20,6 +20,9 @@ export interface ResolvedAgentLimits {
20
20
  softLimitRatio: number
21
21
  maxRepeatedToolCalls: number
22
22
  maxRepeatedResponses: number
23
+ maxCost: number
24
+ maxTokens: number
25
+ maxCompactions: number
23
26
  }
24
27
 
25
28
  const DEFAULTS: ResolvedAgentLimits = {
@@ -31,6 +34,10 @@ const DEFAULTS: ResolvedAgentLimits = {
31
34
  softLimitRatio: 0.8,
32
35
  maxRepeatedToolCalls: 3,
33
36
  maxRepeatedResponses: 3,
37
+ // Budgets and the compaction cap are opt-in: unset means unlimited.
38
+ maxCost: Number.POSITIVE_INFINITY,
39
+ maxTokens: Number.POSITIVE_INFINITY,
40
+ maxCompactions: Number.POSITIVE_INFINITY,
34
41
  }
35
42
 
36
43
  export function resolveAgentLimits(config?: AgentLimits): ResolvedAgentLimits {
@@ -44,9 +51,98 @@ export function resolveAgentLimits(config?: AgentLimits): ResolvedAgentLimits {
44
51
  softLimitRatio: config.softLimitRatio ?? DEFAULTS.softLimitRatio,
45
52
  maxRepeatedToolCalls: config.maxRepeatedToolCalls ?? DEFAULTS.maxRepeatedToolCalls,
46
53
  maxRepeatedResponses: config.maxRepeatedResponses ?? DEFAULTS.maxRepeatedResponses,
54
+ maxCost: config.maxCost ?? DEFAULTS.maxCost,
55
+ maxTokens: config.maxTokens ?? DEFAULTS.maxTokens,
56
+ maxCompactions: config.maxCompactions ?? DEFAULTS.maxCompactions,
47
57
  }
48
58
  }
49
59
 
60
+ // ============================================================================
61
+ // Session budget
62
+ // ============================================================================
63
+
64
+ export interface ResolvedSessionLimits {
65
+ maxSessionCost: number
66
+ maxSessionTokens: number
67
+ softLimitRatio: number
68
+ }
69
+
70
+ const SESSION_DEFAULTS: ResolvedSessionLimits = {
71
+ maxSessionCost: Number.POSITIVE_INFINITY,
72
+ maxSessionTokens: Number.POSITIVE_INFINITY,
73
+ softLimitRatio: 0.8,
74
+ }
75
+
76
+ export function resolveSessionLimits(config?: LimitsSessionConfig): ResolvedSessionLimits {
77
+ if (!config) return SESSION_DEFAULTS
78
+ return {
79
+ maxSessionCost: config.maxSessionCost ?? SESSION_DEFAULTS.maxSessionCost,
80
+ maxSessionTokens: config.maxSessionTokens ?? SESSION_DEFAULTS.maxSessionTokens,
81
+ softLimitRatio: config.softLimitRatio ?? SESSION_DEFAULTS.softLimitRatio,
82
+ }
83
+ }
84
+
85
+ /** Cumulative spend, either for a single agent or summed across the session. */
86
+ export interface BudgetSpend {
87
+ costSpent: number
88
+ tokensUsed: number
89
+ }
90
+
91
+ /**
92
+ * Budget check (cost + tokens) shared by per-agent and session-wide budgets.
93
+ *
94
+ * Kept separate from {@link checkLimits} so it can run in `beforeInference` —
95
+ * blocking the *next* call once the budget is exhausted — without also tripping
96
+ * the counter/pattern limits (those are enforced in `afterInference`). Uses
97
+ * float-aware comparisons (no flooring) so sub-dollar budgets behave correctly.
98
+ */
99
+ export function checkBudget(
100
+ spend: BudgetSpend,
101
+ costLimit: number,
102
+ tokenLimit: number,
103
+ softLimitRatio: number,
104
+ names: { cost: string; tokens: string },
105
+ ): LimitCheckResult {
106
+ const checks: Array<{ name: string; current: number; max: number }> = [
107
+ { name: names.cost, current: spend.costSpent, max: costLimit },
108
+ { name: names.tokens, current: spend.tokensUsed, max: tokenLimit },
109
+ ]
110
+
111
+ // Hard limits
112
+ for (const check of checks) {
113
+ if (check.current >= check.max) {
114
+ return {
115
+ status: 'hard_limit',
116
+ limitName: check.name,
117
+ currentValue: check.current,
118
+ hardLimit: check.max,
119
+ reason: `${check.name} reached: ${formatBudget(check.current)}/${formatBudget(check.max)}`,
120
+ }
121
+ }
122
+ }
123
+
124
+ // Soft warnings
125
+ for (const check of checks) {
126
+ if (check.max !== Number.POSITIVE_INFINITY && check.current >= check.max * softLimitRatio) {
127
+ return {
128
+ status: 'soft_warning',
129
+ limitName: check.name,
130
+ currentValue: check.current,
131
+ hardLimit: check.max,
132
+ message: `Approaching ${check.name} limit: ${formatBudget(check.current)}/${formatBudget(check.max)}`,
133
+ }
134
+ }
135
+ }
136
+
137
+ return { status: 'ok' }
138
+ }
139
+
140
+ /** Format a budget value compactly — 4 decimals for fractional (cost), integer otherwise. */
141
+ function formatBudget(value: number): string {
142
+ if (value === Number.POSITIVE_INFINITY) return '∞'
143
+ return Number.isInteger(value) ? String(value) : value.toFixed(4)
144
+ }
145
+
50
146
  // ============================================================================
51
147
  // Check result
52
148
  // ============================================================================
@@ -68,6 +164,7 @@ export function checkLimits(counters: AgentCounters, limits: ResolvedAgentLimits
68
164
  { name: 'maxToolCalls', current: counters.toolCallCount, max: limits.maxToolCalls },
69
165
  { name: 'maxSpawnedAgents', current: counters.spawnedAgentCount, max: limits.maxSpawnedAgents },
70
166
  { name: 'maxMessagesSent', current: counters.messagesSentCount, max: limits.maxMessagesSent },
167
+ { name: 'maxCompactions', current: counters.compactionCount, max: limits.maxCompactions },
71
168
  ]
72
169
 
73
170
  for (const check of hardChecks) {
@@ -2,11 +2,17 @@ import { describe, expect, it } from 'bun:test'
2
2
  import { AgentId } from '~/core/agents/schema.js'
3
3
  import { agentEvents } from '~/core/agents/state.js'
4
4
  import { MockLLMProvider } from '~/core/llm/mock.js'
5
+ import type { InferenceRequest } from '~/core/llm/provider.js'
6
+ import { ModelId } from '~/core/llm/schema.js'
7
+ import { llmEvents } from '~/core/llm/state.js'
5
8
  import { selectPluginState } from '~/core/sessions/reducer.js'
6
9
  import { ToolCallId } from '~/core/tools/schema.js'
10
+ import { contextCompactPlugin } from '~/plugins/context-compact/index.js'
11
+ import { getAgentMailbox, selectMailboxState } from '~/plugins/mailbox/query.js'
12
+ import { mailboxEvents } from '~/plugins/mailbox/state.js'
7
13
  import { createMultiAgentPreset, createTestPreset, TestHarness } from '~/testing/index.js'
8
14
  import type { AgentCounters } from './plugin.js'
9
- import { limitsGuardPlugin } from './plugin.js'
15
+ import { limitsEvents, limitsGuardPlugin } from './plugin.js'
10
16
 
11
17
  function createLimitsHarness(options: Omit<ConstructorParameters<typeof TestHarness>[0], 'systemPlugins'>) {
12
18
  return new TestHarness({ ...options, systemPlugins: [limitsGuardPlugin] })
@@ -434,4 +440,328 @@ describe('limits-guard plugin', () => {
434
440
  await harness.shutdown()
435
441
  })
436
442
  })
443
+
444
+ // =========================================================================
445
+ // budgets (cost / tokens)
446
+ // =========================================================================
447
+
448
+ describe('budgets', () => {
449
+ it('agent exceeding cost budget → paused with budget_exceeded event', async () => {
450
+ let n = 0
451
+ const harness = createLimitsHarness({
452
+ presets: [createTestPreset({
453
+ orchestratorSystem: 'Test agent.',
454
+ // $0.50 per call, $1.00 budget → pauses before the 3rd call.
455
+ orchestratorPlugins: [limitsGuardPlugin.configureAgent({ limits: { maxCost: 1.0, maxTurns: 100 } })],
456
+ })],
457
+ mockHandler: () => {
458
+ n++
459
+ return {
460
+ content: null,
461
+ toolCalls: [{ id: ToolCallId(`tc${n}`), name: 'tell_user', input: { message: `Turn ${n}` } }],
462
+ finishReason: 'stop',
463
+ metrics: MockLLMProvider.defaultMetricsWithCost(0.5),
464
+ }
465
+ },
466
+ })
467
+
468
+ const session = await harness.createSession('test')
469
+ const entryAgentId = session.getEntryAgentId()!
470
+ await session.sendMessage('Start')
471
+ await waitForAgentPaused(session, entryAgentId)
472
+
473
+ expect(session.state.agents.get(entryAgentId)!.status).toBe('paused')
474
+
475
+ const counters = selectPluginState<Map<AgentId, AgentCounters>>(session.state, 'agentLimits')?.get(entryAgentId)
476
+ expect(counters!.costSpent).toBeGreaterThanOrEqual(1.0)
477
+
478
+ const budgetEvents = await session.getEventsByType(limitsEvents, 'budget_exceeded')
479
+ const evt = budgetEvents.find(e => e.agentId === entryAgentId)
480
+ expect(evt).toBeDefined()
481
+ expect(evt!.scope).toBe('agent')
482
+ expect(evt!.limitName).toBe('maxCost')
483
+
484
+ await harness.shutdown()
485
+ })
486
+
487
+ it('costSpent is preserved across resume — budget cannot be bypassed by pausing', async () => {
488
+ let n = 0
489
+ const harness = createLimitsHarness({
490
+ presets: [createTestPreset({
491
+ orchestratorSystem: 'Test agent.',
492
+ orchestratorPlugins: [limitsGuardPlugin.configureAgent({ limits: { maxCost: 1.0, maxTurns: 100 } })],
493
+ })],
494
+ mockHandler: () => {
495
+ n++
496
+ return {
497
+ content: null,
498
+ toolCalls: [{ id: ToolCallId(`tc${n}`), name: 'tell_user', input: { message: `Turn ${n}` } }],
499
+ finishReason: 'stop',
500
+ metrics: MockLLMProvider.defaultMetricsWithCost(0.5),
501
+ }
502
+ },
503
+ })
504
+
505
+ const session = await harness.createSession('test')
506
+ const entryAgentId = session.getEntryAgentId()!
507
+ await session.sendMessage('Start')
508
+ await waitForAgentPaused(session, entryAgentId)
509
+
510
+ const before = selectPluginState<Map<AgentId, AgentCounters>>(session.state, 'agentLimits')?.get(entryAgentId)
511
+ expect(before).toBeDefined()
512
+ expect(before!.costSpent).toBeGreaterThanOrEqual(1.0)
513
+
514
+ await session.callPluginMethod('agents.resume', { agentId: String(entryAgentId) })
515
+ // Budget is still exhausted → agent pauses again immediately without inferring.
516
+ await waitForAgentPaused(session, entryAgentId)
517
+
518
+ const after = selectPluginState<Map<AgentId, AgentCounters>>(session.state, 'agentLimits')?.get(entryAgentId)
519
+ expect(after).toBeDefined()
520
+ // Anti-looping counter reset…
521
+ expect(after!.inferenceCount).toBe(0)
522
+ // …but spend preserved, so the cap is not bypassable.
523
+ expect(after!.costSpent).toBeGreaterThanOrEqual(before!.costSpent)
524
+
525
+ await harness.shutdown()
526
+ })
527
+
528
+ it('child pausing on budget → parent is notified via a child-paused message', async () => {
529
+ let orchestratorCalls = 0
530
+ let workerCalls = 0
531
+ const harness = createLimitsHarness({
532
+ presets: [createTestPreset({
533
+ orchestratorSystem: 'Orchestrator agent.',
534
+ agents: [{
535
+ name: 'worker',
536
+ system: 'Worker agent.',
537
+ tools: [],
538
+ agents: [],
539
+ // $0.50 per call, $0.50 budget → pauses at the 2nd inference's
540
+ // beforeInference (after one completed call spent the budget).
541
+ plugins: [limitsGuardPlugin.configureAgent({ limits: { maxCost: 0.5, maxTurns: 100 } })],
542
+ }],
543
+ })],
544
+ mockHandler: (request) => {
545
+ // Worker: keep spending until the budget pauses it.
546
+ if (request.systemPrompt.includes('Worker agent.')) {
547
+ workerCalls++
548
+ return {
549
+ content: null,
550
+ toolCalls: [{ id: ToolCallId(`w${workerCalls}`), name: 'tell_user', input: { message: `Work ${workerCalls}` } }],
551
+ finishReason: 'stop',
552
+ metrics: MockLLMProvider.defaultMetricsWithCost(0.5),
553
+ }
554
+ }
555
+ // Orchestrator: spawn the worker exactly once, then idle.
556
+ orchestratorCalls++
557
+ if (orchestratorCalls === 1) {
558
+ return {
559
+ content: null,
560
+ toolCalls: [{ id: ToolCallId('spawn'), name: 'start_worker', input: { message: 'Do work' } }],
561
+ finishReason: 'stop',
562
+ metrics: MockLLMProvider.defaultMetrics(),
563
+ }
564
+ }
565
+ return { content: 'Waiting', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
566
+ },
567
+ })
568
+
569
+ const session = await harness.createSession('test')
570
+ await session.sendMessage('Start')
571
+ await waitForAgentPaused(session, AgentId('worker_1'))
572
+
573
+ const orchestratorId = session.getEntryAgentId()!
574
+ // The mailbox plugin's onPause hook reports the pause to the parent.
575
+ // onPause runs *after* the agent_paused event (which flips status to
576
+ // 'paused'), so poll for the notification.
577
+ const findNotice = async () =>
578
+ (await session.getEventsByType(mailboxEvents, 'mailbox_message')).find(m =>
579
+ m.toAgentId === orchestratorId
580
+ && m.message.from === AgentId('worker_1')
581
+ && m.message.content.includes('<child-paused')
582
+ && m.message.content.includes('worker_1'),
583
+ )
584
+ let notice = await findNotice()
585
+ const deadline = Date.now() + 5000
586
+ while (!notice && Date.now() < deadline) {
587
+ await new Promise(r => setTimeout(r, 20))
588
+ notice = await findNotice()
589
+ }
590
+ expect(notice).toBeDefined()
591
+
592
+ await harness.shutdown()
593
+ })
594
+
595
+ it('child-paused notice is actually consumed by a parent that already went idle', async () => {
596
+ // Regression guard for the lifecycle: a parent that finished its work is
597
+ // NOT in a terminal "complete" state — it's persisted as `pending` with an
598
+ // empty mailbox. When the child pauses and delivers <child-paused>, the
599
+ // dequeue check flips the parent's decide() from "complete" back to "infer",
600
+ // so the parent wakes and reads the message rather than leaving it unconsumed.
601
+ let workerCalls = 0
602
+ let orchestratorSawChildPaused = false
603
+
604
+ const requestHasChildPaused = (request: InferenceRequest): boolean =>
605
+ request.messages.some((m) => {
606
+ const c = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
607
+ return c.includes('<child-paused')
608
+ })
609
+
610
+ const harness = createLimitsHarness({
611
+ presets: [createTestPreset({
612
+ orchestratorSystem: 'Orchestrator agent.',
613
+ agents: [{
614
+ name: 'worker',
615
+ system: 'Worker agent.',
616
+ tools: [],
617
+ agents: [],
618
+ plugins: [limitsGuardPlugin.configureAgent({ limits: { maxCost: 0.5, maxTurns: 100 } })],
619
+ }],
620
+ })],
621
+ mockHandler: (request) => {
622
+ if (request.systemPrompt.includes('Worker agent.')) {
623
+ workerCalls++
624
+ return {
625
+ content: null,
626
+ toolCalls: [{ id: ToolCallId(`w${workerCalls}`), name: 'tell_user', input: { message: `Work ${workerCalls}` } }],
627
+ finishReason: 'stop',
628
+ metrics: MockLLMProvider.defaultMetricsWithCost(0.5),
629
+ }
630
+ }
631
+ // Orchestrator: spawn the worker once, then go idle. Any later wake-up
632
+ // is driven by an incoming message — record if it carried the notice.
633
+ if (requestHasChildPaused(request)) orchestratorSawChildPaused = true
634
+ if (workerCalls === 0) {
635
+ return {
636
+ content: null,
637
+ toolCalls: [{ id: ToolCallId('spawn'), name: 'start_worker', input: { message: 'Do work' } }],
638
+ finishReason: 'stop',
639
+ metrics: MockLLMProvider.defaultMetrics(),
640
+ }
641
+ }
642
+ return { content: 'Acknowledged', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
643
+ },
644
+ })
645
+
646
+ const session = await harness.createSession('test')
647
+ await session.sendMessage('Start')
648
+ await waitForAgentPaused(session, AgentId('worker_1'))
649
+
650
+ // The parent should wake from idle and run an inference that includes the
651
+ // <child-paused> message — proving the notice is consumed, not orphaned.
652
+ const deadline = Date.now() + 5000
653
+ while (!orchestratorSawChildPaused && Date.now() < deadline) {
654
+ await new Promise(r => setTimeout(r, 20))
655
+ }
656
+ expect(orchestratorSawChildPaused).toBe(true)
657
+
658
+ // And the message is marked consumed in the parent's mailbox.
659
+ const orchestratorId = session.getEntryAgentId()!
660
+ const mailbox = getAgentMailbox(selectMailboxState(session.state), orchestratorId)
661
+ const childPausedMsg = mailbox.find((m) => m.content.includes('<child-paused'))
662
+ expect(childPausedMsg).toBeDefined()
663
+ expect(childPausedMsg!.consumed).toBe(true)
664
+
665
+ await harness.shutdown()
666
+ })
667
+
668
+ it('compaction (auxiliary inference) cost counts toward the budget', async () => {
669
+ // The compaction summarization is a real, billed LLM call routed through
670
+ // runAuxiliaryInference → auxiliary_inference_completed. It must be charged
671
+ // against the cost budget, otherwise an agent could spend unboundedly on
672
+ // compaction without ever tripping its cap.
673
+ const REGULAR_COST = 0.1
674
+ const SUMMARY_COST = 5.0
675
+
676
+ // Compaction request detection: inline compaction appends a trailing user
677
+ // message containing the summarization marker.
678
+ const isSummarizationRequest = (request: InferenceRequest): boolean => {
679
+ const last = request.messages[request.messages.length - 1]
680
+ if (!last || last.role !== 'user') return false
681
+ const content = typeof last.content === 'string' ? last.content : JSON.stringify(last.content)
682
+ return content.includes('[CONTEXT COMPACTION REQUEST]')
683
+ }
684
+
685
+ const harness = new TestHarness({
686
+ systemPlugins: [contextCompactPlugin, limitsGuardPlugin],
687
+ presets: [createTestPreset({
688
+ orchestratorSystem: 'Test agent.',
689
+ plugins: [
690
+ contextCompactPlugin.configure({
691
+ compaction: { model: ModelId('mock'), maxTokens: 10, keepRecentMessages: 2 },
692
+ }),
693
+ ],
694
+ // Budget large enough to survive the cheap regular turns but small
695
+ // enough that one expensive summarization call blows past it.
696
+ orchestratorPlugins: [
697
+ limitsGuardPlugin.configureAgent({ limits: { maxCost: 2.0, maxTurns: 100 } }),
698
+ ],
699
+ })],
700
+ mockHandler: (request) => {
701
+ if (isSummarizationRequest(request)) {
702
+ return {
703
+ content: 'Summary of conversation so far.',
704
+ toolCalls: [],
705
+ finishReason: 'stop',
706
+ metrics: MockLLMProvider.defaultMetricsWithCost(SUMMARY_COST),
707
+ }
708
+ }
709
+ return {
710
+ content: 'Agent response with some content to increase token count.',
711
+ toolCalls: [],
712
+ finishReason: 'stop',
713
+ metrics: MockLLMProvider.defaultMetricsWithCost(REGULAR_COST),
714
+ }
715
+ },
716
+ })
717
+
718
+ const session = await harness.createSession('test')
719
+ const entryAgentId = session.getEntryAgentId()!
720
+
721
+ // Returns once the agent is either idle or paused — used because we don't
722
+ // know up front whether the compaction cost trips the budget on the same
723
+ // turn (depends on beforeInference hook ordering) or on the next one.
724
+ const waitForIdleOrPaused = async (timeoutMs = 10000): Promise<'idle' | 'paused'> => {
725
+ const deadline = Date.now() + timeoutMs
726
+ while (Date.now() < deadline) {
727
+ const st = session.state.agents.get(entryAgentId)
728
+ if (st?.status === 'paused') return 'paused'
729
+ if (st?.status === 'pending' && st.pendingToolCalls.length === 0 && st.pendingToolResults.length === 0) {
730
+ return 'idle'
731
+ }
732
+ await new Promise(r => setTimeout(r, 10))
733
+ }
734
+ throw new Error('waitForIdleOrPaused timed out')
735
+ }
736
+
737
+ await session.sendAndWaitForIdle('First message')
738
+ await session.sendAndWaitForIdle('Second message')
739
+ // Third message triggers compaction (the expensive summarization call).
740
+ // It may pause on this turn or settle idle and pause on the next one.
741
+ await session.sendMessage('Third message to trigger compaction')
742
+ if (await waitForIdleOrPaused() === 'idle') {
743
+ await session.sendMessage('Fourth message')
744
+ }
745
+ await waitForAgentPaused(session, entryAgentId)
746
+
747
+ // Compaction genuinely ran and was billed.
748
+ const auxEvents = await session.getEventsByType(llmEvents, 'auxiliary_inference_completed')
749
+ expect(auxEvents.some((e) => e.metrics.cost === SUMMARY_COST)).toBe(true)
750
+
751
+ // The summarization cost is reflected in the agent's tracked spend…
752
+ const counters = selectPluginState<Map<AgentId, AgentCounters>>(session.state, 'agentLimits')?.get(entryAgentId)
753
+ expect(counters).toBeDefined()
754
+ expect(counters!.costSpent).toBeGreaterThanOrEqual(SUMMARY_COST)
755
+
756
+ // …and it tripped the cost budget (the regular turns alone, at 0.1 each,
757
+ // could never reach the 2.0 cap on their own here).
758
+ const budgetEvents = await session.getEventsByType(limitsEvents, 'budget_exceeded')
759
+ const evt = budgetEvents.find((e) => e.agentId === entryAgentId)
760
+ expect(evt).toBeDefined()
761
+ expect(evt!.scope).toBe('agent')
762
+ expect(evt!.limitName).toBe('maxCost')
763
+
764
+ await harness.shutdown()
765
+ })
766
+ })
437
767
  })