Merge branch 'feature-foundry-agents' into feature-declarative-agents-dotnet

This commit is contained in:
Mark Wallace
2025-11-11 11:41:38 +00:00
committed by GitHub
Unverified
30 changed files with 678 additions and 313 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251110.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251110.1</PackageVersion>
<GitTag>1.0.0-preview.251110.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251110.2</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251110.2</PackageVersion>
<GitTag>1.0.0-preview.251110.2</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -142,11 +142,11 @@ You:
Besides the Aspire Dashboard and the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
### Agent Overview dashboard
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-agent>
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
![Agent Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-agent.gif)
### Workflow Overview dashboard
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-workflow>
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
![Workflow Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-workflow.gif)
## Key Features Demonstrated
@@ -32,6 +32,7 @@ public static class DevUIExtensions
{
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapMeta();
group.MapEntities();
return group;
}
@@ -15,10 +15,12 @@ namespace Microsoft.Agents.AI.DevUI.Entities;
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EntityInfo))]
[JsonSerializable(typeof(DiscoveryResponse))]
[JsonSerializable(typeof(MetaResponse))]
[JsonSerializable(typeof(EnvVarRequirement))]
[JsonSerializable(typeof(List<EntityInfo>))]
[JsonSerializable(typeof(List<JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, bool>))]
[JsonSerializable(typeof(JsonElement))]
[ExcludeFromCodeCoverage]
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DevUI.Entities;
/// <summary>
/// Server metadata response for the /meta endpoint.
/// Provides information about the DevUI server configuration, capabilities, and requirements.
/// </summary>
/// <remarks>
/// This response is used by the frontend to:
/// - Determine the UI mode (developer vs user interface)
/// - Check server capabilities (tracing, OpenAI proxy support)
/// - Verify authentication requirements
/// - Display framework and version information
/// </remarks>
internal sealed record MetaResponse
{
/// <summary>
/// Gets the UI interface mode.
/// "developer" shows debug tools and advanced features, "user" shows a simplified interface.
/// </summary>
[JsonPropertyName("ui_mode")]
public string UiMode { get; init; } = "developer";
/// <summary>
/// Gets the DevUI version string.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; init; } = "0.1.0";
/// <summary>
/// Gets the backend framework identifier.
/// Always "agent_framework" for Agent Framework implementations.
/// </summary>
[JsonPropertyName("framework")]
public string Framework { get; init; } = "agent_framework";
/// <summary>
/// Gets the backend runtime/language.
/// "dotnet" for .NET implementations, "python" for Python implementations.
/// Used by frontend for deployment guides and feature availability.
/// </summary>
[JsonPropertyName("runtime")]
public string Runtime { get; init; } = "dotnet";
/// <summary>
/// Gets the server capabilities dictionary.
/// Key-value pairs indicating which optional features are enabled.
/// </summary>
/// <remarks>
/// Standard capability keys:
/// - "tracing": Whether trace events are emitted for debugging
/// - "openai_proxy": Whether the server can proxy requests to OpenAI
/// </remarks>
[JsonPropertyName("capabilities")]
public Dictionary<string, bool> Capabilities { get; init; } = new();
/// <summary>
/// Gets a value indicating whether Bearer token authentication is required for API access.
/// When true, clients must include "Authorization: Bearer {token}" header in requests.
/// </summary>
[JsonPropertyName("auth_required")]
public bool AuthRequired { get; init; }
}
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI.DevUI.Entities;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DevUI;
@@ -56,79 +58,19 @@ internal static class EntitiesApiExtensions
{
var entities = new List<EntityInfo>();
// Discover agents from the agent catalog
if (agentCatalog is not null)
// Discover agents
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
{
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
if (agent.GetType().Name == "WorkflowHostAgent")
{
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
// and workflows are handled below.
continue;
}
entities.Add(new EntityInfo(
Id: agent.Name ?? agent.Id,
Type: "agent",
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
});
}
entities.Add(agentInfo);
}
// Discover workflows from the workflow catalog
if (workflowCatalog is not null)
// Discover workflows
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
{
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
entities.Add(new EntityInfo(
Id: workflow.Name ?? workflow.StartExecutorId,
Type: "workflow",
Name: workflow.Name ?? workflow.StartExecutorId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "string",
StartExecutorId = workflow.StartExecutorId
});
}
entities.Add(workflowInfo);
}
return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse);
return Results.Json(new DiscoveryResponse([.. entities]), EntitiesJsonContext.Default.DiscoveryResponse);
}
catch (Exception ex)
{
@@ -141,93 +83,26 @@ internal static class EntitiesApiExtensions
private static async Task<IResult> GetEntityInfoAsync(
string entityId,
string? type,
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
CancellationToken cancellationToken)
{
try
{
// Try to find the entity among discovered agents
if (agentCatalog is not null)
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
{
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
{
if (agent.GetType().Name == "WorkflowHostAgent")
{
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
// and workflows are handled below.
continue;
}
if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase))
{
var entityInfo = new EntityInfo(
Id: agent.Name ?? agent.Id,
Type: "agent",
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
};
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
}
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
// Try to find the entity among discovered workflows
if (workflowCatalog is not null)
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
{
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase))
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
var entityInfo = new EntityInfo(
Id: workflowId,
Type: "workflow",
Name: workflow.Name ?? workflow.StartExecutorId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "Input",
StartExecutorId = workflow.StartExecutorId
};
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
}
return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
@@ -241,4 +116,123 @@ internal static class EntitiesApiExtensions
title: "Error getting entity info");
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
AgentCatalog? agentCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (agentCatalog is null)
{
yield break;
}
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
// If filtering by entity ID, skip non-matching agents
if (entityIdFilter is not null &&
!string.Equals(agent.Name, entityIdFilter, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(agent.Id, entityIdFilter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return CreateAgentEntityInfo(agent);
// If we found the entity we're looking for, we're done
if (entityIdFilter is not null)
{
yield break;
}
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverWorkflowsAsync(
WorkflowCatalog? workflowCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (workflowCatalog is null)
{
yield break;
}
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
// If filtering by entity ID, skip non-matching workflows
if (entityIdFilter is not null && !string.Equals(workflowId, entityIdFilter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return CreateWorkflowEntityInfo(workflow);
// If we found the entity we're looking for, we're done
if (entityIdFilter is not null)
{
yield break;
}
}
}
private static EntityInfo CreateAgentEntityInfo(AIAgent agent)
{
var entityId = agent.Name ?? agent.Id;
return new EntityInfo(
Id: entityId,
Type: "agent",
Name: entityId,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
};
}
private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow)
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
var workflowId = workflow.Name ?? workflow.StartExecutorId;
return new EntityInfo(
Id: workflowId,
Type: "workflow",
Name: workflowId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "string",
StartExecutorId = workflow.StartExecutorId
};
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DevUI.Entities;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Provides extension methods for mapping the server metadata endpoint to an <see cref="IEndpointRouteBuilder"/>.
/// </summary>
internal static class MetaApiExtensions
{
/// <summary>
/// Maps the HTTP API endpoint for retrieving server metadata.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoint:
/// <list type="bullet">
/// <item><description>GET /meta - Retrieve server metadata including UI mode, version, capabilities, and auth requirements</description></item>
/// </list>
/// The endpoint is compatible with the Python DevUI frontend and provides essential
/// configuration information needed for proper frontend initialization.
/// </remarks>
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGet("/meta", GetMeta)
.WithName("GetMeta")
.WithSummary("Get server metadata and configuration")
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
}
private static IResult GetMeta()
{
// TODO: Consider making these configurable via IOptions<DevUIOptions>
// For now, using sensible defaults that match Python DevUI behavior
var meta = new MetaResponse
{
UiMode = "developer", // Could be made configurable to support "user" mode
Version = "0.1.0", // TODO: Extract from assembly version attribute
Framework = "agent_framework",
Runtime = "dotnet", // .NET runtime for deployment guides
Capabilities = new Dictionary<string, bool>
{
// Tracing capability - will be enabled when trace event support is added
["tracing"] = false,
// OpenAI proxy capability - not currently supported in .NET DevUI
["openai_proxy"] = false,
// Deployment capability - not currently supported in .NET DevUI
["deployment"] = false
},
AuthRequired = false // Could be made configurable based on authentication middleware
};
return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
}
}
@@ -109,6 +109,7 @@ internal static class OpenAIHostingJsonUtilities
[JsonSerializable(typeof(MCPApprovalRequestItemResource))]
[JsonSerializable(typeof(MCPApprovalResponseItemResource))]
[JsonSerializable(typeof(MCPCallItemResource))]
[JsonSerializable(typeof(ExecutorActionItemResource))]
[JsonSerializable(typeof(List<ItemResource>))]
// ItemParam types
[JsonSerializable(typeof(ItemParam))]
@@ -45,6 +45,9 @@ internal static class AgentRunResponseUpdateExtensions
var updateEnumerator = updates.GetAsyncEnumerator(cancellationToken);
await using var _ = updateEnumerator.ConfigureAwait(false);
// Track active item IDs by executor ID to pair invoked/completed/failed events
Dictionary<string, string> executorItemIds = [];
AgentRunResponseUpdate? previousUpdate = null;
StreamingEventGenerator? generator = null;
while (await updateEnumerator.MoveNextAsync().ConfigureAwait(false))
@@ -55,7 +58,92 @@ internal static class AgentRunResponseUpdateExtensions
// Special-case for agent framework workflow events.
if (update.RawRepresentation is WorkflowEvent workflowEvent)
{
yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex);
// Convert executor events to standard OpenAI output_item events
if (workflowEvent is ExecutorInvokedEvent invokedEvent)
{
var itemId = IdGenerator.NewId(prefix: "item");
// Store the item ID for this executor so we can reuse it for completion/failure
executorItemIds[invokedEvent.ExecutorId] = itemId;
var item = new ExecutorActionItemResource
{
Id = itemId,
ExecutorId = invokedEvent.ExecutorId,
Status = "in_progress",
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
yield return new StreamingOutputItemAdded
{
SequenceNumber = seq.Increment(),
OutputIndex = outputIndex,
Item = item
};
}
else if (workflowEvent is ExecutorCompletedEvent completedEvent)
{
// Reuse the item ID from the invoked event, or generate a new one if not found
var itemId = executorItemIds.TryGetValue(completedEvent.ExecutorId, out var existingId)
? existingId
: IdGenerator.NewId(prefix: "item");
// Remove from tracking as this executor run is now complete
executorItemIds.Remove(completedEvent.ExecutorId);
JsonElement? resultData = null;
if (completedEvent.Data != null && JsonSerializer.IsReflectionEnabledByDefault)
{
resultData = JsonSerializer.SerializeToElement(
completedEvent.Data,
OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
var item = new ExecutorActionItemResource
{
Id = itemId,
ExecutorId = completedEvent.ExecutorId,
Status = "completed",
Result = resultData,
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
yield return new StreamingOutputItemDone
{
SequenceNumber = seq.Increment(),
OutputIndex = outputIndex,
Item = item
};
}
else if (workflowEvent is ExecutorFailedEvent failedEvent)
{
// Reuse the item ID from the invoked event, or generate a new one if not found
var itemId = executorItemIds.TryGetValue(failedEvent.ExecutorId, out var existingId)
? existingId
: IdGenerator.NewId(prefix: "item");
// Remove from tracking as this executor run has now failed
executorItemIds.Remove(failedEvent.ExecutorId);
var item = new ExecutorActionItemResource
{
Id = itemId,
ExecutorId = failedEvent.ExecutorId,
Status = "failed",
Error = failedEvent.Data?.ToString(),
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
yield return new StreamingOutputItemDone
{
SequenceNumber = seq.Increment(),
OutputIndex = outputIndex,
Item = item
};
}
else
{
// For other workflow events (not executor-specific), keep the old format as fallback
yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex);
}
continue;
}
@@ -45,6 +45,7 @@ internal sealed class ItemResourceConverter : JsonConverter<ItemResource>
MCPApprovalRequestItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalRequestItemResource),
MCPApprovalResponseItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalResponseItemResource),
MCPCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPCallItemResource),
ExecutorActionItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ExecutorActionItemResource),
_ => null
};
}
@@ -106,6 +107,9 @@ internal sealed class ItemResourceConverter : JsonConverter<ItemResource>
case MCPCallItemResource mcpCall:
JsonSerializer.Serialize(writer, mcpCall, OpenAIHostingJsonContext.Default.MCPCallItemResource);
break;
case ExecutorActionItemResource executorAction:
JsonSerializer.Serialize(writer, executorAction, OpenAIHostingJsonContext.Default.ExecutorActionItemResource);
break;
default:
throw new JsonException($"Unknown item type: {value.GetType().Name}");
}
@@ -888,3 +888,47 @@ internal sealed class MCPCallItemResource : ItemResource
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// An executor action item resource for workflow execution visualization.
/// </summary>
internal sealed class ExecutorActionItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for executor action items.
/// </summary>
public const string ItemType = "executor_action";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The executor identifier.
/// </summary>
[JsonPropertyName("executor_id")]
public required string ExecutorId { get; init; }
/// <summary>
/// The execution status: "in_progress", "completed", "failed", or "cancelled".
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// The executor result data (for completed status).
/// </summary>
[JsonPropertyName("result")]
public JsonElement? Result { get; init; }
/// <summary>
/// The error message (for failed status).
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// The creation timestamp.
/// </summary>
[JsonPropertyName("created_at")]
public long CreatedAt { get; init; }
}
@@ -565,7 +565,7 @@ internal sealed class StreamingWorkflowEventComplete : StreamingResponseEvent
/// <summary>
/// The constant event type identifier for workflow event events.
/// </summary>
public const string EventType = "response.workflow_event.complete";
public const string EventType = "response.workflow_event.completed";
/// <inheritdoc/>
[JsonIgnore]
@@ -642,6 +642,7 @@ class ChatAgent(BaseAgent):
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
This overrides the model_id set in the chat client if it contains one.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
@@ -690,7 +691,7 @@ class ChatAgent(BaseAgent):
self._local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
self.chat_options = ChatOptions(
model_id=model_id,
model_id=model_id or (str(chat_client.model_id) if hasattr(chat_client, "model_id") else None),
allow_multiple_tool_calls=allow_multiple_tool_calls,
conversation_id=conversation_id,
frequency_penalty=frequency_penalty,
@@ -846,6 +846,7 @@ def _trace_get_response(
kwargs.get("model_id")
or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None)
or getattr(self, "model_id", None)
or "unknown"
)
service_url = str(
service_url_func()
@@ -933,6 +934,7 @@ def _trace_get_streaming_response(
kwargs.get("model_id")
or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None)
or getattr(self, "model_id", None)
or "unknown"
)
service_url = str(
service_url_func()
@@ -1324,7 +1326,10 @@ def _get_span(
attributes: dict[str, Any],
span_name_attribute: str,
) -> Generator["trace.Span", Any, Any]:
"""Start a span for a agent run."""
"""Start a span for a agent run.
Note: `attributes` must contain the `span_name_attribute` key.
"""
span = get_tracer().start_span(f"{attributes[OtelAttr.OPERATION]} {attributes[span_name_attribute]}")
span.set_attributes(attributes)
with trace.use_span(
@@ -1353,7 +1358,8 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]:
attributes[SpanAttributes.LLM_SYSTEM] = system_name
if provider_name := kwargs.get("provider_name"):
attributes[OtelAttr.PROVIDER_NAME] = provider_name
attributes[SpanAttributes.LLM_REQUEST_MODEL] = kwargs.get("model", "unknown")
if model_id := kwargs.get("model", chat_options.model_id):
attributes[SpanAttributes.LLM_REQUEST_MODEL] = model_id
if service_url := kwargs.get("service_url"):
attributes[OtelAttr.ADDRESS] = service_url
if conversation_id := kwargs.get("conversation_id", chat_options.conversation_id):
@@ -279,6 +279,45 @@ async def test_chat_client_streaming_observability(
assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None
async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter):
"""Test telemetry shouldn't fail when the model_id is not provided for unknown reason."""
client = use_observability(mock_chat_client)()
messages = [ChatMessage(role=Role.USER, text="Test")]
span_exporter.clear()
response = await client.get_response(messages=messages)
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "chat unknown"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
async def test_chat_client_streaming_without_model_id_observability(
mock_chat_client, span_exporter: InMemorySpanExporter
):
"""Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason."""
client = use_observability(mock_chat_client)()
messages = [ChatMessage(role=Role.USER, text="Test")]
span_exporter.clear()
# Collect all yielded updates
updates = []
async for update in client.get_streaming_response(messages=messages):
updates.append(update)
# Verify we got the expected updates, this shouldn't be dependent on otel
assert len(updates) == 2
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "chat unknown"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
def test_prepend_user_agent_with_none_value():
"""Test prepend user agent with None value in headers."""
headers = {"User-Agent": None}
@@ -368,6 +407,7 @@ def mock_chat_agent():
self.name = "test_agent"
self.display_name = "Test Agent"
self.description = "Test agent description"
self.chat_options = ChatOptions(model_id="TestModel")
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
@@ -405,7 +445,7 @@ async def test_agent_instrumentation_enabled(
assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id"
assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "TestModel"
assert span.attributes[OtelAttr.INPUT_TOKENS] == 15
assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 25
if enable_sensitive_data:
@@ -433,7 +473,7 @@ async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator(
assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id"
assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "TestModel"
if enable_sensitive_data:
assert span.attributes.get(OtelAttr.OUTPUT_MESSAGES) is not None # Streaming, so no usage yet
@@ -397,6 +397,7 @@ class DevServer:
ui_mode=self.mode, # type: ignore[arg-type]
version=__version__,
framework="agent_framework",
runtime="python", # Python DevUI backend
capabilities={
"tracing": os.getenv("ENABLE_OTEL") == "true",
"openai_proxy": openai_executor.is_configured,
@@ -386,6 +386,9 @@ class MetaResponse(BaseModel):
framework: str = "agent_framework"
"""Backend framework identifier."""
runtime: Literal["python", "dotnet"] = "python"
"""Backend runtime/language - 'python' or 'dotnet' for deployment guides and feature availability."""
capabilities: dict[str, bool] = {}
"""Server capabilities (e.g., tracing, openai_proxy)."""
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -104,6 +104,7 @@ export default function App() {
useDevUIStore.getState().setServerMeta({
uiMode: meta.ui_mode,
runtime: meta.runtime,
capabilities: meta.capabilities,
authRequired: meta.auth_required,
});
@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
interface ExecutorRun {
executorId: string;
@@ -112,24 +113,28 @@ function ExecutorRunItem({
if (canExpand) onToggle();
}}
>
<div className="flex items-center gap-2 mb-1">
{canExpand && (
<div className="text-muted-foreground">
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</div>
)}
{getStateIcon(run.state)}
<span className="font-medium text-sm truncate flex-1">
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
<div className="w-3 text-muted-foreground">
{canExpand && (
<>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</>
)}
</div>
<div>{getStateIcon(run.state)}</div>
<span className="font-medium text-sm truncate overflow-hidden">
{run.executorName}
</span>
{run.runNumber > 1 && (
<Badge variant="outline" className="text-xs">
{run.runNumber > 1 ? (
<Badge variant="outline" className="text-xs whitespace-nowrap">
Run #{run.runNumber}
</Badge>
) : (
<div></div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
@@ -228,7 +233,7 @@ export function ExecutionTimeline({
runs.push({
executorId,
executorName: executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
@@ -244,7 +249,7 @@ export function ExecutionTimeline({
runs.push({
executorId,
executorName: executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
@@ -310,7 +315,7 @@ export function ExecutionTimeline({
runs.push({
executorId,
executorName: executorId,
executorName: truncateText(executorId, 35),
itemId: syntheticItemId,
state: "running",
output: itemOutputs[syntheticItemId] || "",
@@ -6,6 +6,7 @@ import {
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateText } from "@/utils/workflow-utils";
export type ExecutorState =
| "pending"
@@ -82,11 +83,13 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const details = [];
if (nodeData.error && typeof nodeData.error === "string") {
// Truncate error to first 150 characters for node display
const truncatedError = truncateText(nodeData.error, 150);
details.push(
<div key="error" className="mb-2">
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
{nodeData.error}
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words">
{truncatedError}
</div>
</div>
);
@@ -28,6 +28,7 @@ export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
const addSession = useDevUIStore((state) => state.addSession);
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const runtime = useDevUIStore((state) => state.runtime);
const [creatingSession, setCreatingSession] = useState(false);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
@@ -63,14 +64,19 @@ export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
}
} catch (error) {
console.error("Failed to load workflow conversations:", error);
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
// Silently handle for .NET backend (doesn't support conversations yet)
// Only show error for Python backend where this is unexpected
if (runtime !== "dotnet") {
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
}
} finally {
setLoadingSessions(false);
}
}, [workflowId, currentSession, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
}, [workflowId, currentSession, runtime, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
// Load sessions on mount
useEffect(() => {
@@ -433,6 +433,7 @@ export function WorkflowView({
const addSession = useDevUIStore((state) => state.addSession);
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const runtime = useDevUIStore((state) => state.runtime);
// Selected checkpoint for resume (local state)
const [selectedCheckpointId, setSelectedCheckpointId] = useState<
@@ -595,14 +596,23 @@ export function WorkflowView({
}
} catch (error) {
console.error("Failed to load sessions:", error);
addToast({ message: "Failed to load sessions", type: "error" });
// Silently handle for .NET backend (doesn't support conversations yet)
// Only show error for Python backend where this is unexpected
if (runtime !== "dotnet") {
addToast({
message: "Failed to load sessions",
type: "error"
});
}
} finally {
setLoadingSessions(false);
}
};
loadSessions();
}, [workflowInfo?.id]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowInfo?.id, runtime]);
// Handle session change - just clear checkpoint selection
const handleSessionChange = useCallback(
@@ -41,8 +41,8 @@ export function SettingsModal({
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, and auth status from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired } = useDevUIStore();
// OpenAI proxy mode, Azure deployment, auth status, and server capabilities from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -128,19 +128,21 @@ export function SettingsModal({
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("proxy")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "proxy"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
OpenAI Proxy
{activeTab === "proxy" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{serverCapabilities.openai_proxy && (
<button
onClick={() => setActiveTab("proxy")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "proxy"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
OpenAI Proxy
{activeTab === "proxy" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
@@ -281,7 +283,8 @@ export function SettingsModal({
</div>
)}
{/* Deployment Setting */}
{/* Deployment Setting - Only show if backend supports deployment */}
{serverCapabilities.deployment && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@@ -331,10 +334,11 @@ export function SettingsModal({
</div>
</details>
</div>
)}
</div>
)}
{activeTab === "proxy" && (
{activeTab === "proxy" && serverCapabilities.openai_proxy && (
<div className="space-y-6 pt-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -274,7 +274,7 @@ class ApiClient {
async getAgentInfo(agentId: string): Promise<AgentInfo> {
// Get detailed entity info from unified endpoint
return this.request<AgentInfo>(`/v1/entities/${agentId}/info`);
return this.request<AgentInfo>(`/v1/entities/${agentId}/info?type=agent`);
}
async getWorkflowInfo(
@@ -282,7 +282,7 @@ class ApiClient {
): Promise<import("@/types").WorkflowInfo> {
// Get detailed entity info from unified endpoint
return this.request<import("@/types").WorkflowInfo>(
`/v1/entities/${workflowId}/info`
`/v1/entities/${workflowId}/info?type=workflow`
);
}
@@ -79,9 +79,11 @@ interface DevUIState {
// Server Meta Slice
uiMode: "developer" | "user";
runtime: "python" | "dotnet";
serverCapabilities: {
tracing: boolean;
openai_proxy: boolean;
deployment: boolean;
};
authRequired: boolean;
@@ -161,7 +163,7 @@ interface DevUIActions {
toggleOAIMode: () => void;
// Server Meta Actions
setServerMeta: (meta: { uiMode: "developer" | "user"; capabilities: { tracing: boolean; openai_proxy: boolean }; authRequired: boolean }) => void;
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean }) => void;
// Deployment Actions
startDeployment: () => void;
@@ -240,9 +242,11 @@ export const useDevUIStore = create<DevUIStore>()(
// Server Meta State
uiMode: "developer", // Default to developer mode
runtime: "python", // Default to Python runtime
serverCapabilities: {
tracing: false,
openai_proxy: false,
deployment: false,
},
authRequired: false,
@@ -505,6 +509,7 @@ export const useDevUIStore = create<DevUIStore>()(
setServerMeta: (meta) =>
set({
uiMode: meta.uiMode,
runtime: meta.runtime,
serverCapabilities: meta.capabilities,
authRequired: meta.authRequired,
}),
@@ -157,9 +157,11 @@ export interface MetaResponse {
ui_mode: "developer" | "user";
version: string;
framework: string;
runtime: "python" | "dotnet";
capabilities: {
tracing: boolean;
openai_proxy: boolean;
deployment: boolean;
};
auth_required: boolean;
}
@@ -11,6 +11,23 @@ import type {
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
/**
* Truncates text that exceeds the maximum length and appends ellipsis
* @param text - The text to truncate
* @param maxLength - Maximum length before truncation (default: 50)
* @param ellipsis - String to append when truncated (default: '...')
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
*
* @example
* truncateText('Hello World', 5) // 'Hello...'
* truncateText('Short', 10) // 'Short'
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
*/
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + ellipsis;
}
export interface WorkflowDumpExecutor {
id: string;
type: string;
@@ -191,11 +191,11 @@ dependencies
Besides the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
#### Agent Overview dashboard
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-agent>
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
![Agent Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-agent.gif)
#### Workflow Overview dashboard
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-workflow>
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
![Workflow Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-workflow.gif)
## Aspire Dashboard