diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs
index 81c0a3570c..9acba99eea 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs
@@ -5,6 +5,8 @@ namespace Microsoft.Agents.AI.Workflows.Observability;
internal static class Tags
{
public const string WorkflowId = "workflow.id";
+ public const string WorkflowName = "workflow.name";
+ public const string WorkflowDescription = "workflow.description";
public const string WorkflowDefinition = "workflow.definition";
public const string BuildErrorMessage = "build.error.message";
public const string BuildErrorType = "build.error.type";
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs
index 67c6970bf3..a656e33d1c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs
@@ -56,14 +56,28 @@ public class Workflow
///
public string StartExecutorId { get; }
+ ///
+ /// Gets the optional human-readable name of the workflow.
+ ///
+ public string? Name { get; internal init; }
+
+ ///
+ /// Gets the optional description of what the workflow does.
+ ///
+ public string? Description { get; internal init; }
+
///
/// Initializes a new instance of the class with the specified starting executor identifier
/// and input type.
///
/// The unique identifier of the starting executor for the workflow. Cannot be null.
- internal Workflow(string startExecutorId)
+ /// Optional human-readable name for the workflow.
+ /// Optional description of what the workflow does.
+ internal Workflow(string startExecutorId, string? name = null, string? description = null)
{
this.StartExecutorId = Throw.IfNull(startExecutorId);
+ this.Name = name;
+ this.Description = description;
}
///
@@ -193,7 +207,10 @@ public class Workflow : Workflow
/// Initializes a new instance of the class with the specified starting executor identifier
///
/// The unique identifier of the starting executor for the workflow. Cannot be null.
- public Workflow(string startExecutorId) : base(startExecutorId)
+ /// Optional human-readable name for the workflow.
+ /// Optional description of what the workflow does.
+ public Workflow(string startExecutorId, string? name = null, string? description = null)
+ : base(startExecutorId, name, description)
{
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
index 676a3a02cf..7a848de4e5 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
@@ -36,6 +36,8 @@ public class WorkflowBuilder
private readonly HashSet _outputExecutors = [];
private readonly string _startExecutorId;
+ private string? _name;
+ private string? _description;
private static readonly string s_namespace = typeof(WorkflowBuilder).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
@@ -114,6 +116,28 @@ public class WorkflowBuilder
return this;
}
+ ///
+ /// Sets the human-readable name for the workflow.
+ ///
+ /// The name of the workflow.
+ /// The current instance, enabling fluent configuration.
+ public WorkflowBuilder WithName(string name)
+ {
+ this._name = name;
+ return this;
+ }
+
+ ///
+ /// Sets the description for the workflow.
+ ///
+ /// The description of what the workflow does.
+ /// The current instance, enabling fluent configuration.
+ public WorkflowBuilder WithDescription(string description)
+ {
+ this._description = description;
+ return this;
+ }
+
///
/// Binds the specified executor to the workflow, allowing it to participate in workflow execution.
///
@@ -372,7 +396,7 @@ public class WorkflowBuilder
activity?.AddEvent(new ActivityEvent(EventNames.BuildValidationCompleted));
- var workflow = new Workflow(this._startExecutorId)
+ var workflow = new Workflow(this._startExecutorId, this._name, this._description)
{
Registrations = this._executors,
Edges = this._edges,
@@ -382,6 +406,14 @@ public class WorkflowBuilder
// Using the start executor ID as a proxy for the workflow ID
activity?.SetTag(Tags.WorkflowId, workflow.StartExecutorId);
+ if (workflow.Name is not null)
+ {
+ activity?.SetTag(Tags.WorkflowName, workflow.Name);
+ }
+ if (workflow.Description is not null)
+ {
+ activity?.SetTag(Tags.WorkflowDescription, workflow.Description);
+ }
if (activity is not null)
{
var workflowJsonDefinitionData = new WorkflowJsonDefinitionData
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs
index a545ee369e..c1ba97b0e9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs
@@ -81,4 +81,35 @@ public partial class WorkflowBuilderSmokeTests
workflow.Registrations.Should().ContainKey("start");
workflow.Registrations["start"].ExecutorType.Should().Be();
}
+
+ [Fact]
+ public void Test_Workflow_NameAndDescription()
+ {
+ // Test with name and description
+ Workflow workflow1 = new WorkflowBuilder("start")
+ .WithName("Test Pipeline")
+ .WithDescription("Test workflow description")
+ .BindExecutor(new NoOpExecutor("start"))
+ .Build();
+
+ workflow1.Name.Should().Be("Test Pipeline");
+ workflow1.Description.Should().Be("Test workflow description");
+
+ // Test without (defaults to null)
+ Workflow workflow2 = new WorkflowBuilder("start2")
+ .BindExecutor(new NoOpExecutor("start2"))
+ .Build();
+
+ workflow2.Name.Should().BeNull();
+ workflow2.Description.Should().BeNull();
+
+ // Test with only name (no description)
+ Workflow workflow3 = new WorkflowBuilder("start3")
+ .WithName("Named Only")
+ .BindExecutor(new NoOpExecutor("start3"))
+ .Build();
+
+ workflow3.Name.Should().Be("Named Only");
+ workflow3.Description.Should().BeNull();
+ }
}
diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py
index 51e6dcba98..d659009c9c 100644
--- a/python/packages/core/agent_framework/_workflows/_workflow.py
+++ b/python/packages/core/agent_framework/_workflows/_workflow.py
@@ -172,6 +172,8 @@ class Workflow(DictConvertible):
start_executor: Executor | str,
runner_context: RunnerContext,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
+ name: str | None = None,
+ description: str | None = None,
**kwargs: Any,
):
"""Initialize the workflow with a list of edges.
@@ -182,6 +184,8 @@ class Workflow(DictConvertible):
start_executor: The starting executor for the workflow, which can be an Executor instance or its ID.
runner_context: The RunnerContext instance to be used during workflow execution.
max_iterations: The maximum number of iterations the workflow will run for convergence.
+ name: Optional human-readable name for the workflow.
+ description: Optional description of what the workflow does.
kwargs: Additional keyword arguments. Unused in this implementation.
"""
# Convert start_executor to string ID if it's an Executor instance
@@ -194,6 +198,8 @@ class Workflow(DictConvertible):
self.start_executor_id = start_executor_id
self.max_iterations = max_iterations
self.id = id
+ self.name = name
+ self.description = description
# Store non-serializable runtime objects as private attributes
self._runner_context = runner_context
@@ -236,6 +242,12 @@ class Workflow(DictConvertible):
"executors": {executor_id: executor.to_dict() for executor_id, executor in self.executors.items()},
}
+ # Add optional name and description if provided
+ if self.name is not None:
+ data["name"] = self.name
+ if self.description is not None:
+ data["description"] = self.description
+
executors_data: dict[str, dict[str, Any]] = data.get("executors", {})
for executor_id, executor_payload in executors_data.items():
if (
@@ -284,11 +296,15 @@ class Workflow(DictConvertible):
WorkflowEvent: The events generated during the workflow execution.
"""
# Create workflow span that encompasses the entire execution
+ attributes: dict[str, Any] = {OtelAttr.WORKFLOW_ID: self.id}
+ if self.name:
+ attributes[OtelAttr.WORKFLOW_NAME] = self.name
+ if self.description:
+ attributes[OtelAttr.WORKFLOW_DESCRIPTION] = self.description
+
with create_workflow_span(
OtelAttr.WORKFLOW_RUN_SPAN,
- {
- OtelAttr.WORKFLOW_ID: self.id,
- },
+ attributes,
) as span:
saw_request = False
emitted_in_progress_pending = False
@@ -874,14 +890,27 @@ class WorkflowBuilder:
This class provides methods to add edges and set the starting executor for the workflow.
"""
- def __init__(self, max_iterations: int = DEFAULT_MAX_ITERATIONS):
- """Initialize the WorkflowBuilder with an empty list of edges and no starting executor."""
+ def __init__(
+ self,
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
+ name: str | None = None,
+ description: str | None = None,
+ ):
+ """Initialize the WorkflowBuilder with an empty list of edges and no starting executor.
+
+ Args:
+ max_iterations: Maximum number of iterations for workflow convergence.
+ name: Optional human-readable name for the workflow.
+ description: Optional description of what the workflow does.
+ """
self._edge_groups: list[EdgeGroup] = []
self._executors: dict[str, Executor] = {}
self._duplicate_executor_ids: set[str] = set()
self._start_executor: Executor | str | None = None
self._checkpoint_storage: CheckpointStorage | None = None
self._max_iterations: int = max_iterations
+ self._name: str | None = name
+ self._description: str | None = description
# Maps underlying AgentProtocol object id -> wrapped Executor so we reuse the same wrapper
# across set_start_executor / add_edge calls. Without this, unnamed agents (which receive
# random UUID based executor ids) end up wrapped multiple times, giving different ids for
@@ -1185,12 +1214,23 @@ class WorkflowBuilder:
# Create workflow instance after validation
workflow = Workflow(
- self._edge_groups, self._executors, self._start_executor, context, self._max_iterations
+ self._edge_groups,
+ self._executors,
+ self._start_executor,
+ context,
+ self._max_iterations,
+ name=self._name,
+ description=self._description,
)
- span.set_attributes({
+ build_attributes: dict[str, Any] = {
OtelAttr.WORKFLOW_ID: workflow.id,
OtelAttr.WORKFLOW_DEFINITION: workflow.to_json(),
- })
+ }
+ if workflow.name:
+ build_attributes[OtelAttr.WORKFLOW_NAME] = workflow.name
+ if workflow.description:
+ build_attributes[OtelAttr.WORKFLOW_DESCRIPTION] = workflow.description
+ span.set_attributes(build_attributes)
# Add workflow build completed event
span.add_event(OtelAttr.BUILD_COMPLETED)
diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py
index 32117357fd..1a84504f70 100644
--- a/python/packages/core/agent_framework/observability.py
+++ b/python/packages/core/agent_framework/observability.py
@@ -180,6 +180,8 @@ class OtelAttr(str, Enum):
# Workflow attributes
WORKFLOW_ID = "workflow.id"
+ WORKFLOW_NAME = "workflow.name"
+ WORKFLOW_DESCRIPTION = "workflow.description"
WORKFLOW_DEFINITION = "workflow.definition"
WORKFLOW_BUILD_SPAN = "workflow.build"
WORKFLOW_RUN_SPAN = "workflow.run"
diff --git a/python/packages/core/tests/workflow/test_serialization.py b/python/packages/core/tests/workflow/test_serialization.py
index 1360d5e72c..08245ef534 100644
--- a/python/packages/core/tests/workflow/test_serialization.py
+++ b/python/packages/core/tests/workflow/test_serialization.py
@@ -599,6 +599,48 @@ class TestSerializationWorkflowClasses:
assert "_shared_state" not in data
assert "_runner" not in data
+ def test_workflow_name_description_serialization(self) -> None:
+ """Test that workflow name and description are serialized correctly."""
+ # Test 1: With name and description
+ workflow1 = (
+ WorkflowBuilder(name="Test Pipeline", description="Test workflow description")
+ .set_start_executor(SampleExecutor(id="e1"))
+ .build()
+ )
+
+ assert workflow1.name == "Test Pipeline"
+ assert workflow1.description == "Test workflow description"
+
+ data1 = workflow1.to_dict()
+ assert data1["name"] == "Test Pipeline"
+ assert data1["description"] == "Test workflow description"
+
+ # Test JSON serialization
+ json_str1 = workflow1.to_json()
+ parsed1 = json.loads(json_str1)
+ assert parsed1["name"] == "Test Pipeline"
+ assert parsed1["description"] == "Test workflow description"
+
+ # Test 2: Without name and description (defaults)
+ workflow2 = WorkflowBuilder().set_start_executor(SampleExecutor(id="e2")).build()
+
+ assert workflow2.name is None
+ assert workflow2.description is None
+
+ data2 = workflow2.to_dict()
+ assert "name" not in data2 # Should not include None values
+ assert "description" not in data2
+
+ # Test 3: With only name (no description)
+ workflow3 = WorkflowBuilder(name="Named Only").set_start_executor(SampleExecutor(id="e3")).build()
+
+ assert workflow3.name == "Named Only"
+ assert workflow3.description is None
+
+ data3 = workflow3.to_dict()
+ assert data3["name"] == "Named Only"
+ assert "description" not in data3
+
def test_executor_field_validation(self) -> None:
"""Test that Executor field validation works correctly."""
# Valid executor
diff --git a/python/packages/core/tests/workflow/test_workflow_observability.py b/python/packages/core/tests/workflow/test_workflow_observability.py
index e3073c544b..6ca4b59680 100644
--- a/python/packages/core/tests/workflow/test_workflow_observability.py
+++ b/python/packages/core/tests/workflow/test_workflow_observability.py
@@ -282,6 +282,23 @@ async def test_end_to_end_workflow_tracing(span_exporter: InMemorySpanExporter)
assert "build.validation_completed" in build_event_names
assert "build.completed" in build_event_names
+ # Clear spans to test workflow with name and description
+ span_exporter.clear()
+
+ # Test workflow with name and description - verify OTEL attributes
+ (
+ WorkflowBuilder(name="Test Pipeline", description="Test workflow description")
+ .set_start_executor(MockExecutor("start"))
+ .build()
+ )
+
+ build_spans_with_metadata = [s for s in span_exporter.get_finished_spans() if s.name == "workflow.build"]
+ assert len(build_spans_with_metadata) == 1
+ metadata_build_span = build_spans_with_metadata[0]
+ assert metadata_build_span.attributes is not None
+ assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_NAME) == "Test Pipeline"
+ assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_DESCRIPTION) == "Test workflow description"
+
# Clear spans to separate build from run tracing
span_exporter.clear()