Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import type { z } from 'zod'
import type { SessionManager } from '../session/session-manager.js'
import { Tracer } from '../telemetry/tracer.js'
import { Meter } from '../telemetry/meter.js'
import type { InvocationMetricsData } from '../telemetry/meter.js'
import type { AttributeValue } from '@opentelemetry/api'
import { logger } from '../logging/logger.js'

Expand Down Expand Up @@ -154,6 +155,18 @@ export type AgentConfig = {
* Optional unique identifier for the agent. Defaults to "agent".
*/
id?: string
/**
* Maximum number of invocation history entries to retain.
* When exceeded, oldest entries are evicted. Defaults to Infinity (unbounded).
* Set to a finite value for long-lived agents to prevent memory growth.
*/
maxInvocationHistory?: number
/**
* Called with evicted invocation entries before removal.
* Invoked fire-and-forget; errors are caught and ignored.
* Use to persist evicted history to a database or metrics backend.
*/
onInvocationHistoryFlush?: (evicted: InvocationMetricsData[]) => void | Promise<void>
}

/** Default name assigned to agents when none is provided. */
Expand Down Expand Up @@ -270,7 +283,10 @@ export class Agent implements LocalAgent, InvokableAgent {
this._tracer = new Tracer(config?.traceAttributes)

// Initialize meter for local metrics accumulation
this._meter = new Meter()
this._meter = new Meter({
maxInvocationHistory: config?.maxInvocationHistory,
onInvocationHistoryFlush: config?.onInvocationHistoryFlush,
})

this._initialized = false
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export { AgentTrace } from './telemetry/tracer.js'

// Local Metrics
export { AgentMetrics } from './telemetry/meter.js'
export type { MeterConfig, InvocationMetricsData } from './telemetry/meter.js'

// Multi-agent orchestration
export { Graph } from './multiagent/index.js'
Expand Down
93 changes: 92 additions & 1 deletion src/telemetry/__tests__/meter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { metrics as otelMetrics, type Meter as OtelMeter } from '@opentelemetry/api'
import { Meter, AgentMetrics } from '../meter.js'
import { Meter, AgentMetrics, type InvocationMetricsData } from '../meter.js'
import { MockMeter } from '../../__fixtures__/mock-meter.js'
import type { ToolUse } from '../../tools/types.js'

Expand Down Expand Up @@ -560,6 +560,8 @@ describe('AgentMetrics', () => {
accumulatedMetrics: { latencyMs: 0 },
agentInvocations: [],
toolMetrics: {},
totalCycleDurationMs: 0,
totalCycleCount: 0,
})
})

Expand All @@ -569,6 +571,8 @@ describe('AgentMetrics', () => {
toolMetrics: {
search: { callCount: 2, successCount: 1, errorCount: 1, totalTime: 2.0 },
},
totalCycleDurationMs: 0,
totalCycleCount: 0,
accumulatedUsage: { inputTokens: 30, outputTokens: 15, totalTokens: 45 },
accumulatedMetrics: { latencyMs: 350 },
agentInvocations: [
Expand Down Expand Up @@ -598,6 +602,8 @@ describe('AgentMetrics', () => {
toolMetrics: {
search: { callCount: 2, successCount: 1, errorCount: 1, totalTime: 2.0 },
},
totalCycleDurationMs: 0,
totalCycleCount: 0,
})
})
})
Expand All @@ -622,6 +628,8 @@ describe('AgentMetrics', () => {
search: { callCount: 2, successCount: 2, errorCount: 0, totalTime: 1.5 },
calc: { callCount: 1, successCount: 0, errorCount: 1, totalTime: 0.3 },
},
totalCycleDurationMs: 0,
totalCycleCount: 0,
})

const json = JSON.stringify(original)
Expand Down Expand Up @@ -701,6 +709,8 @@ describe('AgentMetrics', () => {
toolMetrics: {
search: { callCount: 2, successCount: 1, errorCount: 1, totalTime: 2.0 },
},
totalCycleDurationMs: 0,
totalCycleCount: 0,
})

expect(metrics.toolUsage).toStrictEqual({
Expand All @@ -720,6 +730,8 @@ describe('AgentMetrics', () => {
toolMetrics: {
broken: { callCount: 0, successCount: 0, errorCount: 0, totalTime: 0 },
},
totalCycleDurationMs: 0,
totalCycleCount: 0,
})

expect(metrics.toolUsage).toStrictEqual({
Expand All @@ -740,3 +752,82 @@ describe('AgentMetrics', () => {
})
})
})

describe('bounded invocation history', () => {
it('evicts oldest entries when maxInvocationHistory is exceeded', () => {
const meter = new Meter({ maxInvocationHistory: 2 })

meter.startNewInvocation() // inv 1
const { startTime: s1 } = meter.startCycle()
meter.endCycle(s1 - 100)

meter.startNewInvocation() // inv 2
const { startTime: s2 } = meter.startCycle()
meter.endCycle(s2 - 200)

meter.startNewInvocation() // inv 3 — should evict inv 1

const { agentInvocations } = meter.metrics
expect(agentInvocations).toHaveLength(2)
// inv 1 (with ~100ms cycle) was evicted; remaining are inv 2 and inv 3
})

it('calls onInvocationHistoryFlush with evicted entries', () => {
const flushed: InvocationMetricsData[][] = []
const meter = new Meter({
maxInvocationHistory: 1,
onInvocationHistoryFlush: (evicted) => {
flushed.push(evicted)
},
})

meter.startNewInvocation() // inv 1
meter.startNewInvocation() // inv 2 — evicts inv 1

expect(flushed).toHaveLength(1)
expect(flushed[0]).toHaveLength(1)
})

it('preserves lifetime totalDuration after eviction', () => {
const meter = new Meter({ maxInvocationHistory: 1 })

meter.startNewInvocation()
const { startTime: s1 } = meter.startCycle()
meter.endCycle(s1 - 1000) // ~1000ms cycle

meter.startNewInvocation() // evicts first invocation
const { startTime: s2 } = meter.startCycle()
meter.endCycle(s2 - 500) // ~500ms cycle

const metrics = meter.metrics
// totalDuration reflects ALL cycles, not just retained ones
expect(metrics.totalDuration).toBeGreaterThanOrEqual(1400)
// Only 1 invocation retained
expect(metrics.agentInvocations).toHaveLength(1)
})

it('defaults to unbounded when no config is provided', () => {
const meter = new Meter()

for (let i = 0; i < 100; i++) {
meter.startNewInvocation()
}

expect(meter.metrics.agentInvocations).toHaveLength(100)
})

it('swallows errors from onInvocationHistoryFlush', () => {
const meter = new Meter({
maxInvocationHistory: 1,
onInvocationHistoryFlush: () => {
throw new Error('flush failed')
},
})

// Should not throw
expect(() => {
meter.startNewInvocation()
meter.startNewInvocation()
}).not.toThrow()
})
})
93 changes: 92 additions & 1 deletion src/telemetry/meter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ export interface AgentMetricsData {
* Per-tool execution metrics keyed by tool name.
*/
toolMetrics: Record<string, ToolMetricsData>

/**
* Lifetime total duration of all cycles in milliseconds (survives eviction).
*/
totalCycleDurationMs?: number

/**
* Lifetime total number of cycles (survives eviction).
*/
totalCycleCount?: number
}

/**
Expand Down Expand Up @@ -171,12 +181,24 @@ export class AgentMetrics implements JSONSerializable<AgentMetricsData> {
*/
readonly toolMetrics: Record<string, ToolMetricsData>

/**
* @internal Lifetime total cycle duration (survives eviction).
*/
private readonly _totalCycleDurationMs: number

/**
* @internal Lifetime total cycle count (survives eviction).
*/
private readonly _totalCycleCount: number

constructor(data?: Partial<AgentMetricsData>) {
this.cycleCount = data?.cycleCount ?? 0
this.accumulatedUsage = data?.accumulatedUsage ?? createEmptyUsage()
this.accumulatedMetrics = data?.accumulatedMetrics ?? { latencyMs: 0 }
this.agentInvocations = data?.agentInvocations ?? []
this.toolMetrics = data?.toolMetrics ?? {}
this._totalCycleDurationMs = data?.totalCycleDurationMs ?? 0
this._totalCycleCount = data?.totalCycleCount ?? 0
}

/**
Expand All @@ -197,13 +219,21 @@ export class AgentMetrics implements JSONSerializable<AgentMetricsData> {
* Total duration of all cycles in milliseconds.
*/
get totalDuration(): number {
if (this._totalCycleDurationMs > 0) {
return this._totalCycleDurationMs
}
// Fallback for metrics constructed without lifetime scalars
return this.agentInvocations.flatMap((inv) => inv.cycles.map((c) => c.duration)).reduce((sum, d) => sum + d, 0)
}

/**
* Average cycle duration in milliseconds, or 0 if no cycles exist.
*/
get averageCycleTime(): number {
if (this._totalCycleCount > 0) {
return this._totalCycleDurationMs / this._totalCycleCount
}
// Fallback for metrics constructed without lifetime scalars
const durations = this.agentInvocations.flatMap((inv) => inv.cycles.map((c) => c.duration))
return durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
}
Expand Down Expand Up @@ -236,6 +266,8 @@ export class AgentMetrics implements JSONSerializable<AgentMetricsData> {
accumulatedMetrics: this.accumulatedMetrics,
agentInvocations: this.agentInvocations,
toolMetrics: this.toolMetrics,
totalCycleDurationMs: this._totalCycleDurationMs,
totalCycleCount: this._totalCycleCount,
}
}
}
Expand All @@ -251,6 +283,24 @@ export class AgentMetrics implements JSONSerializable<AgentMetricsData> {
* as OTEL counters and histograms via the global metrics API. If no
* provider is registered the OTEL meter is a no-op and adds no overhead.
*/

/**
* Configuration options for the Meter.
*/
export interface MeterConfig {
/**
* Maximum number of invocation history entries to retain.
* When exceeded, oldest entries are evicted. Defaults to Infinity.
*/
maxInvocationHistory?: number | undefined

/**
* Called with evicted invocation entries before removal.
* Invoked fire-and-forget; errors are caught and ignored.
*/
onInvocationHistoryFlush?: ((evicted: InvocationMetricsData[]) => void | Promise<void>) | undefined
}

export class Meter {
/**
* Number of agent loop cycles executed.
Expand All @@ -277,6 +327,26 @@ export class Meter {
*/
private readonly _toolMetrics: Record<string, ToolMetricsData> = {}

/**
* Maximum invocation history entries to retain.
*/
private readonly _maxInvocationHistory: number

/**
* Callback for evicted invocation entries.
*/
private readonly _onInvocationHistoryFlush?: ((evicted: InvocationMetricsData[]) => void | Promise<void>) | undefined

/**
* Running total of all cycle durations (survives eviction).
*/
private _totalCycleDurationMs: number = 0

/**
* Running total of all cycles (survives eviction).
*/
private _totalCycleCount: number = 0

// OTEL instruments (no-op when no MeterProvider is registered)
private readonly _otelMeter: OtelMeter
private readonly _otelCycleCounter: Counter
Expand All @@ -290,7 +360,9 @@ export class Meter {
private readonly _otelModelLatency: Histogram
private readonly _otelTimeToFirstToken: Histogram

constructor() {
constructor(config?: MeterConfig) {
this._maxInvocationHistory = config?.maxInvocationHistory ?? Infinity
this._onInvocationHistoryFlush = config?.onInvocationHistoryFlush
this._otelMeter = otelMetrics.getMeter(getServiceName())

this._otelCycleCounter = this._otelMeter.createCounter('gen_ai.agent.cycle.count', {
Expand Down Expand Up @@ -340,6 +412,21 @@ export class Meter {
usage: createEmptyUsage(),
})
this._otelInvocationCounter.add(1)

// Evict oldest entries if over the cap
if (this._maxInvocationHistory < Infinity && this._agentInvocations.length > this._maxInvocationHistory) {
const evictCount = this._agentInvocations.length - this._maxInvocationHistory
const evicted = this._agentInvocations.splice(0, evictCount)

if (this._onInvocationHistoryFlush && evicted.length > 0) {
try {
// Fire-and-forget — never block the agent loop
void Promise.resolve(this._onInvocationHistoryFlush(evicted)).catch(() => {})
} catch {
// Swallow synchronous errors from the callback
}
}
}
}

/**
Expand Down Expand Up @@ -374,6 +461,8 @@ export class Meter {
endCycle(startTime: number): void {
const duration = Date.now() - startTime
this._otelCycleDuration.record(duration)
this._totalCycleDurationMs += duration
this._totalCycleCount++

const latestInvocation = this._latestAgentInvocation
if (latestInvocation) {
Expand Down Expand Up @@ -438,6 +527,8 @@ export class Meter {
accumulatedMetrics: this._accumulatedMetrics,
agentInvocations: this._agentInvocations,
toolMetrics: this._toolMetrics,
totalCycleDurationMs: this._totalCycleDurationMs,
totalCycleCount: this._totalCycleCount,
})
}

Expand Down
Loading