Merge branch 'main' into feature-foundry-agents

This commit is contained in:
Chris
2025-11-10 21:09:06 -08:00
committed by GitHub
Unverified
28 changed files with 674 additions and 309 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>
@@ -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;