diff --git a/BACKEND_UI_CHANGES.md b/BACKEND_UI_CHANGES.md index 89749ad..51445f3 100644 --- a/BACKEND_UI_CHANGES.md +++ b/BACKEND_UI_CHANGES.md @@ -61,6 +61,8 @@ The .NET sidecar emits `agent-activity` events when the workflow stream exposes In practice, `thinking` and `completed` are driven by executor lifecycle events, while `tool-calling` and `handoff` are emitted when request-info payloads expose recognizable tool-call or handoff targets. +One important runtime nuance is that MAF / Copilot executor identifiers are not always the raw configured agent IDs. Real streams can emit composite values such as `Architect_agent_concurrent_architect` or generic names such as `assistant`. The sidecar therefore normalizes these runtime identifiers back to the configured pattern agents before emitting `agent-activity` events or choosing the visible author name. + ### .NET sidecar changes In the sidecar's turn-execution pipeline, emit a new JSON event type alongside the existing `turn-delta` and `turn-complete` events: diff --git a/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs b/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs new file mode 100644 index 0000000..c21234a --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/Services/AgentIdentityResolver.cs @@ -0,0 +1,176 @@ +using System.Text; +using Kopaya.AgentHost.Contracts; + +namespace Kopaya.AgentHost.Services; + +internal readonly record struct AgentIdentity(string AgentId, string AgentName); + +internal static class AgentIdentityResolver +{ + public static bool TryResolveKnownAgentIdentity( + PatternDefinitionDto pattern, + string? agentIdentifier, + out AgentIdentity agent) + { + agent = default; + if (string.IsNullOrWhiteSpace(agentIdentifier)) + { + return false; + } + + PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier); + if (match is null + && IsGenericAssistantIdentifier(agentIdentifier) + && pattern.Agents.Count == 1) + { + match = pattern.Agents[0]; + } + + if (match is null) + { + return false; + } + + agent = ToAgentIdentity(match); + return true; + } + + public static AgentIdentity ResolveAgentIdentity( + PatternDefinitionDto pattern, + string? agentId, + string? agentName) + { + PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId) + ?? FindKnownAgent(pattern, agentName); + + if (match is null + && pattern.Agents.Count == 1 + && (IsGenericAssistantIdentifier(agentId) || IsGenericAssistantIdentifier(agentName))) + { + match = pattern.Agents[0]; + } + + if (match is not null) + { + return ToAgentIdentity(match); + } + + string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId) + ? agentId + : agentName ?? "agent"; + + if (!string.IsNullOrWhiteSpace(agentName)) + { + return new AgentIdentity(resolvedAgentId, agentName); + } + + return new AgentIdentity(resolvedAgentId, resolvedAgentId); + } + + public static string ResolveDisplayAuthorName( + PatternDefinitionDto pattern, + string? primaryIdentifier, + string? fallbackIdentifier = null) + { + if (TryResolveKnownAgentIdentity(pattern, primaryIdentifier, out AgentIdentity primaryAgent)) + { + return primaryAgent.AgentName; + } + + if (TryResolveKnownAgentIdentity(pattern, fallbackIdentifier, out AgentIdentity fallbackAgent)) + { + return fallbackAgent.AgentName; + } + + if (!string.IsNullOrWhiteSpace(primaryIdentifier)) + { + return primaryIdentifier; + } + + if (!string.IsNullOrWhiteSpace(fallbackIdentifier)) + { + return fallbackIdentifier; + } + + return "assistant"; + } + + private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate) + { + return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate)); + } + + private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent) + { + return new AgentIdentity( + agent.Id, + string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name); + } + + private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + if (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase) + || string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string normalizedCandidate = NormalizeComparisonKey(candidate); + string normalizedId = NormalizeComparisonKey(agent.Id); + string normalizedName = NormalizeComparisonKey(agent.Name); + + if (normalizedCandidate.Length == 0) + { + return false; + } + + if (normalizedCandidate == normalizedId + || normalizedCandidate == normalizedName) + { + return true; + } + + if (normalizedId.Length > 0 + && normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal)) + { + return true; + } + + return normalizedId.Length > 0 + && normalizedName.Length > 0 + && normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal) + && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal); + } + + private static bool IsGenericAssistantIdentifier(string? candidate) + { + return string.Equals( + NormalizeComparisonKey(candidate), + "assistant", + StringComparison.Ordinal); + } + + private static string NormalizeComparisonKey(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + StringBuilder builder = new(value.Length); + foreach (char character in value) + { + if (char.IsLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + } + } + + return builder.ToString(); + } +} diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index b2d4f7a..644a32c 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -23,8 +23,6 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions"); private readonly PatternValidator _patternValidator; - private readonly record struct AgentIdentity(string AgentId, string AgentName); - public CopilotWorkflowRunner(PatternValidator patternValidator) { _patternValidator = patternValidator; @@ -59,7 +57,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) { if (evt is ExecutorInvokedEvent invoked - && TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent)) + && AgentIdentityResolver.TryResolveKnownAgentIdentity( + command.Pattern, + invoked.ExecutorId, + out AgentIdentity invokedAgent)) { activeAgent = invokedAgent; await EmitThinkingIfNeeded( @@ -82,16 +83,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } else if (evt is AgentResponseUpdateEvent update && !string.IsNullOrEmpty(update.Update.Text)) { + AgentIdentity? updateAgent = null; + string authorName = update.ExecutorId; + if (AgentIdentityResolver.TryResolveKnownAgentIdentity( + command.Pattern, + update.ExecutorId, + out AgentIdentity resolvedUpdateAgent)) + { + updateAgent = resolvedUpdateAgent; + authorName = resolvedUpdateAgent.AgentName; + } + string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}"; - StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId); + StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName); segment.Content.Append(update.Update.Text); - if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent)) + if (updateAgent.HasValue) { - activeAgent = updateAgent; + activeAgent = updateAgent.Value; await EmitThinkingIfNeeded( command, - updateAgent, + updateAgent.Value, startedAgents, onActivity).ConfigureAwait(false); } @@ -102,12 +114,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner RequestId = command.RequestId, SessionId = command.SessionId, MessageId = messageId, - AuthorName = update.ExecutorId, + AuthorName = authorName, ContentDelta = update.Update.Text, }).ConfigureAwait(false); } else if (evt is ExecutorCompletedEvent completed - && TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent)) + && AgentIdentityResolver.TryResolveKnownAgentIdentity( + command.Pattern, + completed.ExecutorId, + out AgentIdentity completedAgent)) { if (completedAgents.Add(completedAgent.AgentId)) { @@ -207,7 +222,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { foreach (ChatMessageDto message in messages) { - if (!TryResolveKnownAgentIdentity(command.Pattern, message.AuthorName, out AgentIdentity messageAgent)) + if (!AgentIdentityResolver.TryResolveKnownAgentIdentity( + command.Pattern, + message.AuthorName, + out AgentIdentity messageAgent)) { continue; } @@ -236,7 +254,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget); - agent = ResolveAgentIdentity( + agent = AgentIdentityResolver.ResolveAgentIdentity( pattern, GetStringProperty(target, "Id"), GetStringProperty(target, "Name")); @@ -275,65 +293,6 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return false; } - private static bool TryResolveKnownAgentIdentity( - PatternDefinitionDto pattern, - string? agentIdentifier, - out AgentIdentity agent) - { - agent = default; - if (string.IsNullOrWhiteSpace(agentIdentifier)) - { - return false; - } - - PatternAgentDefinitionDto? match = pattern.Agents.FirstOrDefault(agent => - MatchesAgent(agent, agentIdentifier)); - - if (match is null) - { - return false; - } - - agent = new AgentIdentity( - match.Id, - string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name); - return true; - } - - private static AgentIdentity ResolveAgentIdentity( - PatternDefinitionDto pattern, - string? agentId, - string? agentName) - { - PatternAgentDefinitionDto? match = pattern.Agents.FirstOrDefault(agent => - MatchesAgent(agent, agentId) || MatchesAgent(agent, agentName)); - - if (match is not null) - { - return new AgentIdentity( - match.Id, - string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name); - } - - string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId) - ? agentId - : agentName ?? "agent"; - - if (!string.IsNullOrWhiteSpace(agentName)) - { - return new AgentIdentity(resolvedAgentId, agentName); - } - - return new AgentIdentity(resolvedAgentId, resolvedAgentId); - } - - private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate) - { - return !string.IsNullOrWhiteSpace(candidate) - && (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase) - || string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase)); - } - private static Type? LoadType(string assemblyQualifiedName) { return Type.GetType(assemblyQualifiedName, throwOnError: false); @@ -386,7 +345,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { Id = segment?.MessageId ?? $"{command.RequestId}-final-{segmentIndex}", Role = message.Role == ChatRole.System ? "system" : "assistant", - AuthorName = message.AuthorName ?? segment?.AuthorName ?? "assistant", + AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName( + command.Pattern, + message.AuthorName, + segment?.AuthorName), Content = message.Text ?? segment?.Content.ToString() ?? string.Empty, CreatedAt = DateTimeOffset.UtcNow.ToString("O"), }); @@ -398,7 +360,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { Id = segment.MessageId, Role = "assistant", - AuthorName = segment.AuthorName, + AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), Content = segment.Content.ToString(), CreatedAt = DateTimeOffset.UtcNow.ToString("O"), })); diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs new file mode 100644 index 0000000..f154c4a --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/AgentIdentityResolverTests.cs @@ -0,0 +1,122 @@ +using Kopaya.AgentHost.Contracts; +using Kopaya.AgentHost.Services; + +namespace Kopaya.AgentHost.Tests; + +public sealed class AgentIdentityResolverTests +{ + [Fact] + public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier() + { + PatternDefinitionDto pattern = CreatePattern( + [ + CreateAgent(id: "agent-concurrent-architect", name: "Architect"), + CreateAgent(id: "agent-concurrent-product", name: "Product"), + ]); + + bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity( + pattern, + "Architect_agent_concurrent_architect", + out AgentIdentity agent); + + Assert.True(resolved); + Assert.Equal("agent-concurrent-architect", agent.AgentId); + Assert.Equal("Architect", agent.AgentName); + } + + [Fact] + public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId() + { + PatternDefinitionDto pattern = CreatePattern( + [ + CreateAgent(id: "agent-single-primary", name: "Primary Agent"), + ], + mode: "single"); + + bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity( + pattern, + "Primary_Agent_agent_single_primary", + out AgentIdentity agent); + + Assert.True(resolved); + Assert.Equal("agent-single-primary", agent.AgentId); + Assert.Equal("Primary Agent", agent.AgentName); + } + + [Fact] + public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent() + { + PatternDefinitionDto pattern = CreatePattern( + [ + CreateAgent(id: "agent-single-primary", name: "Primary Agent"), + ], + mode: "single"); + + bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity( + pattern, + "assistant", + out AgentIdentity agent); + + Assert.True(resolved); + Assert.Equal("agent-single-primary", agent.AgentId); + Assert.Equal("Primary Agent", agent.AgentName); + } + + [Fact] + public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentPattern() + { + PatternDefinitionDto pattern = CreatePattern( + [ + CreateAgent(id: "agent-concurrent-architect", name: "Architect"), + CreateAgent(id: "agent-concurrent-product", name: "Product"), + ]); + + bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity( + pattern, + "assistant", + out _); + + Assert.False(resolved); + } + + [Fact] + public void ResolveDisplayAuthorName_UsesCanonicalAgentName() + { + PatternDefinitionDto pattern = CreatePattern( + [ + CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"), + ], + mode: "single"); + + string authorName = AgentIdentityResolver.ResolveDisplayAuthorName( + pattern, + "Implementer_agent_concurrent_implementer"); + + Assert.Equal("Implementer", authorName); + } + + private static PatternDefinitionDto CreatePattern( + IReadOnlyList agents, + string mode = "concurrent") + { + return new PatternDefinitionDto + { + Id = $"{mode}-pattern", + Name = "Pattern", + Mode = mode, + Availability = "available", + Agents = agents, + }; + } + + private static PatternAgentDefinitionDto CreateAgent(string id, string name) + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Model = "gpt-5.4", + Instructions = "Help with the request.", + }; + } +}