mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
fix: normalize runtime agent identities
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user