@roj-ai/sdk 0.1.32 → 0.1.34
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/bootstrap.d.ts +3 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +5 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/core/sessions/session-manager.d.ts +3 -0
- package/dist/core/sessions/session-manager.d.ts.map +1 -1
- package/dist/core/sessions/session-manager.js +3 -1
- package/dist/core/sessions/session-manager.js.map +1 -1
- package/dist/core/system.d.ts +2 -0
- package/dist/core/system.d.ts.map +1 -1
- package/dist/core/system.js +2 -1
- package/dist/core/system.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugins/services/pid-registry.d.ts +72 -0
- package/dist/plugins/services/pid-registry.d.ts.map +1 -0
- package/dist/plugins/services/pid-registry.js +171 -0
- package/dist/plugins/services/pid-registry.js.map +1 -0
- package/dist/plugins/services/pid-registry.test.d.ts +2 -0
- package/dist/plugins/services/pid-registry.test.d.ts.map +1 -0
- package/dist/plugins/services/pid-registry.test.js +161 -0
- package/dist/plugins/services/pid-registry.test.js.map +1 -0
- package/dist/plugins/services/plugin.d.ts +3 -0
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +5 -1
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/service.d.ts +4 -0
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js +8 -0
- package/dist/plugins/services/service.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/bootstrap.ts +7 -0
- package/src/core/sessions/session-manager.ts +5 -1
- package/src/core/system.ts +4 -0
- package/src/index.ts +1 -1
- package/src/plugins/services/pid-registry.test.ts +180 -0
- package/src/plugins/services/pid-registry.ts +191 -0
- package/src/plugins/services/plugin.ts +8 -1
- package/src/plugins/services/service.ts +12 -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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServicePidRegistry — on-disk record of the service processes this host has spawned,
|
|
3
|
+
* so a later agent can find and kill the ones nobody owns any more.
|
|
4
|
+
*
|
|
5
|
+
* The session projection cannot do this job. It remembers one pid per service type, so
|
|
6
|
+
* every start overwrites the previous generation, and its reconcile runs from
|
|
7
|
+
* `onSessionReady` — which fires lazily, when a session is loaded. A session nobody
|
|
8
|
+
* reopens after a restart therefore never reconciles at all, and its dev server keeps
|
|
9
|
+
* running until the box runs out of memory. That is not theoretical: a production
|
|
10
|
+
* sandbox on 2026-08-05 held four dev servers at ~500 MB each across three sessions on
|
|
11
|
+
* 4 GB with no swap, and the kernel OOM-killer fired repeatedly.
|
|
12
|
+
*
|
|
13
|
+
* A file per (session, service type) survives both an agent restart and a VM
|
|
14
|
+
* pause/resume, and is enumerable without knowing which sessions exist — which is what
|
|
15
|
+
* makes a host-wide sweep at boot possible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import type { FileSystem } from '~/platform/fs.js'
|
|
20
|
+
import type { Logger } from '../../lib/logger/logger.js'
|
|
21
|
+
import { getProcessStartTime } from './service.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What we need in order to decide, later and from another process, whether a recorded
|
|
25
|
+
* pid may be killed.
|
|
26
|
+
*
|
|
27
|
+
* `pidStartTime` is /proc's start time in clock ticks: paired with the pid it identifies
|
|
28
|
+
* one process, so a mismatch means the kernel recycled the pid and killing would hit an
|
|
29
|
+
* unrelated process. `agentPid`/`agentStartTime` identify the agent that spawned it — a
|
|
30
|
+
* booting agent must not kill the services of another agent that is still running.
|
|
31
|
+
*/
|
|
32
|
+
export interface ServicePidRecord {
|
|
33
|
+
sessionId: string
|
|
34
|
+
serviceType: string
|
|
35
|
+
pid: number
|
|
36
|
+
pidStartTime?: number
|
|
37
|
+
command: string
|
|
38
|
+
agentPid: number
|
|
39
|
+
agentStartTime?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REGISTRY_DIR = 'service-pids'
|
|
43
|
+
|
|
44
|
+
export class ServicePidRegistry {
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly fs: FileSystem,
|
|
47
|
+
private readonly logger: Logger,
|
|
48
|
+
/** Agent data directory. Never the session workspace — that is a git worktree. */
|
|
49
|
+
private readonly dataPath: string,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
private get root(): string {
|
|
53
|
+
return join(this.dataPath, REGISTRY_DIR)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private recordPath(sessionId: string, serviceType: string): string {
|
|
57
|
+
// Service types are plugin-configured identifiers (`astro-dev`, `cms-sidecar`) and
|
|
58
|
+
// session ids are uuids, so neither needs escaping to be a safe file name.
|
|
59
|
+
return join(this.root, `${sessionId}__${serviceType}.json`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Record a freshly spawned service process. Best-effort: failing to record costs us a
|
|
64
|
+
* later sweep, so it must never take down the start that just succeeded.
|
|
65
|
+
*/
|
|
66
|
+
async record(record: Omit<ServicePidRecord, 'agentPid' | 'agentStartTime'>): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
await this.fs.mkdir(this.root, { recursive: true })
|
|
69
|
+
const full: ServicePidRecord = {
|
|
70
|
+
...record,
|
|
71
|
+
agentPid: process.pid,
|
|
72
|
+
agentStartTime: await getProcessStartTime(this.fs, process.pid),
|
|
73
|
+
}
|
|
74
|
+
await this.fs.writeFile(this.recordPath(record.sessionId, record.serviceType), JSON.stringify(full))
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.logger.debug('Could not record service pid', { sessionId: record.sessionId, serviceType: record.serviceType, error })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drop the record for a service whose process is gone. */
|
|
81
|
+
async forget(sessionId: string, serviceType: string): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
await this.fs.unlink(this.recordPath(sessionId, serviceType))
|
|
84
|
+
} catch {
|
|
85
|
+
// Already gone, or never recorded.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Kill every recorded service process that no live agent owns, and clear its record.
|
|
91
|
+
*
|
|
92
|
+
* Meant to run once at agent boot, before any session loads: at that moment this agent
|
|
93
|
+
* tracks nothing, so every survivor of a previous incarnation is by definition an
|
|
94
|
+
* orphan. Records left by an agent that is still running are skipped — during an
|
|
95
|
+
* overlapping restart the outgoing agent's services are still its own.
|
|
96
|
+
*/
|
|
97
|
+
async sweepOrphans(): Promise<void> {
|
|
98
|
+
let files: string[]
|
|
99
|
+
try {
|
|
100
|
+
files = await this.fs.readdir(this.root)
|
|
101
|
+
} catch {
|
|
102
|
+
return // No registry yet — nothing has ever been spawned here.
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
if (!file.endsWith('.json')) continue
|
|
107
|
+
const path = join(this.root, file)
|
|
108
|
+
const record = await this.readRecord(path)
|
|
109
|
+
if (!record) {
|
|
110
|
+
await this.fs.unlink(path).catch(() => {})
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (await this.isAgentAlive(record)) {
|
|
115
|
+
this.logger.debug('Leaving service to its still-running agent', {
|
|
116
|
+
sessionId: record.sessionId,
|
|
117
|
+
serviceType: record.serviceType,
|
|
118
|
+
agentPid: record.agentPid,
|
|
119
|
+
})
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await this.killIfStillOurs(record)
|
|
124
|
+
await this.fs.unlink(path).catch(() => {})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async readRecord(path: string): Promise<ServicePidRecord | null> {
|
|
129
|
+
try {
|
|
130
|
+
const parsed: unknown = JSON.parse(await this.fs.readFile(path, 'utf-8'))
|
|
131
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
132
|
+
const record = parsed as Partial<ServicePidRecord>
|
|
133
|
+
if (typeof record.pid !== 'number' || typeof record.sessionId !== 'string' || typeof record.serviceType !== 'string') {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
sessionId: record.sessionId,
|
|
138
|
+
serviceType: record.serviceType,
|
|
139
|
+
pid: record.pid,
|
|
140
|
+
pidStartTime: record.pidStartTime,
|
|
141
|
+
command: typeof record.command === 'string' ? record.command : '',
|
|
142
|
+
agentPid: typeof record.agentPid === 'number' ? record.agentPid : 0,
|
|
143
|
+
agentStartTime: record.agentStartTime,
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a live agent still owns this record. Ourselves counts: a record carrying our own
|
|
152
|
+
* pid describes a service this very process started, which the sweep must never touch —
|
|
153
|
+
* that is what makes the sweep safe to run at any time, not only before the first start.
|
|
154
|
+
*/
|
|
155
|
+
private async isAgentAlive(record: ServicePidRecord): Promise<boolean> {
|
|
156
|
+
if (record.agentPid === process.pid) return true
|
|
157
|
+
if (record.agentPid === 0) return false
|
|
158
|
+
const currentStartTime = await getProcessStartTime(this.fs, record.agentPid)
|
|
159
|
+
if (currentStartTime === undefined) return false
|
|
160
|
+
return record.agentStartTime === undefined || currentStartTime === record.agentStartTime
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async killIfStillOurs(record: ServicePidRecord): Promise<void> {
|
|
164
|
+
const currentStartTime = await getProcessStartTime(this.fs, record.pid)
|
|
165
|
+
if (currentStartTime === undefined) return // Process is gone.
|
|
166
|
+
if (record.pidStartTime !== undefined && currentStartTime !== record.pidStartTime) {
|
|
167
|
+
this.logger.warn('PID reuse detected during orphan sweep — refusing to kill', {
|
|
168
|
+
sessionId: record.sessionId,
|
|
169
|
+
serviceType: record.serviceType,
|
|
170
|
+
pid: record.pid,
|
|
171
|
+
storedStartTime: record.pidStartTime,
|
|
172
|
+
currentStartTime,
|
|
173
|
+
})
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
// Negative pid: services are spawned detached, so this takes the whole group —
|
|
179
|
+
// the `/bin/sh -c` wrapper and the runtime it exec'd underneath it.
|
|
180
|
+
process.kill(-record.pid, 'SIGKILL')
|
|
181
|
+
this.logger.info('Killed orphaned service process group', {
|
|
182
|
+
sessionId: record.sessionId,
|
|
183
|
+
serviceType: record.serviceType,
|
|
184
|
+
pid: record.pid,
|
|
185
|
+
command: record.command,
|
|
186
|
+
})
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.logger.debug('Orphaned service process already gone', { pid: record.pid, error })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -8,6 +8,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
|
|
|
8
8
|
import type { PortPool } from './port-pool.js'
|
|
9
9
|
import { buildServiceStatusMessage } from './prompt.js'
|
|
10
10
|
import type { ServiceConfig, ServiceEntry } from './schema.js'
|
|
11
|
+
import type { ServicePidRegistry } from './pid-registry.js'
|
|
11
12
|
import { getProcessStartTime, ServiceExecutor } from './service.js'
|
|
12
13
|
|
|
13
14
|
export const serviceEvents = createEventsFactory({
|
|
@@ -33,6 +34,8 @@ export type ServiceStatusChangedEvent = (typeof serviceEvents)['Events']['servic
|
|
|
33
34
|
export interface ServicePluginConfig {
|
|
34
35
|
services: ServiceConfig[]
|
|
35
36
|
portPool: PortPool
|
|
37
|
+
/** Durable pid record swept at agent boot. Absent for embedders without a data dir. */
|
|
38
|
+
pidRegistry?: ServicePidRegistry
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -133,7 +136,11 @@ export const servicePlugin = definePlugin('services')
|
|
|
133
136
|
},
|
|
134
137
|
})
|
|
135
138
|
.context(async (ctx, pluginConfig) => {
|
|
136
|
-
const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, {
|
|
139
|
+
const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, {
|
|
140
|
+
fs: ctx.platform.fs,
|
|
141
|
+
process: ctx.platform.process,
|
|
142
|
+
pidRegistry: pluginConfig.pidRegistry,
|
|
143
|
+
})
|
|
137
144
|
executor.onStatusChanged = (sessionId, serviceType, status, details) => {
|
|
138
145
|
void ctx.emitEvent(serviceEvents.create('service_status_changed', {
|
|
139
146
|
serviceType,
|
|
@@ -13,6 +13,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
|
|
|
13
13
|
import type { FileSystem } from '~/platform/fs.js'
|
|
14
14
|
import type { ChildProcess, ProcessRunner } from '~/platform/process.js'
|
|
15
15
|
import type { PortPool } from '~/plugins/services/port-pool.js'
|
|
16
|
+
import type { ServicePidRegistry } from '~/plugins/services/pid-registry.js'
|
|
16
17
|
import type { ServiceConfig, ServiceStartArgs, ServiceStatus } from '~/plugins/services/schema.js'
|
|
17
18
|
import type { ToolError } from '../../core/tools/executor.js'
|
|
18
19
|
import type { Logger } from '../../lib/logger/logger.js'
|
|
@@ -89,6 +90,8 @@ const MAX_PORT_CONFLICT_RETRIES = 3
|
|
|
89
90
|
export interface ServiceExecutorDeps {
|
|
90
91
|
fs: FileSystem
|
|
91
92
|
process: ProcessRunner
|
|
93
|
+
/** Durable pid record, swept at agent boot. Optional so embedders without a data dir still work. */
|
|
94
|
+
pidRegistry?: ServicePidRegistry
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
export class ServiceExecutor {
|
|
@@ -103,6 +106,7 @@ export class ServiceExecutor {
|
|
|
103
106
|
private readonly portPool: PortPool
|
|
104
107
|
private readonly fs: FileSystem
|
|
105
108
|
private readonly processRunner: ProcessRunner
|
|
109
|
+
private readonly pidRegistry?: ServicePidRegistry
|
|
106
110
|
|
|
107
111
|
/** Optional callback invoked on every service status change */
|
|
108
112
|
onStatusChanged?: (
|
|
@@ -117,6 +121,7 @@ export class ServiceExecutor {
|
|
|
117
121
|
this.portPool = portPool
|
|
118
122
|
this.fs = deps.fs
|
|
119
123
|
this.processRunner = deps.process
|
|
124
|
+
this.pidRegistry = deps.pidRegistry
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
private notifyStatusChanged(
|
|
@@ -354,6 +359,11 @@ export class ServiceExecutor {
|
|
|
354
359
|
// "our process" from "an unrelated process that grabbed this PID after ours died"
|
|
355
360
|
const pidStartTime = await getProcessStartTime(this.fs, child.pid)
|
|
356
361
|
|
|
362
|
+
// Durably record the process so a future agent can reap it if we die before the
|
|
363
|
+
// `close` handler gets to forget it. The session projection cannot serve that
|
|
364
|
+
// purpose — see ServicePidRegistry.
|
|
365
|
+
await this.pidRegistry?.record({ sessionId: String(sessionId), serviceType: config.type, pid: child.pid, pidStartTime, command })
|
|
366
|
+
|
|
357
367
|
// Emit starting event with PID, port, resolved cwd/command, and start time.
|
|
358
368
|
this.notifyStatusChanged(sessionId, config.type, 'starting', { port, pid: child.pid, pidStartTime, cwd, command })
|
|
359
369
|
|
|
@@ -512,6 +522,8 @@ export class ServiceExecutor {
|
|
|
512
522
|
// Handle unexpected exit
|
|
513
523
|
child.on('close', (code) => {
|
|
514
524
|
clearReadinessTimers()
|
|
525
|
+
// The process is gone, so its durable record has nothing left to reap.
|
|
526
|
+
void this.pidRegistry?.forget(String(sessionId), config.type)
|
|
515
527
|
// Flush remaining partial lines
|
|
516
528
|
if (stdoutPartial) {
|
|
517
529
|
processLine(stdoutPartial)
|
|
@@ -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
|
}
|