Skip to content
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
84 changes: 84 additions & 0 deletions src/telemetry/__tests__/meter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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', () => {
Expand Down
23 changes: 23 additions & 0 deletions src/telemetry/meter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export interface AgentMetricsData {
* Per-tool execution metrics keyed by tool name.
*/
toolMetrics: Record<string, ToolMetricsData>

/**
* The most recent input token count from the last model invocation.
* Represents the current context window utilization.
*/
latestContextSize?: number
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: The TSDoc says "context window utilization" but inputTokens is only one component of context window usage — it doesn't account for outputTokens generated in the same turn or provider-specific overhead. Consider making the doc more precise:

/**
 * The most recent input token count from the last model invocation.
 * This is a lagging indicator (post-call) that tracks the `inputTokens` value
 * reported by the model provider, useful for threshold-based context management decisions.
 */

This matches the Python PR description's framing: "a lagging indicator (post-call), not a pre-call estimate."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update commit — the startNewInvocation reset and toJSON test merge look good. This TSDoc wording is the only remaining item from the previous review. The current phrasing "Represents the current context window utilization" could mislead users into thinking this reflects the full window capacity when it's actually the inputTokens count from the last model response. A small wording tweak would make the distinction clear.

}

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

/**
* 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<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.latestContextSize = data?.latestContextSize
}

/**
Expand Down Expand Up @@ -236,6 +250,7 @@ export class AgentMetrics implements JSONSerializable<AgentMetricsData> {
accumulatedMetrics: this.accumulatedMetrics,
agentInvocations: this.agentInvocations,
toolMetrics: this.toolMetrics,
...(this.latestContextSize !== undefined && { latestContextSize: this.latestContextSize }),
}
}
}
Expand Down Expand Up @@ -277,6 +292,11 @@ export class Meter {
*/
private readonly _toolMetrics: Record<string, ToolMetricsData> = {}

/**
* 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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -438,6 +459,7 @@ export class Meter {
accumulatedMetrics: this._accumulatedMetrics,
agentInvocations: this._agentInvocations,
toolMetrics: this._toolMetrics,
...(this._latestContextSize !== undefined && { latestContextSize: this._latestContextSize }),
})
}

Expand Down Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions src/types/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading