@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.
- package/dist/core/agents/agent.d.ts +5 -1
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/config.d.ts +5 -1
- package/dist/core/agents/config.d.ts.map +1 -1
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +7 -1
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.d.ts +39 -5
- package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
- package/dist/core/llm/cache-breakpoints.js +22 -8
- package/dist/core/llm/cache-breakpoints.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.test.js +32 -8
- package/dist/core/llm/cache-breakpoints.test.js.map +1 -1
- package/dist/core/llm/openrouter-mapping.test.js +29 -0
- package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
- package/dist/core/llm/openrouter.d.ts.map +1 -1
- package/dist/core/llm/openrouter.js +28 -8
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/result-eviction/plugin.d.ts.map +1 -1
- package/dist/plugins/result-eviction/plugin.js +24 -9
- package/dist/plugins/result-eviction/plugin.js.map +1 -1
- package/dist/plugins/result-eviction/result-eviction.integration.test.js +77 -0
- package/dist/plugins/result-eviction/result-eviction.integration.test.js.map +1 -1
- package/dist/plugins/session-stats/index.d.ts +1 -1
- package/dist/plugins/session-stats/index.d.ts.map +1 -1
- package/dist/plugins/session-stats/plugin.d.ts +16 -0
- package/dist/plugins/session-stats/plugin.d.ts.map +1 -1
- package/dist/plugins/session-stats/plugin.js +29 -2
- package/dist/plugins/session-stats/plugin.js.map +1 -1
- package/dist/plugins/session-stats/plugin.test.d.ts +2 -0
- package/dist/plugins/session-stats/plugin.test.d.ts.map +1 -0
- package/dist/plugins/session-stats/plugin.test.js +145 -0
- package/dist/plugins/session-stats/plugin.test.js.map +1 -0
- package/dist/transport/http/middleware/error-handler.d.ts +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +5 -2
- package/src/core/agents/config.ts +5 -1
- package/src/core/llm/anthropic.ts +7 -1
- package/src/core/llm/cache-breakpoints.test.ts +38 -8
- package/src/core/llm/cache-breakpoints.ts +54 -9
- package/src/core/llm/openrouter-mapping.test.ts +37 -1
- package/src/core/llm/openrouter.ts +35 -9
- package/src/index.ts +2 -1
- package/src/plugins/result-eviction/plugin.ts +26 -11
- package/src/plugins/result-eviction/result-eviction.integration.test.ts +88 -0
- package/src/plugins/session-stats/index.ts +1 -1
- package/src/plugins/session-stats/plugin.test.ts +160 -0
- 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
|
-
|
|
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
|
|
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,
|
|
67
|
+
await ctx.files.session.write(filePath, text)
|
|
54
68
|
|
|
55
|
-
const
|
|
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:
|
|
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()],
|
|
@@ -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
|
}
|