fix: normalize runtime agent identities

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 16:09:13 +01:00
co-authored by Copilot
parent e6826f1324
commit 0b15af444d
4 changed files with 334 additions and 72 deletions
+2
View File
@@ -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:
@@ -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();
}
}
@@ -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"),
}));
@@ -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<PatternAgentDefinitionDto> 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.",
};
}
}