diff --git a/src/telemetry/__tests__/meter.test.ts b/src/telemetry/__tests__/meter.test.ts index 4619adfc..fc0cb056 100644 --- a/src/telemetry/__tests__/meter.test.ts +++ b/src/telemetry/__tests__/meter.test.ts @@ -272,6 +272,80 @@ describe('Meter', () => { }) }) + describe('latestContextSize', () => { + it('is undefined when no invocations have occurred', () => { + expect(meter.metrics.latestContextSize).toBeUndefined() + }) + + it('returns the most recent inputTokens after a model call', () => { + meter.updateCycle({ + type: 'modelMetadataEvent', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }) + + expect(meter.metrics.latestContextSize).toBe(100) + }) + + it('updates across multiple cycles', () => { + meter.updateCycle({ + type: 'modelMetadataEvent', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }) + meter.updateCycle({ + type: 'modelMetadataEvent', + usage: { inputTokens: 200, outputTokens: 80, totalTokens: 280 }, + }) + + expect(meter.metrics.latestContextSize).toBe(200) + }) + + it('updates across multiple invocations', () => { + meter.startNewInvocation() + meter.startCycle() + meter.updateCycle({ + type: 'modelMetadataEvent', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }) + + meter.startNewInvocation() + meter.startCycle() + meter.updateCycle({ + type: 'modelMetadataEvent', + usage: { inputTokens: 300, outputTokens: 100, totalTokens: 400 }, + }) + + expect(meter.metrics.latestContextSize).toBe(300) + }) + + it('resets to undefined when a new invocation has no metadata', () => { + meter.startNewInvocation() + meter.startCycle() + meter.updateCycle({ + type: 'modelMetadataEvent', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }) + + meter.startNewInvocation() + + expect(meter.metrics.latestContextSize).toBeUndefined() + }) + + it('remains undefined when metadata has no usage', () => { + meter.updateCycle({ + type: 'modelMetadataEvent', + metrics: { latencyMs: 100 }, + }) + + expect(meter.metrics.latestContextSize).toBeUndefined() + }) + + it('remains undefined when updateCycle is called with undefined', () => { + meter.updateCycle(undefined) + + expect(meter.metrics.latestContextSize).toBeUndefined() + }) + }) + describe('updateCycle', () => { it('accumulates usage and latency from metadata', () => { meter.updateCycle({ @@ -600,6 +674,16 @@ describe('AgentMetrics', () => { }, }) }) + + it('includes latestContextSize when set', () => { + const metrics = new AgentMetrics({ latestContextSize: 42 }) + expect(metrics.toJSON()).toHaveProperty('latestContextSize', 42) + }) + + it('omits latestContextSize when undefined', () => { + const metrics = new AgentMetrics() + expect(metrics.toJSON()).not.toHaveProperty('latestContextSize') + }) }) describe('toJSON roundtrip', () => { diff --git a/src/telemetry/meter.ts b/src/telemetry/meter.ts index 808a983a..3b740d4d 100644 --- a/src/telemetry/meter.ts +++ b/src/telemetry/meter.ts @@ -106,6 +106,12 @@ export interface AgentMetricsData { * Per-tool execution metrics keyed by tool name. */ toolMetrics: Record + + /** + * The most recent input token count from the last model invocation. + * Represents the current context window utilization. + */ + latestContextSize?: number } /** @@ -171,12 +177,20 @@ export class AgentMetrics implements JSONSerializable { */ readonly toolMetrics: Record + /** + * The most recent input token count from the last model invocation. + * Represents the current context window utilization. + * Returns `undefined` when no invocations have occurred. + */ + readonly latestContextSize: number | undefined + constructor(data?: Partial) { 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.latestContextSize = data?.latestContextSize } /** @@ -236,6 +250,7 @@ export class AgentMetrics implements JSONSerializable { accumulatedMetrics: this.accumulatedMetrics, agentInvocations: this.agentInvocations, toolMetrics: this.toolMetrics, + ...(this.latestContextSize !== undefined && { latestContextSize: this.latestContextSize }), } } } @@ -277,6 +292,11 @@ export class Meter { */ private readonly _toolMetrics: Record = {} + /** + * The most recent input token count from the last model invocation. + */ + private _latestContextSize: number | undefined + // OTEL instruments (no-op when no MeterProvider is registered) private readonly _otelMeter: OtelMeter private readonly _otelCycleCounter: Counter @@ -335,6 +355,7 @@ export class Meter { * Creates a new InvocationMetricsData entry for per-invocation metrics. */ startNewInvocation(): void { + this._latestContextSize = undefined this._agentInvocations.push({ cycles: [], usage: createEmptyUsage(), @@ -438,6 +459,7 @@ export class Meter { accumulatedMetrics: this._accumulatedMetrics, agentInvocations: this._agentInvocations, toolMetrics: this._toolMetrics, + ...(this._latestContextSize !== undefined && { latestContextSize: this._latestContextSize }), }) } @@ -474,6 +496,7 @@ export class Meter { */ private _updateUsage(usage: Usage): void { accumulateUsage(this._accumulatedUsage, usage) + this._latestContextSize = usage.inputTokens this._otelInputTokens.add(usage.inputTokens) this._otelOutputTokens.add(usage.outputTokens) diff --git a/src/types/__tests__/agent.test.ts b/src/types/__tests__/agent.test.ts index c3926c95..4d4ebead 100644 --- a/src/types/__tests__/agent.test.ts +++ b/src/types/__tests__/agent.test.ts @@ -196,6 +196,54 @@ describe('AgentResult', () => { }) }) + describe('contextSize', () => { + it('returns latestContextSize from metrics', () => { + const message = new Message({ + role: 'assistant', + content: [new TextBlock('Hello')], + }) + + const metrics = new AgentMetrics({ latestContextSize: 500 }) + + const result = new AgentResult({ + stopReason: 'endTurn', + lastMessage: message, + metrics, + }) + + expect(result.contextSize).toBe(500) + }) + + it('returns undefined when metrics has no latestContextSize', () => { + const message = new Message({ + role: 'assistant', + content: [new TextBlock('Hello')], + }) + + const result = new AgentResult({ + stopReason: 'endTurn', + lastMessage: message, + metrics: new AgentMetrics(), + }) + + expect(result.contextSize).toBeUndefined() + }) + + it('returns undefined when no metrics are available', () => { + const message = new Message({ + role: 'assistant', + content: [new TextBlock('Hello')], + }) + + const result = new AgentResult({ + stopReason: 'endTurn', + lastMessage: message, + }) + + expect(result.contextSize).toBeUndefined() + }) + }) + describe('toJSON', () => { it('excludes traces and metrics from serialization', () => { const message = new Message({ diff --git a/src/types/agent.ts b/src/types/agent.ts index 4aba5d76..62d415ed 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -195,6 +195,15 @@ export class AgentResult { } } + /** + * The most recent input token count from the last model invocation. + * Convenience accessor that delegates to `metrics.latestContextSize`. + * Returns `undefined` when no metrics or invocations are available. + */ + get contextSize(): number | undefined { + return this.metrics?.latestContextSize + } + /** * Custom JSON serialization that excludes traces and metrics by default. * This prevents accidentally sending large trace/metric data over the wire