From 256b39002991bdc76603abdfdf895abfa039892e Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Fri, 23 Jan 2026 19:01:20 -0800 Subject: [PATCH 1/2] feat: auto-inherit VoltAgent spans for wrapped agent calls --- .changeset/silent-queens-sit.md | 38 ++++++++ examples/with-workflow/src/index.ts | 38 ++++++++ packages/core/src/agent/agent.ts | 4 + .../src/agent/open-telemetry/trace-context.ts | 90 ++++++++++++++----- packages/core/src/agent/types.ts | 5 ++ 5 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 .changeset/silent-queens-sit.md diff --git a/.changeset/silent-queens-sit.md b/.changeset/silent-queens-sit.md new file mode 100644 index 000000000..9aa9a8b9f --- /dev/null +++ b/.changeset/silent-queens-sit.md @@ -0,0 +1,38 @@ +--- +"@voltagent/core": patch +--- + +feat: auto-inherit VoltAgent spans for wrapped agent calls + +Agent calls now attach to the active VoltAgent workflow/agent span by default when `parentSpan` is not provided. This keeps wrapper logic (like `andThen` + `generateText`) inside the same trace without needing `andAgent`. Ambient framework spans are still ignored; only VoltAgent workflow/agent spans are eligible. + +Example: + +```ts +const contentAgent = new Agent({ + name: "ContentAgent", + model: "openai/gpt-4o-mini", + instructions: "Write concise summaries.", +}); + +const wrappedAgentWorkflow = createWorkflowChain({ + id: "wrapped-agent-call", + name: "Wrapped Agent Call Workflow", + input: z.object({ topic: z.string() }), + result: z.object({ summary: z.string() }), +}).andThen({ + id: "maybe-call-agent", + execute: async ({ data }) => { + const { text } = await contentAgent.generateText( + `Write a single-sentence summary about: ${data.topic}` + ); + return { summary: text.trim() }; + }, +}); +``` + +Opt out when you want a fresh trace: + +```ts +await contentAgent.generateText("...", { inheritParentSpan: false }); +``` diff --git a/examples/with-workflow/src/index.ts b/examples/with-workflow/src/index.ts index 621a51a28..3fd7bdd1f 100644 --- a/examples/with-workflow/src/index.ts +++ b/examples/with-workflow/src/index.ts @@ -549,6 +549,43 @@ const guardrailWorkflow = createWorkflowChain({ execute: async ({ data }) => data, }); +// ============================================================================== +// Example 9: Wrapped Agent Call Workflow +// Concepts: Agent call inside andThen; parent span inheritance +// ============================================================================== +const wrappedAgentWorkflow = createWorkflowChain({ + id: "wrapped-agent-call", + name: "Wrapped Agent Call Workflow", + purpose: "Call an agent from a custom step without using andAgent", + input: z.object({ + topic: z.string(), + useAgent: z.boolean().default(true), + }), + result: z.object({ + summary: z.string(), + usedAgent: z.boolean(), + }), +}).andThen({ + id: "maybe-call-agent", + execute: async ({ data }) => { + if (!data.useAgent) { + return { + summary: `Skipped agent for ${data.topic}.`, + usedAgent: false, + }; + } + + const { text } = await contentAgent.generateText( + `Write a single-sentence summary about: ${data.topic}`, + ); + + return { + summary: text.trim(), + usedAgent: true, + }; + }, +}); + // Register workflows with VoltAgent // Create logger @@ -573,5 +610,6 @@ new VoltAgent({ batchTransformWorkflow, loopAndBranchWorkflow, guardrailWorkflow, + wrappedAgentWorkflow, }, }); diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 6d078ff1e..0dd5413eb 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -415,6 +415,7 @@ export interface BaseGenerationOptions extends Partial { parentAgentId?: string; parentOperationContext?: OperationContext; parentSpan?: Span; // Optional parent span for OpenTelemetry context propagation + inheritParentSpan?: boolean; // Use active VoltAgent span if parentSpan is not provided // Memory contextLimit?: number; @@ -516,6 +517,7 @@ export class Agent { readonly maxRetries: number; readonly stopWhen?: StopWhen; readonly markdown: boolean; + readonly inheritParentSpan: boolean; readonly voice?: Voice; readonly retriever?: BaseRetriever; readonly supervisorConfig?: SupervisorConfig; @@ -561,6 +563,7 @@ export class Agent { this.maxRetries = options.maxRetries ?? DEFAULT_LLM_MAX_RETRIES; this.stopWhen = options.stopWhen; this.markdown = options.markdown ?? false; + this.inheritParentSpan = options.inheritParentSpan ?? true; this.voice = options.voice; this.retriever = options.retriever; this.supervisorConfig = options.supervisorConfig; @@ -3029,6 +3032,7 @@ export class Agent { conversationId: options?.conversationId, operationId, parentSpan: options?.parentSpan, + inheritParentSpan: options?.inheritParentSpan ?? this.inheritParentSpan, parentAgentId: options?.parentAgentId, input, }); diff --git a/packages/core/src/agent/open-telemetry/trace-context.ts b/packages/core/src/agent/open-telemetry/trace-context.ts index 7bb2cad57..a2fb76719 100644 --- a/packages/core/src/agent/open-telemetry/trace-context.ts +++ b/packages/core/src/agent/open-telemetry/trace-context.ts @@ -42,6 +42,28 @@ const popActiveSpan = (span: Span) => { const isPromiseLike = (value: unknown): value is PromiseLike => typeof value === "object" && value !== null && typeof (value as any).then === "function"; +type SpanAttributes = Record; +type ResolvedParentSpan = { + span: Span; + parentType: "agent" | "workflow" | "unknown"; + agentInfo?: { id?: string; name?: string }; +}; + +const getSpanAttributes = (span?: Span): SpanAttributes => + (span as unknown as { attributes?: SpanAttributes })?.attributes ?? {}; + +const isWorkflowSpan = (attributes: SpanAttributes): boolean => + attributes["entity.type"] === "workflow" || attributes["span.type"] === "workflow-step"; + +const isAgentSpan = (attributes: SpanAttributes): boolean => attributes["entity.type"] === "agent"; + +const getAgentInfo = (attributes: SpanAttributes): { id?: string; name?: string } => ({ + id: + (attributes["entity.id"] as string | undefined) ?? + (attributes["eval.source.agent_id"] as string | undefined), + name: attributes["entity.name"] as string | undefined, +}); + export interface TraceContextOptions { agentId: string; agentName?: string; @@ -49,6 +71,7 @@ export interface TraceContextOptions { conversationId?: string; operationId: string; parentSpan?: Span; + inheritParentSpan?: boolean; parentAgentId?: string; input?: string | UIMessage[] | BaseMessage[]; } @@ -66,13 +89,16 @@ export class AgentTraceContext { ) { this.tracer = observability.getTracer(); - const resolvedParent = this.resolveParentSpan(options.parentSpan); + const resolvedParent = this.resolveParentSpan(options.parentSpan, options.inheritParentSpan); const parentSpan = resolvedParent?.span ?? options.parentSpan; - const parentAgentId = options.parentAgentId ?? resolvedParent?.agentInfo?.id; - const parentAgentName = resolvedParent?.agentInfo?.name; + const isWorkflowParent = resolvedParent?.parentType === "workflow"; + const isSubagent = !!parentSpan && !isWorkflowParent; + const parentAgentId = isSubagent + ? (options.parentAgentId ?? resolvedParent?.agentInfo?.id) + : undefined; + const parentAgentName = isSubagent ? resolvedParent?.agentInfo?.name : undefined; // Store common attributes once - these will be inherited by all child spans - const isSubagent = !!parentSpan; const commonAttributes: Record = { ...(options.userId && { "user.id": options.userId }), ...(options.conversationId && { "conversation.id": options.conversationId }), @@ -124,9 +150,9 @@ export class AgentTraceContext { spanAttributes.input = inputStr; } - // If we have a parent span, this agent is being called as a subagent + // If we have an agent parent span, this agent is being called as a subagent // Create a more descriptive span name to show the hierarchy clearly - const spanName = parentSpan ? `subagent:${options.agentName || operationName}` : operationName; + const spanName = isSubagent ? `subagent:${options.agentName || operationName}` : operationName; this.rootSpan = this.tracer.startSpan( spanName, @@ -136,7 +162,7 @@ export class AgentTraceContext { ...spanAttributes, "agent.state": "running", // Track initial agent state // Mark as subagent if we have a parent span - ...(parentSpan && { + ...(isSubagent && { "agent.is_subagent": true, "voltagent.is_subagent": true, }), @@ -451,9 +477,19 @@ export class AgentTraceContext { private resolveParentSpan( explicitParent?: Span, - ): { span: Span; agentInfo?: { id?: string; name?: string } } | undefined { + inheritParentSpan?: boolean, + ): ResolvedParentSpan | undefined { if (explicitParent) { - return { span: explicitParent }; + const attributes = getSpanAttributes(explicitParent); + return { + span: explicitParent, + parentType: isWorkflowSpan(attributes) + ? "workflow" + : isAgentSpan(attributes) + ? "agent" + : "unknown", + agentInfo: isAgentSpan(attributes) ? getAgentInfo(attributes) : undefined, + }; } const activeSpan = trace.getSpan(context.active()); @@ -461,23 +497,37 @@ export class AgentTraceContext { return undefined; } - const attributes = - (activeSpan as unknown as { attributes?: Record }).attributes ?? {}; - + const attributes = getSpanAttributes(activeSpan); const spanType = attributes["span.type"]; const scorerId = attributes["eval.scorer.id"]; - if (spanType !== "scorer" && scorerId === undefined) { + if (spanType === "scorer" || scorerId !== undefined) { + return { + span: activeSpan, + parentType: "agent", + agentInfo: getAgentInfo(attributes), + }; + } + + if (!inheritParentSpan) { return undefined; } - const agentInfo = { - id: - (attributes["entity.id"] as string | undefined) ?? - (attributes["eval.source.agent_id"] as string | undefined), - name: attributes["entity.name"] as string | undefined, - }; + if (isWorkflowSpan(attributes)) { + return { + span: activeSpan, + parentType: "workflow", + }; + } + + if (isAgentSpan(attributes)) { + return { + span: activeSpan, + parentType: "agent", + agentInfo: getAgentInfo(attributes), + }; + } - return { span: activeSpan, agentInfo }; + return undefined; } /** diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 0e458af55..8b9102923 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -616,6 +616,11 @@ export type AgentOptions = { */ stopWhen?: StopWhen; markdown?: boolean; + /** + * When true, use the active VoltAgent span as the parent if parentSpan is not provided. + * Defaults to true. + */ + inheritParentSpan?: boolean; // Voice voice?: Voice; From e01be7cdb586620f4d08953551e207d9ec960ee0 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Fri, 23 Jan 2026 19:30:17 -0800 Subject: [PATCH 2/2] fix: code reviews --- .../core/src/agent/open-telemetry/trace-context.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent/open-telemetry/trace-context.ts b/packages/core/src/agent/open-telemetry/trace-context.ts index a2fb76719..dc938583c 100644 --- a/packages/core/src/agent/open-telemetry/trace-context.ts +++ b/packages/core/src/agent/open-telemetry/trace-context.ts @@ -92,10 +92,9 @@ export class AgentTraceContext { const resolvedParent = this.resolveParentSpan(options.parentSpan, options.inheritParentSpan); const parentSpan = resolvedParent?.span ?? options.parentSpan; const isWorkflowParent = resolvedParent?.parentType === "workflow"; - const isSubagent = !!parentSpan && !isWorkflowParent; - const parentAgentId = isSubagent - ? (options.parentAgentId ?? resolvedParent?.agentInfo?.id) - : undefined; + const explicitParentAgentId = options.parentAgentId ?? resolvedParent?.agentInfo?.id; + const isSubagent = !!parentSpan && !isWorkflowParent && !!explicitParentAgentId; + const parentAgentId = isSubagent ? explicitParentAgentId : undefined; const parentAgentName = isSubagent ? resolvedParent?.agentInfo?.name : undefined; // Store common attributes once - these will be inherited by all child spans @@ -150,7 +149,7 @@ export class AgentTraceContext { spanAttributes.input = inputStr; } - // If we have an agent parent span, this agent is being called as a subagent + // If we have an explicit agent parent, this agent is being called as a subagent // Create a more descriptive span name to show the hierarchy clearly const spanName = isSubagent ? `subagent:${options.agentName || operationName}` : operationName; @@ -161,7 +160,7 @@ export class AgentTraceContext { attributes: { ...spanAttributes, "agent.state": "running", // Track initial agent state - // Mark as subagent if we have a parent span + // Mark as subagent only when an explicit agent parent is present ...(isSubagent && { "agent.is_subagent": true, "voltagent.is_subagent": true,