diff --git a/.changeset/trace-state-dead-field.md b/.changeset/trace-state-dead-field.md new file mode 100644 index 000000000..fa78f66ca --- /dev/null +++ b/.changeset/trace-state-dead-field.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Remove a dead `channelKind` field from local trace session state and collapse the per-attempt model and tool context maps into one, keyed by the operation id that already distinguishes them. diff --git a/packages/eve/src/harness/instrumentation-lifecycle.ts b/packages/eve/src/harness/instrumentation-lifecycle.ts index b352b3b22..a212be7a7 100644 --- a/packages/eve/src/harness/instrumentation-lifecycle.ts +++ b/packages/eve/src/harness/instrumentation-lifecycle.ts @@ -25,7 +25,6 @@ export interface InstrumentationAttemptStartedEvent { export interface InstrumentationSessionStartedEvent { readonly type: "session.started"; readonly agentName?: string; - readonly channelKind?: string; readonly parentTraceContext?: InstrumentationTraceContext; readonly rootSessionId: string; readonly sessionId: string; diff --git a/packages/eve/src/tracing/agent-otel-content.ts b/packages/eve/src/tracing/agent-otel-content.ts index 12559c47e..d601a2a5d 100644 --- a/packages/eve/src/tracing/agent-otel-content.ts +++ b/packages/eve/src/tracing/agent-otel-content.ts @@ -22,15 +22,8 @@ const TRUNCATED_MESSAGES_KEY = "eve.truncated"; */ export function contentAttribute(value: unknown, strip = true): string | undefined { if (value === undefined) return undefined; - const prepared = strip ? stripContentNoise(value, 0) : value; - let json: string | undefined; - try { - json = JSON.stringify(prepared); - } catch { - return undefined; - } - if (json === undefined) return undefined; - return textContentAttribute(json); + const json = stringifyContent(strip ? stripContentNoise(value, 0) : value); + return json === undefined ? undefined : textContentAttribute(json); } /** diff --git a/packages/eve/src/tracing/agent-otel-provider.test.ts b/packages/eve/src/tracing/agent-otel-provider.test.ts index 3019fe046..f7ef76608 100644 --- a/packages/eve/src/tracing/agent-otel-provider.test.ts +++ b/packages/eve/src/tracing/agent-otel-provider.test.ts @@ -183,7 +183,6 @@ async function publishTurnStarted(input: { const rootSessionId = input.rootSessionId ?? input.sessionId; await input.hooks.publish({ agentName: "weather", - channelKind: "http", parentTraceContext: input.parentTraceContext, rootSessionId, sessionId: input.sessionId, @@ -319,7 +318,6 @@ describe("createAgentOtelInstrumentation", () => { }; await runtime.hooks.publish({ agentName: "weather", - channelKind: "http", rootSessionId: "session-1", sessionId: "session-1", type: "session.started", diff --git a/packages/eve/src/tracing/agent-otel-provider.ts b/packages/eve/src/tracing/agent-otel-provider.ts index 73ac55810..62cf514ea 100644 --- a/packages/eve/src/tracing/agent-otel-provider.ts +++ b/packages/eve/src/tracing/agent-otel-provider.ts @@ -52,9 +52,16 @@ interface ToolSpanState extends SpanState { /** Sized so an ordinary session stays one trace and only an outsized one rolls. */ export const SESSION_WINDOW_TURN_LIMIT = 200; +/** What opening a session's trace state needs, narrower than the start event. */ +interface SessionContextInput { + readonly agentName?: string; + readonly parentTraceContext?: InstrumentationTraceContext; + readonly rootSessionId: string; + readonly sessionId: string; +} + export interface AgentSessionTraceState { readonly agentName?: string; - readonly channelKind?: string; readonly context: SpanContext; readonly rootSessionId: string; readonly turnsInWindow: number; @@ -96,7 +103,7 @@ export class InMemoryAgentTraceStateStore implements AgentTraceStateStore { } deleteTurn(sessionId: string, turnId: string): void { - this.#turns.delete(turnKey(sessionId, turnId)); + this.#turns.delete(agentTurnStateKey(sessionId, turnId)); } getSession(sessionId: string): AgentSessionTraceState | undefined { @@ -104,7 +111,7 @@ export class InMemoryAgentTraceStateStore implements AgentTraceStateStore { } getTurn(sessionId: string, turnId: string): AgentTurnTraceState | undefined { - return this.#turns.get(turnKey(sessionId, turnId)); + return this.#turns.get(agentTurnStateKey(sessionId, turnId)); } setSession(sessionId: string, state: AgentSessionTraceState): void { @@ -112,7 +119,7 @@ export class InMemoryAgentTraceStateStore implements AgentTraceStateStore { } setTurn(sessionId: string, turnId: string, state: AgentTurnTraceState): void { - this.#turns.set(turnKey(sessionId, turnId), state); + this.#turns.set(agentTurnStateKey(sessionId, turnId), state); } } @@ -139,15 +146,25 @@ export function createAgentOtelInstrumentation( input: AgentOtelInstrumentationInput, ): AgentOtelInstrumentation { const captureContent = input.captureContent ?? true; - const executionContexts = new WeakMap< - InstrumentationAttemptScope, - { readonly models: Map; readonly tools: Map } - >(); + // Keyed by the operation id, which already encodes whether the operation is + // a model call or a tool call (see `ai-sdk-hook-bridge.ts`). + const executionContexts = new WeakMap>(); // A serverless turn runs inside one `turnStep` "use step" invocation. If // that worker is lost, Workflow retries the whole step from entry rather // than resuming this callback sequence in a replacement process. const steps = new WeakMap(); + /** Attributes every span within one attempt carries: framework, session, turn, step. */ + const attemptScopeAttributes = (scope: InstrumentationAttemptScope) => ({ + "agent.framework.name": "eve", + "agent.framework.version": input.frameworkVersion, + "agent.root.session.id": scope.rootSessionId ?? scope.sessionId, + "agent.session.id": scope.sessionId, + "agent.step.attempt": scope.attemptIndex, + "agent.step.index": scope.stepIndex, + "agent.turn.id": scope.turnId, + }); + const onSessionStarted = async (event: InstrumentationSessionStartedEvent): Promise => { await ensureSessionContext(event); }; @@ -156,12 +173,9 @@ export function createAgentOtelInstrumentation( const session = advanceSessionWindow( event.sessionId, await ensureSessionContext({ - agentName: undefined, - channelKind: undefined, parentTraceContext: event.parentTraceContext, rootSessionId: event.rootSessionId, sessionId: event.sessionId, - type: "session.started", }), ); const span = input.tracer.startSpan( @@ -202,13 +216,7 @@ export function createAgentOtelInstrumentation( "agent.step", { attributes: { - "agent.session.id": event.scope.sessionId, - "agent.framework.name": "eve", - "agent.framework.version": input.frameworkVersion, - "agent.root.session.id": event.scope.rootSessionId ?? event.scope.sessionId, - "agent.step.attempt": event.scope.attemptIndex, - "agent.step.index": event.scope.stepIndex, - "agent.turn.id": event.scope.turnId, + ...attemptScopeAttributes(event.scope), "agent.name": event.scope.functionId, }, }, @@ -323,12 +331,12 @@ export function createAgentOtelInstrumentation( if (system !== undefined) span.setAttribute("ai.prompt.system", system); } const state = { context: trace.setSpan(attempt.operation.context, span), span }; - getExecutionContexts(event.scope).models.set(event.id, state.context); + executionContextsFor(event.scope).set(event.id, state.context); return state; }; const afterModelCall = (event: InstrumentationModelCallTerminalEvent, state: unknown): void => { - executionContexts.get(event.scope)?.models.delete(event.id); + executionContexts.get(event.scope)?.delete(event.id); if (!isSpanState(state)) return; if (event.type === "model.call.failed") { recordError(state.span, event.error); @@ -388,16 +396,10 @@ export function createAgentOtelInstrumentation( "agent.action", { attributes: { + ...attemptScopeAttributes(event.scope), "agent.action.call_id": event.source.toolCall.toolCallId, "agent.action.kind": "tool", "agent.action.name": event.source.toolCall.toolName, - "agent.framework.name": "eve", - "agent.framework.version": input.frameworkVersion, - "agent.root.session.id": event.scope.rootSessionId ?? event.scope.sessionId, - "agent.session.id": event.scope.sessionId, - "agent.step.attempt": event.scope.attemptIndex, - "agent.step.index": event.scope.stepIndex, - "agent.turn.id": event.scope.turnId, }, }, attempt.step.context, @@ -423,12 +425,12 @@ export function createAgentOtelInstrumentation( span: actionSpan, toolSpan, }; - getExecutionContexts(event.scope).tools.set(event.id, state.context); + executionContextsFor(event.scope).set(event.id, state.context); return state; }; const afterToolCall = (event: InstrumentationToolCallTerminalEvent, state: unknown): void => { - executionContexts.get(event.scope)?.tools.delete(event.id); + executionContexts.get(event.scope)?.delete(event.id); if (!isToolSpanState(state)) return; if (event.type === "tool.call.failed") { recordError(state.toolSpan, event.error); @@ -473,13 +475,12 @@ export function createAgentOtelInstrumentation( }; const ensureSessionContext = async ( - event: InstrumentationSessionStartedEvent, + event: SessionContextInput, ): Promise => { let state = await input.stateStore.getSession(event.sessionId); if (state === undefined) { state = { agentName: event.agentName, - channelKind: event.channelKind, context: event.parentTraceContext === undefined ? openSessionWindow({ @@ -551,30 +552,27 @@ export function createAgentOtelInstrumentation( }, }, runInContext(operation, execute) { - const contexts = executionContexts.get(operation.scope); - const parent = - operation.type === "model.call" - ? contexts?.models.get(operation.id) - : contexts?.tools.get(operation.id); + const parent = executionContexts.get(operation.scope)?.get(operation.id); return parent === undefined ? execute() : context.with(parent, execute); }, }; - function getExecutionContexts(scope: InstrumentationAttemptScope): { - readonly models: Map; - readonly tools: Map; - } { - let state = executionContexts.get(scope); - if (state === undefined) { - state = { models: new Map(), tools: new Map() }; - executionContexts.set(scope, state); + function executionContextsFor(scope: InstrumentationAttemptScope): Map { + let contexts = executionContexts.get(scope); + if (contexts === undefined) { + contexts = new Map(); + executionContexts.set(scope, contexts); } - return state; + return contexts; } } -function turnKey(sessionId: string, turnId: string): string { - return `${sessionId}:${turnId}`; +/** + * Composite key for one turn's trace state. `\0` separates the parts because + * it cannot occur in either id, so no pair of ids can collide. + */ +export function agentTurnStateKey(sessionId: string, turnId: string): string { + return `${sessionId}\0${turnId}`; } function parentLineageAttributes( diff --git a/packages/eve/src/tracing/agent-trace-context-store.ts b/packages/eve/src/tracing/agent-trace-context-store.ts index 1efee94d8..9742dbc6f 100644 --- a/packages/eve/src/tracing/agent-trace-context-store.ts +++ b/packages/eve/src/tracing/agent-trace-context-store.ts @@ -7,6 +7,7 @@ import type { AgentTraceStateStore, AgentTurnTraceState, } from "#tracing/agent-otel-provider.js"; +import { agentTurnStateKey } from "#tracing/agent-otel-provider.js"; interface AgentTraceContextState { readonly sessions: Readonly>; @@ -58,7 +59,7 @@ export class ContextAgentTraceStateStore implements AgentTraceStateStore { deleteTurn(sessionId: string, turnId: string): void { updateState((state) => { const turns = { ...state.turns }; - delete turns[turnKey(sessionId, turnId)]; + delete turns[agentTurnStateKey(sessionId, turnId)]; return { ...state, turns }; }); } @@ -68,7 +69,9 @@ export class ContextAgentTraceStateStore implements AgentTraceStateStore { } getTurn(sessionId: string, turnId: string): AgentTurnTraceState | undefined { - return contextStorage.getStore()?.get(AgentTraceContextKey)?.turns[turnKey(sessionId, turnId)]; + return contextStorage.getStore()?.get(AgentTraceContextKey)?.turns[ + agentTurnStateKey(sessionId, turnId) + ]; } setSession(sessionId: string, value: AgentSessionTraceState): void { @@ -81,7 +84,7 @@ export class ContextAgentTraceStateStore implements AgentTraceStateStore { setTurn(sessionId: string, turnId: string, value: AgentTurnTraceState): void { updateState((state) => ({ ...state, - turns: { ...state.turns, [turnKey(sessionId, turnId)]: value }, + turns: { ...state.turns, [agentTurnStateKey(sessionId, turnId)]: value }, })); } } @@ -90,10 +93,6 @@ function updateState(update: (state: AgentTraceContextState) => AgentTraceContex loadContext().set(AgentTraceContextKey, (state) => update(state ?? { sessions: {}, turns: {} })); } -function turnKey(sessionId: string, turnId: string): string { - return `${sessionId}\0${turnId}`; -} - function serializeState(state: AgentTraceContextState): unknown { return { sessions: Object.fromEntries( @@ -127,7 +126,6 @@ function deserializeState(data: unknown): AgentTraceContextState { if (!isRecord(value) || !isSpanContext(value.context)) return undefined; return { agentName: typeof value.agentName === "string" ? value.agentName : undefined, - channelKind: typeof value.channelKind === "string" ? value.channelKind : undefined, context: value.context, rootSessionId: typeof value.rootSessionId === "string" ? value.rootSessionId : "", turnsInWindow: typeof value.turnsInWindow === "number" ? value.turnsInWindow : 0,