@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.
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +13 -3
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/context/state.d.ts +8 -0
- package/dist/core/context/state.d.ts.map +1 -1
- package/dist/core/context/state.js +10 -0
- package/dist/core/context/state.js.map +1 -1
- package/dist/core/events/base-event-store.d.ts.map +1 -1
- package/dist/core/events/base-event-store.js +2 -0
- package/dist/core/events/base-event-store.js.map +1 -1
- package/dist/core/events/metadata-utils.d.ts.map +1 -1
- package/dist/core/events/metadata-utils.js +2 -0
- package/dist/core/events/metadata-utils.js.map +1 -1
- package/dist/core/llm/anthropic.test.js +27 -0
- package/dist/core/llm/anthropic.test.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.d.ts +19 -5
- package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
- package/dist/core/llm/cache-breakpoints.js +40 -23
- package/dist/core/llm/cache-breakpoints.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.test.d.ts +2 -0
- package/dist/core/llm/cache-breakpoints.test.d.ts.map +1 -0
- package/dist/core/llm/cache-breakpoints.test.js +45 -0
- package/dist/core/llm/cache-breakpoints.test.js.map +1 -0
- package/dist/core/llm/state.d.ts +22 -0
- package/dist/core/llm/state.d.ts.map +1 -1
- package/dist/core/llm/state.js +23 -11
- package/dist/core/llm/state.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/agent-status/agent-status.test.d.ts +2 -0
- package/dist/plugins/agent-status/agent-status.test.d.ts.map +1 -0
- package/dist/plugins/agent-status/agent-status.test.js +136 -0
- package/dist/plugins/agent-status/agent-status.test.js.map +1 -0
- package/dist/plugins/agent-status/plugin.d.ts +27 -0
- package/dist/plugins/agent-status/plugin.d.ts.map +1 -1
- package/dist/plugins/agent-status/plugin.js +46 -0
- package/dist/plugins/agent-status/plugin.js.map +1 -1
- package/dist/plugins/agents/plugin.d.ts.map +1 -1
- package/dist/plugins/agents/plugin.js +7 -1
- package/dist/plugins/agents/plugin.js.map +1 -1
- package/dist/plugins/context-compact/context-compact.integration.test.js +54 -0
- package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.d.ts +2 -0
- package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
- package/dist/plugins/context-compact/context-compactor.js +29 -0
- package/dist/plugins/context-compact/context-compactor.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.test.js +6 -0
- package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
- package/dist/plugins/limits-guard/config.d.ts +30 -0
- package/dist/plugins/limits-guard/config.d.ts.map +1 -1
- package/dist/plugins/limits-guard/index.d.ts +3 -3
- package/dist/plugins/limits-guard/index.d.ts.map +1 -1
- package/dist/plugins/limits-guard/index.js +1 -1
- package/dist/plugins/limits-guard/index.js.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.d.ts +27 -1
- package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.js +67 -0
- package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.test.js +65 -1
- package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
- package/dist/plugins/limits-guard/limits-guard.integration.test.js +295 -1
- package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
- package/dist/plugins/limits-guard/plugin.d.ts +23 -2
- package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
- package/dist/plugins/limits-guard/plugin.js +107 -2
- package/dist/plugins/limits-guard/plugin.js.map +1 -1
- package/dist/plugins/mailbox/plugin.d.ts.map +1 -1
- package/dist/plugins/mailbox/plugin.js +18 -0
- package/dist/plugins/mailbox/plugin.js.map +1 -1
- package/dist/plugins/session-stats/plugin.d.ts.map +1 -1
- package/dist/plugins/session-stats/plugin.js +5 -1
- package/dist/plugins/session-stats/plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +18 -2
- package/src/core/context/state.ts +10 -0
- package/src/core/events/base-event-store.ts +2 -0
- package/src/core/events/metadata-utils.ts +2 -0
- package/src/core/llm/anthropic.test.ts +34 -0
- package/src/core/llm/cache-breakpoints.test.ts +55 -0
- package/src/core/llm/cache-breakpoints.ts +39 -21
- package/src/core/llm/state.ts +25 -11
- package/src/index.ts +5 -4
- package/src/plugins/agent-status/agent-status.test.ts +164 -0
- package/src/plugins/agent-status/plugin.ts +49 -0
- package/src/plugins/agents/plugin.ts +7 -1
- package/src/plugins/context-compact/context-compact.integration.test.ts +62 -0
- package/src/plugins/context-compact/context-compactor.test.ts +6 -0
- package/src/plugins/context-compact/context-compactor.ts +31 -0
- package/src/plugins/limits-guard/config.ts +35 -0
- package/src/plugins/limits-guard/index.ts +3 -3
- package/src/plugins/limits-guard/limit-guard.test.ts +80 -1
- package/src/plugins/limits-guard/limit-guard.ts +98 -1
- package/src/plugins/limits-guard/limits-guard.integration.test.ts +331 -1
- package/src/plugins/limits-guard/plugin.ts +153 -3
- package/src/plugins/mailbox/plugin.ts +18 -0
- 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
|
})
|