mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
feat: Add name and description support to workflows (#1183)
Add optional name and description fields to workflows in both Python and .NET implementations, matching the existing agent API pattern. Python changes: - Add name/description parameters to WorkflowBuilder.__init__ - Add name/description attributes to Workflow class - Include name/description in to_dict() serialization - Add WORKFLOW_NAME and WORKFLOW_DESCRIPTION OTEL attributes - Add tests in test_serialization.py and test_workflow_observability.py .NET changes: - Add Name and Description properties to Workflow and Workflow<T> - Add WithName() and WithDescription() fluent methods to WorkflowBuilder - Add WorkflowName and WorkflowDescription OTEL tags - Add test in WorkflowBuilderSmokeTests.cs This enables applications like DevUI to display human-readable workflow names (e.g., 'Data Processing Pipeline') instead of auto-generated UUIDs (e.g., 'Workflow 50fdd917'). Fixes: #1181
This commit is contained in:
committed by
GitHub
Unverified
parent
fd819c6c02
commit
5a7ca13af6
@@ -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";
|
||||
|
||||
@@ -56,14 +56,28 @@ public class Workflow
|
||||
/// </summary>
|
||||
public string StartExecutorId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional human-readable name of the workflow.
|
||||
/// </summary>
|
||||
public string? Name { get; internal init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional description of what the workflow does.
|
||||
/// </summary>
|
||||
public string? Description { get; internal init; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Workflow"/> class with the specified starting executor identifier
|
||||
/// and input type.
|
||||
/// </summary>
|
||||
/// <param name="startExecutorId">The unique identifier of the starting executor for the workflow. Cannot be <c>null</c>.</param>
|
||||
internal Workflow(string startExecutorId)
|
||||
/// <param name="name">Optional human-readable name for the workflow.</param>
|
||||
/// <param name="description">Optional description of what the workflow does.</param>
|
||||
internal Workflow(string startExecutorId, string? name = null, string? description = null)
|
||||
{
|
||||
this.StartExecutorId = Throw.IfNull(startExecutorId);
|
||||
this.Name = name;
|
||||
this.Description = description;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -193,7 +207,10 @@ public class Workflow<T> : Workflow
|
||||
/// Initializes a new instance of the <see cref="Workflow{T}"/> class with the specified starting executor identifier
|
||||
/// </summary>
|
||||
/// <param name="startExecutorId">The unique identifier of the starting executor for the workflow. Cannot be <c>null</c>.</param>
|
||||
public Workflow(string startExecutorId) : base(startExecutorId)
|
||||
/// <param name="name">Optional human-readable name for the workflow.</param>
|
||||
/// <param name="description">Optional description of what the workflow does.</param>
|
||||
public Workflow(string startExecutorId, string? name = null, string? description = null)
|
||||
: base(startExecutorId, name, description)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ public class WorkflowBuilder
|
||||
private readonly HashSet<string> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the human-readable name for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the workflow.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
public WorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the description for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="description">The description of what the workflow does.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
public WorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the specified executor to the workflow, allowing it to participate in workflow execution.
|
||||
/// </summary>
|
||||
@@ -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
|
||||
|
||||
@@ -81,4 +81,35 @@ public partial class WorkflowBuilderSmokeTests
|
||||
workflow.Registrations.Should().ContainKey("start");
|
||||
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user