From 256b39002991bdc76603abdfdf895abfa039892e Mon Sep 17 00:00:00 2001
From: Omer Aplak <omeraplak@gmail.com>
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<CallSettings> {
   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<unknown> =>
   typeof value === "object" && value !== null && typeof (value as any).then === "function";
 
+type SpanAttributes = Record<string, unknown>;
+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<string, any> = {
       ...(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<string, unknown> }).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 <omeraplak@gmail.com>
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,
