@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
@@ -400,6 +400,40 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
400
400
  expect(last.content[1].cache_control).toBeUndefined()
401
401
  })
402
402
 
403
+ test('stable prefix + tail: TWO cache_control blocks survive into the HTTP body', async () => {
404
+ // Mirrors the agent flow: an immutable preamble followed by a churning
405
+ // conversation tail. applyCacheBreakpoint with cachedPrefixCount marks
406
+ // BOTH the last preamble message and the tail — both must reach the wire.
407
+ // Roles alternate so mergeConsecutiveMessages doesn't fold them together.
408
+ const messages = applyCacheBreakpoint(
409
+ [
410
+ { role: 'user', content: 'preamble part A' },
411
+ { role: 'assistant', content: 'preamble part B' }, // last preamble msg → prefix breakpoint
412
+ { role: 'user', content: 'conversation turn' },
413
+ { role: 'assistant', content: 'tail reply' }, // tail breakpoint
414
+ ],
415
+ 0, // no uncached suffix → tailIdx = 3
416
+ undefined,
417
+ 2, // cachedPrefixCount → prefixIdx = 1
418
+ )
419
+
420
+ const http = await createProvider().buildHttpRequest(buildRequest(messages))
421
+ const msgs = getBodyMessages(http.body)
422
+
423
+ expect(msgs.length).toBe(4)
424
+ // Prefix breakpoint on the last preamble message
425
+ const prefix = msgs[1]
426
+ expect(prefix.role).toBe('assistant')
427
+ expect(prefix.content[prefix.content.length - 1].cache_control).toEqual({ type: 'ephemeral' })
428
+ // Tail breakpoint on the final message
429
+ const tail = msgs[3]
430
+ expect(tail.role).toBe('assistant')
431
+ expect(tail.content[tail.content.length - 1].cache_control).toEqual({ type: 'ephemeral' })
432
+ // Messages between the two breakpoints stay uncached
433
+ expect(msgs[0].content[msgs[0].content.length - 1].cache_control).toBeUndefined()
434
+ expect(msgs[2].content[msgs[2].content.length - 1].cache_control).toBeUndefined()
435
+ })
436
+
403
437
  test('no flag on any message: only system prompt has cache_control', async () => {
404
438
  const http = await createProvider().buildHttpRequest(buildRequest([
405
439
  { role: 'user', content: 'What is 2+2?' },
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { LLMMessage } from '~/core/agents/state.js'
3
+ import { applyCacheBreakpoint } from './cache-breakpoints.js'
4
+
5
+ const userMessages = (count: number): LLMMessage[] =>
6
+ Array.from({ length: count }, (_, i) => ({ role: 'user', content: `m${i}` }))
7
+
8
+ const cachedIndices = (messages: LLMMessage[]): number[] =>
9
+ messages.flatMap((m, i) => ('cacheControl' in m && m.cacheControl ? [i] : []))
10
+
11
+ describe('applyCacheBreakpoint', () => {
12
+ test('cachedPrefixCount = 0 → single tail breakpoint only (unchanged behaviour)', () => {
13
+ const messages = userMessages(5)
14
+ const result = applyCacheBreakpoint(messages, 1)
15
+ // tail = length - 1 - suffix = 5 - 1 - 1 = 3
16
+ expect(cachedIndices(result)).toEqual([3])
17
+ })
18
+
19
+ test('cachedPrefixCount > 0 → two breakpoints: prefix end and tail', () => {
20
+ const messages = userMessages(10)
21
+ const result = applyCacheBreakpoint(messages, 1, undefined, 3)
22
+ // prefix = 3 - 1 = 2, tail = 10 - 1 - 1 = 8
23
+ expect(cachedIndices(result)).toEqual([2, 8])
24
+ })
25
+
26
+ test('prefixIdx === tailIdx → only one breakpoint, no duplicate', () => {
27
+ // preamble is the whole list, no suffix: tail = 3 - 1 - 0 = 2, prefix = 3 - 1 = 2
28
+ const messages = userMessages(3)
29
+ const result = applyCacheBreakpoint(messages, 0, undefined, 3)
30
+ expect(cachedIndices(result)).toEqual([2])
31
+ })
32
+
33
+ test('cachedPrefixCount larger than messages.length → out-of-range prefix ignored, tail still applied', () => {
34
+ const messages = userMessages(4)
35
+ const result = applyCacheBreakpoint(messages, 0, undefined, 99)
36
+ // prefix = 98 (out of range, ignored), tail = 4 - 1 - 0 = 3
37
+ expect(cachedIndices(result)).toEqual([3])
38
+ })
39
+
40
+ test('ttl is propagated onto the marked messages', () => {
41
+ const messages = userMessages(6)
42
+ const result = applyCacheBreakpoint(messages, 0, '1h', 2)
43
+ // prefix = 1, tail = 5
44
+ for (const idx of [1, 5]) {
45
+ const m = result[idx]
46
+ expect('cacheControl' in m && m.cacheControl).toEqual({ type: 'ephemeral', ttl: '1h' })
47
+ }
48
+ })
49
+
50
+ test('does not mutate the input array', () => {
51
+ const messages = userMessages(5)
52
+ applyCacheBreakpoint(messages, 0, undefined, 2)
53
+ expect(cachedIndices(messages)).toEqual([])
54
+ })
55
+ })
@@ -1,7 +1,7 @@
1
1
  import type { LLMMessage, LLMMessageCacheControl } from '~/core/agents/state.js'
2
2
 
3
3
  /**
4
- * Mark the prompt cache breakpoint on a message list.
4
+ * Mark the prompt cache breakpoints on a message list.
5
5
  *
6
6
  * Flag-based: the target message gets a `cacheControl` marker. Providers that
7
7
  * support ephemeral prompt caching (anthropic, openrouter) react to the flag
@@ -10,9 +10,23 @@ import type { LLMMessage, LLMMessageCacheControl } from '~/core/agents/state.js'
10
10
  * (text / tool_use / tool_result / image). This matches the API semantics
11
11
  * "cache the prefix up to and including this block".
12
12
  *
13
- * Target index is `messages.length - 1 - uncachedSuffixCount`. The suffix is
14
- * the tail of messages that must remain fresh (e.g. ephemeral session context
15
- * rebuilt each inference).
13
+ * Up to two breakpoints are set:
14
+ *
15
+ * 1. **Tail breakpoint** at `messages.length - 1 - uncachedSuffixCount`. The
16
+ * suffix is the tail of messages that must remain fresh (e.g. ephemeral
17
+ * session context rebuilt each inference).
18
+ *
19
+ * 2. **Stable prefix breakpoint** at `cachedPrefixCount - 1` (the last preamble
20
+ * message), set only when `cachedPrefixCount > 0`. The preamble is byte-
21
+ * identical on every call, but the prefix before the tail breakpoint churns
22
+ * turn-to-turn for compacting agents (regenerated summary, sliding recent
23
+ * window), so the tail breakpoint never produces a stable cache entry for
24
+ * the large immutable preamble. Pinning a second breakpoint at the end of
25
+ * the preamble lets Anthropic cache that prefix once and read it at 0.1× on
26
+ * every inference AND compaction call. Anthropic allows up to 4 breakpoints
27
+ * and matches the longest cached prefix, so the two coexist.
28
+ *
29
+ * If the prefix and tail indices coincide, only one breakpoint is set.
16
30
  *
17
31
  * `ttl` opts into Anthropic's 1-hour cache tier (write cost 2× input, read
18
32
  * still 0.1×). Useful for long-lived agents where the default 5-minute TTL
@@ -22,26 +36,30 @@ export function applyCacheBreakpoint(
22
36
  messages: LLMMessage[],
23
37
  uncachedSuffixCount: number,
24
38
  ttl?: '5m' | '1h',
39
+ cachedPrefixCount = 0,
25
40
  ): LLMMessage[] {
26
- const idx = messages.length - 1 - uncachedSuffixCount
27
- if (idx < 0) return messages
28
-
29
41
  const cacheControl: LLMMessageCacheControl = ttl ? { type: 'ephemeral', ttl } : { type: 'ephemeral' }
30
- const target = messages[idx]
31
42
  const result = [...messages]
32
- switch (target.role) {
33
- case 'user':
34
- result[idx] = { ...target, cacheControl }
35
- break
36
- case 'assistant':
37
- result[idx] = { ...target, cacheControl }
38
- break
39
- case 'system':
40
- result[idx] = { ...target, cacheControl }
41
- break
42
- case 'tool':
43
- result[idx] = { ...target, cacheControl }
44
- break
43
+
44
+ const mark = (idx: number) => {
45
+ if (idx < 0 || idx >= result.length) return
46
+ const target = result[idx]
47
+ switch (target.role) {
48
+ case 'user':
49
+ case 'assistant':
50
+ case 'system':
51
+ case 'tool':
52
+ result[idx] = { ...target, cacheControl }
53
+ }
45
54
  }
55
+
56
+ const tailIdx = messages.length - 1 - uncachedSuffixCount
57
+ // Stable prefix breakpoint at the last preamble message. Pins a cache entry
58
+ // that survives turn-to-turn churn in the conversation tail (regenerated
59
+ // summary, sliding recent window), so the immutable preamble is read at 0.1×
60
+ // on every inference AND compaction call instead of being re-billed each turn.
61
+ const prefixIdx = cachedPrefixCount - 1
62
+ if (prefixIdx !== tailIdx) mark(prefixIdx)
63
+ mark(tailIdx)
46
64
  return result
47
65
  }
@@ -48,6 +48,18 @@ export type LLMMetrics = {
48
48
  // LLM events
49
49
  // ============================================================================
50
50
 
51
+ const llmMetricsSchema = z4.object({
52
+ promptTokens: z4.number(),
53
+ completionTokens: z4.number(),
54
+ totalTokens: z4.number(),
55
+ latencyMs: z4.number(),
56
+ model: z4.string(),
57
+ provider: z4.string().optional(),
58
+ cost: z4.number().optional(),
59
+ cachedTokens: z4.number().optional(),
60
+ cacheWriteTokens: z4.number().optional(),
61
+ })
62
+
51
63
  export const llmEvents = createEventsFactory({
52
64
  events: {
53
65
  inference_started: z4.object({
@@ -66,19 +78,20 @@ export const llmEvents = createEventsFactory({
66
78
  input: z4.unknown(),
67
79
  })),
68
80
  }),
69
- metrics: z4.object({
70
- promptTokens: z4.number(),
71
- completionTokens: z4.number(),
72
- totalTokens: z4.number(),
73
- latencyMs: z4.number(),
74
- model: z4.string(),
75
- provider: z4.string().optional(),
76
- cost: z4.number().optional(),
77
- cachedTokens: z4.number().optional(),
78
- cacheWriteTokens: z4.number().optional(),
79
- }),
81
+ metrics: llmMetricsSchema,
80
82
  llmCallId: llmCallIdSchema.optional(),
81
83
  }),
84
+ /**
85
+ * A side-channel ("auxiliary") inference completed — e.g. the context-compact
86
+ * plugin asking the model for a summary. Unlike `inference_completed`, this
87
+ * does NOT touch conversation state; it exists purely so the call's token
88
+ * usage and cost are still accounted in session stats and metadata. Without
89
+ * it, compaction (and any other auxiliary call) would be billed but invisible.
90
+ */
91
+ auxiliary_inference_completed: z4.object({
92
+ agentId: agentIdSchema,
93
+ metrics: llmMetricsSchema,
94
+ }),
82
95
  inference_failed: z4.object({
83
96
  agentId: agentIdSchema,
84
97
  error: z4.string(),
@@ -89,4 +102,5 @@ export const llmEvents = createEventsFactory({
89
102
 
90
103
  export type InferenceStartedEvent = (typeof llmEvents)['Events']['inference_started']
91
104
  export type InferenceCompletedEvent = (typeof llmEvents)['Events']['inference_completed']
105
+ export type AuxiliaryInferenceCompletedEvent = (typeof llmEvents)['Events']['auxiliary_inference_completed']
92
106
  export type InferenceFailedEvent = (typeof llmEvents)['Events']['inference_failed']
package/src/index.ts CHANGED
@@ -82,14 +82,15 @@ export type { BuiltinEvent } from './builtin-events.js'
82
82
  export type { AgentChatMessage, AskUserChatMessage, ChatMessage, UserChatMessage } from '~/plugins/user-chat/index.js'
83
83
 
84
84
  // Plugins
85
- export { agentStatusPlugin } from '~/plugins/agent-status/plugin.js'
85
+ export { agentStatusPlugin, agentStoppedNotificationSchema } from '~/plugins/agent-status/plugin.js'
86
+ export type { AgentStoppedNotification } from '~/plugins/agent-status/plugin.js'
86
87
  export { agentsPlugin } from '~/plugins/agents/plugin.js'
87
88
  export type { AgentsPluginConfig } from '~/plugins/agents/plugin.js'
88
89
  export { contextCompactPlugin } from '~/plugins/context-compact/plugin.js'
89
90
  export type { ContextCompactPluginConfig } from '~/plugins/context-compact/plugin.js'
90
- export { limitsGuardPlugin, selectAgentCounters } from '~/plugins/limits-guard/plugin.js'
91
- export type { AgentCounters, LimitsAgentConfig } from '~/plugins/limits-guard/plugin.js'
92
- export type { AgentLimits } from '~/plugins/limits-guard/config.js'
91
+ export { limitsGuardPlugin, selectAgentCounters, sumSessionSpend } from '~/plugins/limits-guard/plugin.js'
92
+ export type { AgentCounters, BudgetExceededEvent, LimitsAgentConfig } from '~/plugins/limits-guard/plugin.js'
93
+ export type { AgentLimits, LimitsSessionConfig } from '~/plugins/limits-guard/config.js'
93
94
  export { mailboxPlugin } from '~/plugins/mailbox/plugin.js'
94
95
  export type { MailboxAgentConfig, MailboxPresetConfig } from '~/plugins/mailbox/plugin.js'
95
96
  export { resultEvictionPlugin } from '~/plugins/result-eviction/plugin.js'
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { AgentId } from '~/core/agents/schema.js'
3
+ import { MockLLMProvider } from '~/core/llm/mock.js'
4
+ import type { PluginNotification } from '~/core/plugins/plugin-builder.js'
5
+ import { ToolCallId } from '~/core/tools/schema.js'
6
+ import { limitsGuardPlugin } from '~/plugins/limits-guard/plugin.js'
7
+ import { createMultiAgentPreset, createTestPreset, type TestSession, TestHarness } from '~/testing/index.js'
8
+ import { type AgentStoppedNotification, agentStatusPlugin } from './plugin.js'
9
+
10
+ type StatusPayload = { sessionId: string; agentId: string; status: string; definitionName?: string; timestamp: number }
11
+
12
+ const agentStatusNotifications = (session: TestSession): StatusPayload[] =>
13
+ session.getNotifications()
14
+ .filter((n: PluginNotification) => n.pluginName === 'agent-status' && n.type === 'agentStatus')
15
+ .map((n) => n.payload as StatusPayload)
16
+
17
+ const agentStoppedNotifications = (session: TestSession): AgentStoppedNotification[] =>
18
+ session.getNotifications()
19
+ .filter((n: PluginNotification) => n.pluginName === 'agent-status' && n.type === 'agentStopped')
20
+ .map((n) => n.payload as AgentStoppedNotification)
21
+
22
+ async function waitForAgentStatus(session: TestSession, agentId: AgentId, status: string, timeoutMs = 5000): Promise<void> {
23
+ const deadline = Date.now() + timeoutMs
24
+ while (Date.now() < deadline) {
25
+ const agentState = session.state.agents.get(agentId)
26
+ if (agentState?.status === status) return
27
+ await new Promise((r) => setTimeout(r, 10))
28
+ }
29
+ throw new Error(`waitForAgentStatus timed out after ${timeoutMs}ms (wanted ${status}) for agent ${agentId}`)
30
+ }
31
+
32
+ describe('agent-status plugin', () => {
33
+ describe('agentStatus (existing behavior)', () => {
34
+ it('emits thinking on start and idle on completion', async () => {
35
+ const harness = new TestHarness({
36
+ presets: [createTestPreset()],
37
+ systemPlugins: [agentStatusPlugin],
38
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Done', toolCalls: [] }),
39
+ })
40
+
41
+ const session = await harness.createSession('test')
42
+ const entryAgentId = session.getEntryAgentId()!
43
+ await session.sendAndWaitForIdle('Hello')
44
+
45
+ const statuses = agentStatusNotifications(session).map((p) => p.status)
46
+ expect(statuses).toContain('thinking')
47
+ expect(statuses).toContain('idle')
48
+
49
+ // No abnormal-terminal notification for a normal completion.
50
+ expect(agentStoppedNotifications(session)).toHaveLength(0)
51
+
52
+ await harness.shutdown()
53
+ })
54
+ })
55
+
56
+ describe('agentStopped on pause', () => {
57
+ it('manual pause → agentStopped kind:paused reason:manual with message', async () => {
58
+ let orchestratorCalls = 0
59
+ const harness = new TestHarness({
60
+ presets: [createMultiAgentPreset(
61
+ [{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] }],
62
+ { orchestratorSystem: 'Orchestrator agent.' },
63
+ )],
64
+ systemPlugins: [agentStatusPlugin],
65
+ mockHandler: (request) => {
66
+ if (request.systemPrompt.includes('Orchestrator')) {
67
+ orchestratorCalls++
68
+ if (orchestratorCalls === 1) {
69
+ return {
70
+ content: null,
71
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Work' } }],
72
+ finishReason: 'stop',
73
+ metrics: MockLLMProvider.defaultMetrics(),
74
+ }
75
+ }
76
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
77
+ }
78
+ return { content: 'Worker done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
79
+ },
80
+ })
81
+
82
+ const session = await harness.createSession('test')
83
+ await session.sendAndWaitForIdle('Start')
84
+
85
+ // Manual pause via Session.pauseAgent — the real manual-pause API that
86
+ // runs onPause hooks (agents.pause only emits the event, no hooks).
87
+ const innerSession = (session as unknown as { session: { pauseAgent: (id: AgentId, message?: string) => Promise<{ ok: boolean }> } }).session
88
+ const result = await innerSession.pauseAgent(AgentId('worker_1'), 'Pausing for review')
89
+ expect(result.ok).toBe(true)
90
+
91
+ const stopped = agentStoppedNotifications(session).filter((n) => n.agentId === 'worker_1')
92
+ expect(stopped).toHaveLength(1)
93
+ expect(stopped[0].kind).toBe('paused')
94
+ expect(stopped[0].reason).toBe('manual')
95
+ expect(stopped[0].message).toBe('Pausing for review')
96
+ expect(stopped[0].definitionName).toBe('worker')
97
+ expect(typeof stopped[0].timestamp).toBe('number')
98
+
99
+ // idle agentStatus still emitted alongside.
100
+ expect(agentStatusNotifications(session).some((p) => p.agentId === 'worker_1' && p.status === 'idle')).toBe(true)
101
+
102
+ await harness.shutdown()
103
+ })
104
+
105
+ it('limits-guard hard limit → agentStopped kind:paused with reason and message', async () => {
106
+ let inferenceCount = 0
107
+ const harness = new TestHarness({
108
+ presets: [createTestPreset({
109
+ orchestratorSystem: 'Test agent.',
110
+ orchestratorPlugins: [limitsGuardPlugin.configureAgent({ limits: { maxTurns: 2 } })],
111
+ })],
112
+ systemPlugins: [agentStatusPlugin, limitsGuardPlugin],
113
+ mockHandler: () => {
114
+ inferenceCount++
115
+ return {
116
+ content: null,
117
+ toolCalls: [{ id: ToolCallId(`tc${inferenceCount}`), name: 'tell_user', input: { message: `Turn ${inferenceCount}` } }],
118
+ finishReason: 'stop',
119
+ metrics: MockLLMProvider.defaultMetrics(),
120
+ }
121
+ },
122
+ })
123
+
124
+ const session = await harness.createSession('test')
125
+ const entryAgentId = session.getEntryAgentId()!
126
+ await session.sendMessage('Start')
127
+ await waitForAgentStatus(session, entryAgentId, 'paused')
128
+
129
+ const stopped = agentStoppedNotifications(session).filter((n) => n.agentId === String(entryAgentId))
130
+ expect(stopped.length).toBeGreaterThanOrEqual(1)
131
+ expect(stopped[0].kind).toBe('paused')
132
+ // limits-guard pauses via beforeInference {action:'pause'} → reason 'handler',
133
+ // with the human-readable budget detail in `message`.
134
+ expect(stopped[0].reason).toBe('handler')
135
+ expect(stopped[0].message).toBeTruthy()
136
+
137
+ await harness.shutdown()
138
+ })
139
+ })
140
+
141
+ describe('agentStopped on error', () => {
142
+ it('non-retryable LLM error → agentStopped kind:errored with message', async () => {
143
+ const harness = new TestHarness({
144
+ presets: [createTestPreset()],
145
+ systemPlugins: [agentStatusPlugin],
146
+ llmProvider: MockLLMProvider.withError({ type: 'invalid_request', message: 'Bad request' }),
147
+ })
148
+
149
+ const session = await harness.createSession('test')
150
+ const entryAgentId = session.getEntryAgentId()!
151
+ await session.sendMessage('Trigger error')
152
+ await waitForAgentStatus(session, entryAgentId, 'errored')
153
+
154
+ const stopped = agentStoppedNotifications(session).filter((n) => n.agentId === String(entryAgentId))
155
+ expect(stopped).toHaveLength(1)
156
+ expect(stopped[0].kind).toBe('errored')
157
+ expect(stopped[0].reason).toBeUndefined()
158
+ expect(stopped[0].message).toBe('Bad request')
159
+ expect(stopped[0].definitionName).toBeDefined()
160
+
161
+ await harness.shutdown()
162
+ })
163
+ })
164
+ })
@@ -14,6 +14,11 @@
14
14
  * `idle` fires on `onComplete`, `onError`, and `onPause` — covers every terminal
15
15
  * state of an agent turn, including hard-limit pauses (limits-guard) and manual
16
16
  * `Session.pauseAgent` calls that would otherwise leave the indicator hung.
17
+ *
18
+ * Alongside the `idle` status, abnormal terminal states (pause / error) also emit
19
+ * a dedicated `agentStopped` notification carrying the pause reason / error detail,
20
+ * so consumers (e.g. a Cloudflare worker) can alert on a budget pause or crash —
21
+ * which the coarse `idle` status alone can't distinguish from a normal completion.
17
22
  */
18
23
 
19
24
  import z from 'zod/v4'
@@ -21,6 +26,24 @@ import { agentIdSchema, protocolAgentStatusSchema } from '~/core/agents/schema.j
21
26
  import { definePlugin } from '~/core/plugins/index.js'
22
27
  import { sessionIdSchema } from '~/core/sessions/schema.js'
23
28
 
29
+ /**
30
+ * Payload for the `agentStopped` notification — emitted when an agent reaches an
31
+ * abnormal terminal state (paused or errored), as opposed to a normal idle.
32
+ */
33
+ export const agentStoppedNotificationSchema = z.object({
34
+ sessionId: sessionIdSchema,
35
+ agentId: agentIdSchema,
36
+ definitionName: z.string().optional(),
37
+ kind: z.enum(['paused', 'errored']),
38
+ /** Present when kind === 'paused' — why the agent was paused. */
39
+ reason: z.enum(['limit', 'handler', 'manual']).optional(),
40
+ /** Human-readable detail (budget message / error message). */
41
+ message: z.string().optional(),
42
+ timestamp: z.number(),
43
+ })
44
+
45
+ export type AgentStoppedNotification = z.infer<typeof agentStoppedNotificationSchema>
46
+
24
47
  export const agentStatusPlugin = definePlugin('agent-status')
25
48
  .notification('agentStatus', {
26
49
  schema: z.object({
@@ -31,6 +54,9 @@ export const agentStatusPlugin = definePlugin('agent-status')
31
54
  timestamp: z.number(),
32
55
  }),
33
56
  })
57
+ .notification('agentStopped', {
58
+ schema: agentStoppedNotificationSchema,
59
+ })
34
60
  .hook('onStart', async (ctx) => {
35
61
  ctx.notify('agentStatus', {
36
62
  sessionId: ctx.sessionId,
@@ -82,6 +108,15 @@ export const agentStatusPlugin = definePlugin('agent-status')
82
108
  definitionName: ctx.agentState.definitionName,
83
109
  timestamp: Date.now(),
84
110
  })
111
+ // Additive abnormal-terminal signal for alerting (e.g. worker crash alert).
112
+ ctx.notify('agentStopped', {
113
+ sessionId: ctx.sessionId,
114
+ agentId: ctx.agentId,
115
+ definitionName: ctx.agentState.definitionName,
116
+ kind: 'errored',
117
+ message: ctx.error,
118
+ timestamp: Date.now(),
119
+ })
85
120
  return null
86
121
  })
87
122
  .hook('onPause', async (ctx) => {
@@ -95,6 +130,20 @@ export const agentStatusPlugin = definePlugin('agent-status')
95
130
  definitionName: ctx.agentState.definitionName,
96
131
  timestamp: Date.now(),
97
132
  })
133
+ // Additive abnormal-terminal signal carrying the structured pause reason and
134
+ // message. Read from agentState (set by the agent_paused reducer) rather than
135
+ // the loosely-typed `ctx.reason`, which actually carries the message string.
136
+ // A budget breach surfaces as reason:'handler' with the budget detail in
137
+ // `message` (limits-guard pauses via beforeInference `{action:'pause'}`).
138
+ ctx.notify('agentStopped', {
139
+ sessionId: ctx.sessionId,
140
+ agentId: ctx.agentId,
141
+ definitionName: ctx.agentState.definitionName,
142
+ kind: 'paused',
143
+ reason: ctx.agentState.pauseReason,
144
+ message: ctx.agentState.pauseMessage,
145
+ timestamp: Date.now(),
146
+ })
98
147
  return null
99
148
  })
100
149
  .build()
@@ -127,6 +127,11 @@ function buildChildrenStatus(sessionAgents: Map<AgentId, AgentState>, parentId:
127
127
  const last = previewLastAssistant(c)
128
128
 
129
129
  const parts: string[] = [c.id, c.status]
130
+ // Surface why a child paused (e.g. budget/limit exhaustion) so the parent can
131
+ // react — bump the budget and resume, reassign the work, or stop.
132
+ if (c.status === 'paused' && c.pauseMessage) {
133
+ parts.push(`reason: ${c.pauseMessage.replaceAll('"', "'")}`)
134
+ }
130
135
  parts.push(`${tools} tools`)
131
136
  parts.push(`${llm} llm`)
132
137
  if (subs > 0) parts.push(`${subs} sub${subs === 1 ? '' : 's'}`)
@@ -414,7 +419,8 @@ export const agentsPlugin = definePlugin('agents')
414
419
 
415
420
  - **New task** → spawn a new agent using \`start_<agent_name>\`. You will receive the agent's ID in the result — use it with \`send_message\` for follow-up communication.
416
421
  - **Follow-up on an existing task** → send a message to the existing agent via \`send_message\` with the agent's ID. Do NOT spawn a new agent for feedback, corrections, or additional instructions on a task already assigned.
417
- - Spawned agents communicate back to you via \`send_message\`. Check your incoming messages for their results and progress updates.`
422
+ - Spawned agents communicate back to you via \`send_message\`. Check your incoming messages for their results and progress updates.
423
+ - If a child pauses early it sends you a \`<child-paused agent="…">reason</child-paused>\` message (e.g. it hit a cost/limit budget). Decide what to do: resume it (after addressing the cause), reassign or drop the work, or stop.`
418
424
 
419
425
  // Only include supervision instructions if supervision is actually enabled
420
426
  // for this session — otherwise the section is misleading bloat.
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
  import z from 'zod/v4'
3
3
  import { contextEvents } from '~/core/context/state.js'
4
+ import { llmEvents } from '~/core/llm/state.js'
4
5
  import { MockLLMProvider } from '~/core/llm/mock.js'
5
6
  import type { InferenceRequest } from '~/core/llm/provider.js'
6
7
  import { ModelId } from '~/core/llm/schema.js'
7
8
  import type { Preset } from '~/core/preset/index.js'
8
9
  import { createTool } from '~/core/tools/definition.js'
9
10
  import { ToolCallId } from '~/core/tools/schema.js'
11
+ import { selectSessionStats, sessionStatsPlugin } from '~/plugins/session-stats/index.js'
10
12
  import { createTestPreset, TestHarness } from '~/testing/index.js'
11
13
  import { contextCompactPlugin } from './index.js'
12
14
 
@@ -336,4 +338,64 @@ describe('context-compact plugin', () => {
336
338
  await harness.shutdown()
337
339
  })
338
340
  })
341
+
342
+ // =========================================================================
343
+ // Cost accounting — the compaction summarization call is a real, billed LLM
344
+ // call. Its tokens/cost must land in session stats, not vanish. (Regression:
345
+ // runAuxiliaryInference used to skip emitting any stats event.)
346
+ // =========================================================================
347
+
348
+ describe('compaction cost accounting', () => {
349
+ it('summarization call cost is counted in session stats', async () => {
350
+ const REGULAR_COST = 0.01
351
+ const SUMMARY_COST = 0.05
352
+
353
+ const harness = new TestHarness({
354
+ systemPlugins: [contextCompactPlugin, sessionStatsPlugin],
355
+ presets: [createCompactPreset(10)],
356
+ mockHandler: (request) => {
357
+ if (isSummarizationRequest(request)) {
358
+ return {
359
+ content: 'Summary of conversation so far.',
360
+ toolCalls: [],
361
+ finishReason: 'stop',
362
+ metrics: MockLLMProvider.defaultMetricsWithCost(SUMMARY_COST),
363
+ }
364
+ }
365
+ return {
366
+ content: 'Agent response with some content to increase token count.',
367
+ toolCalls: [],
368
+ finishReason: 'stop',
369
+ metrics: MockLLMProvider.defaultMetricsWithCost(REGULAR_COST),
370
+ }
371
+ },
372
+ })
373
+
374
+ const session = await harness.createSession('test')
375
+ await session.sendAndWaitForIdle('First message')
376
+ await session.sendAndWaitForIdle('Second message')
377
+ await session.sendAndWaitForIdle('Third message to trigger actual compaction')
378
+
379
+ // Compaction actually ran and made a billed summarization call.
380
+ const auxEvents = await session.getEventsByType(llmEvents, 'auxiliary_inference_completed')
381
+ expect(auxEvents.length).toBeGreaterThanOrEqual(1)
382
+ expect(auxEvents.some((e) => e.metrics.cost === SUMMARY_COST)).toBe(true)
383
+
384
+ // Session stats must include both the regular turns AND the summarization
385
+ // call — in count, tokens, and cost.
386
+ const inferEvents = await session.getEventsByType(llmEvents, 'inference_completed')
387
+ const allLlmEvents = [...inferEvents, ...auxEvents]
388
+ const expectedCost = allLlmEvents.reduce((sum, e) => sum + (e.metrics.cost ?? 0), 0)
389
+ const expectedTokens = allLlmEvents.reduce((sum, e) => sum + e.metrics.totalTokens, 0)
390
+
391
+ const stats = selectSessionStats(session.state)
392
+ expect(stats.llmCalls).toBe(allLlmEvents.length)
393
+ expect(stats.totalCost).toBeCloseTo(expectedCost, 10)
394
+ expect(stats.totalTokens).toBe(expectedTokens)
395
+ // And the summarization cost is genuinely part of the total (not zero).
396
+ expect(stats.totalCost).toBeGreaterThanOrEqual(SUMMARY_COST)
397
+
398
+ await harness.shutdown()
399
+ })
400
+ })
339
401
  })
@@ -291,6 +291,7 @@ describe('createContextCompactedEvent', () => {
291
291
  { role: 'system', content: 'summary' },
292
292
  { role: 'user', content: 'recent' },
293
293
  ],
294
+ originalMessages: [{ role: 'user', content: 'old message' }],
294
295
  summary: 'The summary',
295
296
  originalTokens: 1000,
296
297
  compactedTokens: 200,
@@ -309,6 +310,8 @@ describe('createContextCompactedEvent', () => {
309
310
  expect(event.newConversationHistory.length).toBe(2)
310
311
  expect(event.newConversationHistory[0].role).toBe('system')
311
312
  expect(event.newConversationHistory[0].content).toBe('summary')
313
+ expect(event.originalMessages?.length).toBe(1)
314
+ expect(event.originalMessages?.[0].content).toBe('old message')
312
315
  expect(event.timestamp).toBeDefined()
313
316
  })
314
317
 
@@ -318,6 +321,7 @@ describe('createContextCompactedEvent', () => {
318
321
  const toolCallId = generateToolCallId()
319
322
  const result: CompactionResult = {
320
323
  compactedMessages: [{ role: 'tool', content: 'tool result', toolCallId }],
324
+ originalMessages: [],
321
325
  summary: '',
322
326
  originalTokens: 100,
323
327
  compactedTokens: 50,
@@ -852,6 +856,7 @@ describe('createContextCompactedEvent with historyPath', () => {
852
856
  compactedMessages: [
853
857
  { role: 'system', content: 'summary' },
854
858
  ],
859
+ originalMessages: [],
855
860
  summary: 'The summary',
856
861
  originalTokens: 1000,
857
862
  compactedTokens: 200,
@@ -871,6 +876,7 @@ describe('createContextCompactedEvent with historyPath', () => {
871
876
  compactedMessages: [
872
877
  { role: 'system', content: 'summary' },
873
878
  ],
879
+ originalMessages: [],
874
880
  summary: 'The summary',
875
881
  originalTokens: 1000,
876
882
  compactedTokens: 200,