@roj-ai/sdk 0.1.31 → 0.1.33

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 (52) hide show
  1. package/dist/core/agents/agent.d.ts +5 -1
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js.map +1 -1
  4. package/dist/core/agents/config.d.ts +5 -1
  5. package/dist/core/agents/config.d.ts.map +1 -1
  6. package/dist/core/llm/anthropic.d.ts.map +1 -1
  7. package/dist/core/llm/anthropic.js +7 -1
  8. package/dist/core/llm/anthropic.js.map +1 -1
  9. package/dist/core/llm/cache-breakpoints.d.ts +39 -5
  10. package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
  11. package/dist/core/llm/cache-breakpoints.js +22 -8
  12. package/dist/core/llm/cache-breakpoints.js.map +1 -1
  13. package/dist/core/llm/cache-breakpoints.test.js +32 -8
  14. package/dist/core/llm/cache-breakpoints.test.js.map +1 -1
  15. package/dist/core/llm/openrouter-mapping.test.js +29 -0
  16. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  17. package/dist/core/llm/openrouter.d.ts.map +1 -1
  18. package/dist/core/llm/openrouter.js +28 -8
  19. package/dist/core/llm/openrouter.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugins/result-eviction/plugin.d.ts.map +1 -1
  24. package/dist/plugins/result-eviction/plugin.js +24 -9
  25. package/dist/plugins/result-eviction/plugin.js.map +1 -1
  26. package/dist/plugins/result-eviction/result-eviction.integration.test.js +77 -0
  27. package/dist/plugins/result-eviction/result-eviction.integration.test.js.map +1 -1
  28. package/dist/plugins/session-stats/index.d.ts +1 -1
  29. package/dist/plugins/session-stats/index.d.ts.map +1 -1
  30. package/dist/plugins/session-stats/plugin.d.ts +16 -0
  31. package/dist/plugins/session-stats/plugin.d.ts.map +1 -1
  32. package/dist/plugins/session-stats/plugin.js +29 -2
  33. package/dist/plugins/session-stats/plugin.js.map +1 -1
  34. package/dist/plugins/session-stats/plugin.test.d.ts +2 -0
  35. package/dist/plugins/session-stats/plugin.test.d.ts.map +1 -0
  36. package/dist/plugins/session-stats/plugin.test.js +145 -0
  37. package/dist/plugins/session-stats/plugin.test.js.map +1 -0
  38. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  39. package/package.json +2 -2
  40. package/src/core/agents/agent.ts +5 -2
  41. package/src/core/agents/config.ts +5 -1
  42. package/src/core/llm/anthropic.ts +7 -1
  43. package/src/core/llm/cache-breakpoints.test.ts +38 -8
  44. package/src/core/llm/cache-breakpoints.ts +54 -9
  45. package/src/core/llm/openrouter-mapping.test.ts +37 -1
  46. package/src/core/llm/openrouter.ts +35 -9
  47. package/src/index.ts +2 -1
  48. package/src/plugins/result-eviction/plugin.ts +26 -11
  49. package/src/plugins/result-eviction/result-eviction.integration.test.ts +88 -0
  50. package/src/plugins/session-stats/index.ts +1 -1
  51. package/src/plugins/session-stats/plugin.test.ts +160 -0
  52. package/src/plugins/session-stats/plugin.ts +48 -2
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export type { AgentPauseReason, AgentState, LLMMessage } from '~/core/agents/sta
53
53
  export type { DomainEvent } from '~/core/events/types.js'
54
54
  export type { FactoryEventType } from '~/core/events/types.js'
55
55
  export { isDomainEvent } from '~/core/events/types.js'
56
+ export type { CacheTtl, CacheTtlConfig } from '~/core/llm/cache-breakpoints.js'
56
57
  export type { ChatMessageContentItem, LLMCallLogEntry, LLMCallMessage, ToolResultContent } from '~/core/llm/llm-log-types.js'
57
58
  export { contentToString } from '~/core/llm/llm-log-types.js'
58
59
  export { applyMiddleware, useProvider, withAnthropic, withMaxTokens, withOpenRouter, withTemperature } from '~/core/llm/middleware.js'
@@ -98,7 +99,7 @@ export type { EvictionAgentConfig, EvictionConfig } from '~/plugins/result-evict
98
99
  export { servicePlugin } from '~/plugins/services/plugin.js'
99
100
  export type { ServiceAgentConfig, ServiceStatusChangedEvent } from '~/plugins/services/plugin.js'
100
101
  export { selectSessionStats, sessionStatsPlugin } from '~/plugins/session-stats/plugin.js'
101
- export type { SessionStatsState } from '~/plugins/session-stats/plugin.js'
102
+ export type { AgentStats, ProviderStats, SessionStatsState } from '~/plugins/session-stats/plugin.js'
102
103
  export { sessionStatePlugin } from '~/plugins/session-state/plugin.js'
103
104
  export type { SessionStatePluginConfig } from '~/plugins/session-state/plugin.js'
104
105
  export { presetsPlugin, sessionLifecyclePlugin } from '~/plugins/session-lifecycle/plugin.js'
@@ -6,6 +6,7 @@
6
6
  * head + tail + file path is returned instead.
7
7
  */
8
8
 
9
+ import { type ChatMessageContentItem, contentToString, type ToolResultContent } from '~/core/llm/llm-log-types.js'
9
10
  import { truncateByTokens } from '~/core/llm/tokens.js'
10
11
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
11
12
 
@@ -23,6 +24,21 @@ export interface EvictionAgentConfig {
23
24
 
24
25
  const DEFAULT_MAX_TOKENS = 20_000
25
26
 
27
+ const isImage = (item: ChatMessageContentItem): boolean => item.type === 'image_url'
28
+
29
+ /**
30
+ * Rebuild a multi-part result with its text collapsed to `preview` and every
31
+ * image part preserved in order.
32
+ *
33
+ * Images are kept rather than truncated because there is no partial image — the
34
+ * alternative to keeping it is dropping the observation entirely. Text is the
35
+ * part that both dominates the payload and survives being summarised.
36
+ */
37
+ const withTextReplaced = (content: ChatMessageContentItem[], preview: string): ChatMessageContentItem[] => [
38
+ { type: 'text', text: preview },
39
+ ...content.filter(isImage),
40
+ ]
41
+
26
42
  export const resultEvictionPlugin = definePlugin('result-eviction')
27
43
  .agentConfig<EvictionAgentConfig>()
28
44
  .hook('afterToolCall', async (ctx) => {
@@ -34,31 +50,30 @@ export const resultEvictionPlugin = definePlugin('result-eviction')
34
50
  }
35
51
 
36
52
  const { content } = ctx.result
37
-
38
- // Only evict string content
39
- if (typeof content !== 'string') {
40
- return null
41
- }
42
-
43
53
  const maxTokens = agentConfig?.config?.maxTokens ?? DEFAULT_MAX_TOKENS
44
54
 
45
- const truncation = truncateByTokens(content, maxTokens)
55
+ // Multi-part results (e.g. a page snapshot returning JSON + a screenshot)
56
+ // used to bail out here, so no threshold could ever evict them however
57
+ // large the text grew. Measure the text, evict the text, keep the images.
58
+ const text = contentToString(content)
59
+ const truncation = truncateByTokens(text, maxTokens)
46
60
  if (!truncation) {
47
61
  return null
48
62
  }
49
63
 
50
- // Write full content to file via FileStore
64
+ // Write full text to file via FileStore
51
65
  const fileName = `${ctx.toolCall.id}.txt`
52
66
  const filePath = `.results/${fileName}`
53
- await ctx.files.session.write(filePath, content)
67
+ await ctx.files.session.write(filePath, text)
54
68
 
55
- const truncatedContent = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
69
+ const preview = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
70
+ const evicted: ToolResultContent = typeof content === 'string' ? preview : withTextReplaced(content, preview)
56
71
 
57
72
  return {
58
73
  action: 'modify',
59
74
  result: {
60
75
  isError: false,
61
- content: truncatedContent,
76
+ content: evicted,
62
77
  },
63
78
  }
64
79
  })
@@ -19,6 +19,18 @@ const bigOutputPlugin = definePlugin('big-output')
19
19
  input: z.object({ size: z.number() }),
20
20
  execute: async (input) => Ok('x'.repeat(input.size)),
21
21
  }),
22
+ // Mirrors a page-snapshot style result: a large JSON/text block plus an
23
+ // image part. These used to bypass eviction entirely.
24
+ createTool({
25
+ name: 'generate_snapshot',
26
+ description: 'Generate a text + image result of a given text size',
27
+ input: z.object({ size: z.number() }),
28
+ execute: async (input) =>
29
+ Ok([
30
+ { type: 'text' as const, text: 'x'.repeat(input.size) },
31
+ { type: 'image_url' as const, imageUrl: { url: 'data:image/png;base64,AAAA' } },
32
+ ]),
33
+ }),
22
34
  ])
23
35
  .build()
24
36
 
@@ -100,6 +112,82 @@ describe('result-eviction plugin', () => {
100
112
  await harness.shutdown()
101
113
  })
102
114
 
115
+ it('large multi-part output → text evicted, image part kept', async () => {
116
+ let toolContent: unknown = null
117
+ const harness = createEvictionHarness({
118
+ presets: [createEvictionPreset()],
119
+ mockHandler: (request) => {
120
+ const callCount = harness.llmProvider.getCallCount()
121
+ if (callCount === 1) {
122
+ return {
123
+ content: null,
124
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 250_000 } }],
125
+ finishReason: 'stop',
126
+ metrics: MockLLMProvider.defaultMetrics(),
127
+ }
128
+ }
129
+ for (const msg of request.messages) {
130
+ if (msg.role === 'tool' && Array.isArray(msg.content)) {
131
+ toolContent = msg.content
132
+ }
133
+ }
134
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
135
+ },
136
+ })
137
+
138
+ const session = await harness.createSession('test')
139
+ await session.sendAndWaitForIdle('Generate large snapshot')
140
+
141
+ expect(Array.isArray(toolContent)).toBe(true)
142
+ const parts = toolContent as Array<{ type: string; text?: string }>
143
+
144
+ // Text collapsed to a preview that names the spill file...
145
+ const textParts = parts.filter((p) => p.type === 'text')
146
+ expect(textParts).toHaveLength(1)
147
+ expect(textParts[0].text).toContain('truncated')
148
+ expect(textParts[0].text).toContain('.results/')
149
+ expect(textParts[0].text!.length).toBeLessThan(250_000)
150
+
151
+ // ...and the image survives, because there is no partial image: the only
152
+ // alternative to keeping it is losing the observation.
153
+ expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
154
+
155
+ await harness.shutdown()
156
+ })
157
+
158
+ it('small multi-part output → returned unchanged', async () => {
159
+ let toolContent: unknown = null
160
+ const harness = createEvictionHarness({
161
+ presets: [createEvictionPreset()],
162
+ mockHandler: (request) => {
163
+ const callCount = harness.llmProvider.getCallCount()
164
+ if (callCount === 1) {
165
+ return {
166
+ content: null,
167
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 100 } }],
168
+ finishReason: 'stop',
169
+ metrics: MockLLMProvider.defaultMetrics(),
170
+ }
171
+ }
172
+ for (const msg of request.messages) {
173
+ if (msg.role === 'tool' && Array.isArray(msg.content)) {
174
+ toolContent = msg.content
175
+ }
176
+ }
177
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
178
+ },
179
+ })
180
+
181
+ const session = await harness.createSession('test')
182
+ await session.sendAndWaitForIdle('Generate small snapshot')
183
+
184
+ const parts = toolContent as Array<{ type: string; text?: string }>
185
+ expect(parts.filter((p) => p.type === 'text')[0].text).toBe('x'.repeat(100))
186
+ expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
187
+
188
+ await harness.shutdown()
189
+ })
190
+
103
191
  it('full output saved to .results/<toolCallId>.txt', async () => {
104
192
  const harness = createEvictionHarness({
105
193
  presets: [createEvictionPreset()],
@@ -1,3 +1,3 @@
1
1
  export { sessionStatsPlugin } from './plugin.js'
2
- export type { SessionStatsState } from './plugin.js'
2
+ export type { AgentStats, ProviderStats, SessionStatsState } from './plugin.js'
3
3
  export { selectSessionStats } from './plugin.js'
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { AgentId } from '~/core/agents/schema.js'
3
+ import { agentEvents } from '~/core/agents/state.js'
4
+ import { contextEvents } from '~/core/context/state.js'
5
+ import { withSessionId } from '~/core/events/test-helpers.js'
6
+ import type { DomainEvent } from '~/core/events/types.js'
7
+ import type { LLMMetrics } from '~/core/llm/state.js'
8
+ import { llmEvents } from '~/core/llm/state.js'
9
+ import { createApplyEvent } from '~/core/sessions/apply-event.js'
10
+ import { SessionId } from '~/core/sessions/schema.js'
11
+ import { createSessionState } from '~/core/sessions/state.js'
12
+ import { ToolCallId } from '~/core/tools/schema.js'
13
+ import { toolEvents } from '~/core/tools/state.js'
14
+ import { userChatEvents } from '~/plugins/user-chat/plugin.js'
15
+ import { ChatMessageId } from '~/plugins/user-chat/schema.js'
16
+ import { selectSessionStats, sessionStatsPlugin } from './plugin.js'
17
+
18
+ const sessionId = SessionId('session-stats-test')
19
+ const orchestrator = AgentId('orchestrator-1')
20
+ const worker = AgentId('worker-1')
21
+
22
+ const applyEvent = createApplyEvent([sessionStatsPlugin.create()])
23
+
24
+ const metrics = (overrides: Partial<LLMMetrics> = {}): LLMMetrics => ({
25
+ promptTokens: 100,
26
+ completionTokens: 20,
27
+ totalTokens: 120,
28
+ latencyMs: 10,
29
+ model: 'test-model',
30
+ provider: 'anthropic',
31
+ cost: 0.5,
32
+ ...overrides,
33
+ })
34
+
35
+ const spawn = (agentId: AgentId, definitionName: string) =>
36
+ withSessionId(sessionId, agentEvents.create('agent_spawned', { agentId, definitionName, parentId: null }))
37
+
38
+ const inference = (agentId: AgentId, m: LLMMetrics) =>
39
+ withSessionId(sessionId, llmEvents.create('inference_completed', {
40
+ agentId,
41
+ consumedMessageIds: [],
42
+ response: { content: 'ok', toolCalls: [] },
43
+ metrics: m,
44
+ }))
45
+
46
+ const reduce = (events: DomainEvent[]) => selectSessionStats(events.reduce(applyEvent, createSessionState(sessionId, 'test-preset', 0)))
47
+
48
+ describe('session-stats plugin', () => {
49
+ it('starts from zeroed counters', () => {
50
+ const stats = reduce([])
51
+ expect(stats.userMessages).toBe(0)
52
+ expect(stats.compactions).toBe(0)
53
+ expect(stats.cacheReadTokens).toBe(0)
54
+ expect(stats.cacheWriteTokens).toBe(0)
55
+ expect(stats.byAgent).toEqual({})
56
+ })
57
+
58
+ it('counts user chat messages', () => {
59
+ const stats = reduce([
60
+ spawn(orchestrator, 'orchestrator'),
61
+ withSessionId(sessionId, userChatEvents.create('user_chat_message_received', {
62
+ agentId: orchestrator,
63
+ messageId: ChatMessageId('m1'),
64
+ content: 'hello',
65
+ timestamp: 1,
66
+ })),
67
+ withSessionId(sessionId, userChatEvents.create('user_chat_message_received', {
68
+ agentId: orchestrator,
69
+ messageId: ChatMessageId('m2'),
70
+ content: 'again',
71
+ timestamp: 2,
72
+ })),
73
+ ])
74
+ expect(stats.userMessages).toBe(2)
75
+ })
76
+
77
+ it('counts context compactions', () => {
78
+ const stats = reduce([
79
+ spawn(orchestrator, 'orchestrator'),
80
+ withSessionId(sessionId, contextEvents.create('context_compacted', {
81
+ agentId: orchestrator,
82
+ compactedContent: 'summary',
83
+ newConversationHistory: [],
84
+ originalTokens: 1000,
85
+ compactedTokens: 100,
86
+ messagesRemoved: 8,
87
+ })),
88
+ ])
89
+ expect(stats.compactions).toBe(1)
90
+ })
91
+
92
+ it('sums cache read and write tokens across main and auxiliary inferences', () => {
93
+ const stats = reduce([
94
+ spawn(orchestrator, 'orchestrator'),
95
+ inference(orchestrator, metrics({ cachedTokens: 800, cacheWriteTokens: 200 })),
96
+ withSessionId(sessionId, llmEvents.create('auxiliary_inference_completed', {
97
+ agentId: orchestrator,
98
+ metrics: metrics({ cachedTokens: 50, cacheWriteTokens: 10 }),
99
+ })),
100
+ // Providers that report no cache usage must not break the totals.
101
+ inference(orchestrator, metrics()),
102
+ ])
103
+ expect(stats.cacheReadTokens).toBe(850)
104
+ expect(stats.cacheWriteTokens).toBe(210)
105
+ expect(stats.llmCalls).toBe(3)
106
+ })
107
+
108
+ it('breaks LLM usage down by agent definition name', () => {
109
+ const stats = reduce([
110
+ spawn(orchestrator, 'orchestrator'),
111
+ spawn(worker, 'coder'),
112
+ inference(orchestrator, metrics({ promptTokens: 100, completionTokens: 20, cost: 0.5 })),
113
+ inference(worker, metrics({ promptTokens: 300, completionTokens: 40, cost: 1.25 })),
114
+ inference(worker, metrics({ promptTokens: 200, completionTokens: 10, cost: 0.25 })),
115
+ withSessionId(sessionId, llmEvents.create('auxiliary_inference_completed', {
116
+ agentId: worker,
117
+ metrics: metrics({ promptTokens: 10, completionTokens: 1, cost: 0.05 }),
118
+ })),
119
+ ])
120
+ expect(stats.byAgent).toEqual({
121
+ orchestrator: { llmCalls: 1, promptTokens: 100, completionTokens: 20, totalCost: 0.5 },
122
+ coder: { llmCalls: 3, promptTokens: 510, completionTokens: 51, totalCost: 1.55 },
123
+ })
124
+ expect(stats.totalCost).toBeCloseTo(2.05, 10)
125
+ })
126
+
127
+ it('keeps existing counters working', () => {
128
+ const stats = reduce([
129
+ spawn(orchestrator, 'orchestrator'),
130
+ inference(orchestrator, metrics()),
131
+ withSessionId(sessionId, llmEvents.create('inference_failed', { agentId: orchestrator, error: 'boom' })),
132
+ withSessionId(sessionId, toolEvents.create('tool_started', {
133
+ agentId: orchestrator,
134
+ toolCallId: ToolCallId('tc-1'),
135
+ toolName: 'read_file',
136
+ input: {},
137
+ })),
138
+ withSessionId(sessionId, toolEvents.create('tool_failed', {
139
+ agentId: orchestrator,
140
+ toolCallId: ToolCallId('tc-1'),
141
+ error: 'ENOENT',
142
+ })),
143
+ ])
144
+ expect(stats.agentCount).toBe(1)
145
+ expect(stats.llmCalls).toBe(1)
146
+ expect(stats.llmErrors).toBe(1)
147
+ expect(stats.toolCalls).toBe(1)
148
+ expect(stats.toolErrors).toBe(1)
149
+ expect(stats.totalTokens).toBe(120)
150
+ expect(stats.promptTokens).toBe(100)
151
+ expect(stats.completionTokens).toBe(20)
152
+ expect(stats.byProvider.anthropic).toEqual({
153
+ llmCalls: 1,
154
+ totalTokens: 120,
155
+ promptTokens: 100,
156
+ completionTokens: 20,
157
+ totalCost: 0.5,
158
+ })
159
+ })
160
+ })
@@ -1,9 +1,11 @@
1
1
  import { agentEvents } from '~/core/agents/state.js'
2
+ import { contextEvents } from '~/core/context/state.js'
2
3
  import { llmEvents } from '~/core/llm/state.js'
3
4
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
4
5
  import { selectPluginState } from '~/core/sessions/reducer.js'
5
6
  import type { SessionState } from '~/core/sessions/state.js'
6
7
  import { toolEvents } from '~/core/tools/state.js'
8
+ import { userChatEvents } from '~/plugins/user-chat/plugin.js'
7
9
 
8
10
  // ============================================================================
9
11
  // Session stats state
@@ -17,35 +19,57 @@ export interface ProviderStats {
17
19
  totalCost: number
18
20
  }
19
21
 
22
+ export interface AgentStats {
23
+ llmCalls: number
24
+ promptTokens: number
25
+ completionTokens: number
26
+ totalCost: number
27
+ }
28
+
20
29
  export interface SessionStatsState {
21
30
  totalTokens: number
22
31
  promptTokens: number
23
32
  completionTokens: number
33
+ /** Prompt tokens served from the provider cache (a subset of promptTokens) */
34
+ cacheReadTokens: number
35
+ /** Prompt tokens written to the provider cache */
36
+ cacheWriteTokens: number
24
37
  totalCost: number
25
38
  llmCalls: number
26
39
  llmErrors: number
27
40
  toolCalls: number
28
41
  toolErrors: number
42
+ /** Chat messages received from the user */
43
+ userMessages: number
44
+ /** Context compactions performed in this session */
45
+ compactions: number
29
46
  agentCount: number
30
47
  firstEventAt: number | null
31
48
  lastEventAt: number | null
32
49
  /** Per-provider breakdown of LLM usage */
33
50
  byProvider: Record<string, ProviderStats>
51
+ /** Per agent-definition breakdown of LLM usage (keyed by definition name, not agent id) */
52
+ byAgent: Record<string, AgentStats>
34
53
  }
35
54
 
36
55
  const createInitialStats = (): SessionStatsState => ({
37
56
  totalTokens: 0,
38
57
  promptTokens: 0,
39
58
  completionTokens: 0,
59
+ cacheReadTokens: 0,
60
+ cacheWriteTokens: 0,
40
61
  totalCost: 0,
41
62
  llmCalls: 0,
42
63
  llmErrors: 0,
43
64
  toolCalls: 0,
44
65
  toolErrors: 0,
66
+ userMessages: 0,
67
+ compactions: 0,
45
68
  agentCount: 0,
46
69
  firstEventAt: null,
47
70
  lastEventAt: null,
48
71
  byProvider: {},
72
+ byAgent: {},
49
73
  })
50
74
 
51
75
  const PLUGIN_STATE_KEY = 'sessionStats'
@@ -62,11 +86,11 @@ export function selectSessionStats(sessionState: SessionState): SessionStatsStat
62
86
  // ============================================================================
63
87
 
64
88
  export const sessionStatsPlugin = definePlugin('session-stats')
65
- .events([agentEvents, llmEvents, toolEvents])
89
+ .events([agentEvents, llmEvents, toolEvents, contextEvents, userChatEvents])
66
90
  .state({
67
91
  key: PLUGIN_STATE_KEY,
68
92
  initial: createInitialStats,
69
- reduce: (stats, event) => {
93
+ reduce: (stats, event, sessionState) => {
70
94
  const now = event.timestamp
71
95
  const withTimestamp = (partial: Partial<SessionStatsState>): SessionStatsState => ({
72
96
  ...stats,
@@ -97,13 +121,29 @@ export const sessionStatsPlugin = definePlugin('session-stats')
97
121
  },
98
122
  }
99
123
  : stats.byProvider
124
+ // Core reducer runs before plugin slices, so the spawning agent is already in state.
125
+ const definitionName = sessionState.agents.get(event.agentId)?.definitionName
126
+ const byAgent = definitionName
127
+ ? {
128
+ ...stats.byAgent,
129
+ [definitionName]: {
130
+ llmCalls: (stats.byAgent[definitionName]?.llmCalls ?? 0) + 1,
131
+ promptTokens: (stats.byAgent[definitionName]?.promptTokens ?? 0) + event.metrics.promptTokens,
132
+ completionTokens: (stats.byAgent[definitionName]?.completionTokens ?? 0) + event.metrics.completionTokens,
133
+ totalCost: (stats.byAgent[definitionName]?.totalCost ?? 0) + (event.metrics.cost ?? 0),
134
+ },
135
+ }
136
+ : stats.byAgent
100
137
  return withTimestamp({
101
138
  llmCalls: stats.llmCalls + 1,
102
139
  totalTokens: stats.totalTokens + event.metrics.totalTokens,
103
140
  promptTokens: stats.promptTokens + event.metrics.promptTokens,
104
141
  completionTokens: stats.completionTokens + event.metrics.completionTokens,
142
+ cacheReadTokens: stats.cacheReadTokens + (event.metrics.cachedTokens ?? 0),
143
+ cacheWriteTokens: stats.cacheWriteTokens + (event.metrics.cacheWriteTokens ?? 0),
105
144
  totalCost: stats.totalCost + (event.metrics.cost ?? 0),
106
145
  byProvider,
146
+ byAgent,
107
147
  })
108
148
  }
109
149
 
@@ -116,6 +156,12 @@ export const sessionStatsPlugin = definePlugin('session-stats')
116
156
  case 'tool_failed':
117
157
  return withTimestamp({ toolErrors: stats.toolErrors + 1 })
118
158
 
159
+ case 'user_chat_message_received':
160
+ return withTimestamp({ userMessages: stats.userMessages + 1 })
161
+
162
+ case 'context_compacted':
163
+ return withTimestamp({ compactions: stats.compactions + 1 })
164
+
119
165
  default:
120
166
  return stats
121
167
  }