mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 13:18:46 +02:00
fix: preserve handoff agent identity
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||||
|
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
List<IAsyncDisposable> disposables = [];
|
List<IAsyncDisposable> disposables = [];
|
||||||
@@ -53,6 +54,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
},
|
},
|
||||||
WorkingDirectory = command.ProjectPath,
|
WorkingDirectory = command.ProjectPath,
|
||||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||||
|
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
|
||||||
Streaming = true,
|
Streaming = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Eryx.AgentHost.Contracts;
|
using Eryx.AgentHost.Contracts;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Eryx.AgentHost.Services;
|
namespace Eryx.AgentHost.Services;
|
||||||
@@ -8,6 +9,7 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
{
|
{
|
||||||
private readonly RunTurnCommandDto _command;
|
private readonly RunTurnCommandDto _command;
|
||||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||||
private int _fallbackMessageIndex;
|
private int _fallbackMessageIndex;
|
||||||
|
|
||||||
@@ -54,6 +56,34 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
|
||||||
|
{
|
||||||
|
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||||
|
_command.Pattern,
|
||||||
|
agentDefinition.Id,
|
||||||
|
agentDefinition.Name);
|
||||||
|
|
||||||
|
switch (sessionEvent)
|
||||||
|
{
|
||||||
|
case AssistantMessageDeltaEvent messageDelta when !string.IsNullOrWhiteSpace(messageDelta.Data?.MessageId):
|
||||||
|
RecordObservedAgentForMessage(agent, messageDelta.Data!.MessageId);
|
||||||
|
break;
|
||||||
|
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||||
|
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||||
|
break;
|
||||||
|
case AssistantReasoningDeltaEvent:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||||
|
{
|
||||||
|
agent = default;
|
||||||
|
return !string.IsNullOrWhiteSpace(messageId)
|
||||||
|
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
||||||
|
}
|
||||||
|
|
||||||
public string CreateMessageId(string? messageId)
|
public string CreateMessageId(string? messageId)
|
||||||
{
|
{
|
||||||
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
||||||
@@ -76,6 +106,12 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
||||||
|
{
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_observedAgentsByMessageId[messageId] = agent;
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateCompletedMessages(
|
public void UpdateCompletedMessages(
|
||||||
IReadOnlyList<ChatMessage> allMessages,
|
IReadOnlyList<ChatMessage> allMessages,
|
||||||
IReadOnlyList<ChatMessage> inputMessages)
|
IReadOnlyList<ChatMessage> inputMessages)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
state.ToolNamesByCallId,
|
state.ToolNamesByCallId,
|
||||||
onApproval,
|
onApproval,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
|
(agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||||
@@ -136,7 +137,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
{
|
{
|
||||||
AgentIdentity? updateAgent = null;
|
AgentIdentity? updateAgent = null;
|
||||||
string authorName = update.ExecutorId;
|
string authorName = update.ExecutorId;
|
||||||
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent))
|
||||||
|
{
|
||||||
|
updateAgent = observedMessageAgent;
|
||||||
|
authorName = observedMessageAgent.AgentName;
|
||||||
|
}
|
||||||
|
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||||
command.Pattern,
|
command.Pattern,
|
||||||
update.ExecutorId,
|
update.ExecutorId,
|
||||||
state.ActiveAgent,
|
state.ActiveAgent,
|
||||||
|
|||||||
@@ -451,11 +451,59 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("A plus", first.Content);
|
Assert.Equal("A plus", first.Content);
|
||||||
},
|
},
|
||||||
second =>
|
second =>
|
||||||
|
{
|
||||||
|
Assert.Equal("msg-2", second.MessageId);
|
||||||
|
Assert.Equal("Implementer", second.AuthorName);
|
||||||
|
Assert.Equal("B", second.Content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_TracksObservedAgentByMessageId()
|
||||||
|
{
|
||||||
|
CopilotTurnExecutionState state = new(CreateHandoffCommand());
|
||||||
|
SessionEvent sessionEvent = SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
{
|
{
|
||||||
Assert.Equal("msg-2", second.MessageId);
|
"type": "assistant.message_delta",
|
||||||
Assert.Equal("Implementer", second.AuthorName);
|
"data": {
|
||||||
Assert.Equal("B", second.Content);
|
"messageId": "msg-7",
|
||||||
});
|
"deltaContent": "Done."
|
||||||
|
},
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"timestamp": "2026-03-24T00:00:00Z"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(CreateAgent("agent-handoff-ux", "UX Specialist"), sessionEvent);
|
||||||
|
|
||||||
|
Assert.True(state.TryResolveObservedAgentForMessage("msg-7", out AgentIdentity observedAgent));
|
||||||
|
Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
|
||||||
|
Assert.Equal("UX Specialist", observedAgent.AgentName);
|
||||||
|
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_UsesReasoningDeltasToTrackActiveAgent()
|
||||||
|
{
|
||||||
|
CopilotTurnExecutionState state = new(CreateHandoffCommand());
|
||||||
|
SessionEvent sessionEvent = SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.reasoning_delta",
|
||||||
|
"data": {
|
||||||
|
"reasoningId": "reasoning-1",
|
||||||
|
"deltaContent": "Planning."
|
||||||
|
},
|
||||||
|
"id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"timestamp": "2026-03-24T00:00:00Z"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(CreateAgent("agent-handoff-ux", "UX Specialist"), sessionEvent);
|
||||||
|
|
||||||
|
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||||
|
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -770,6 +818,27 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RunTurnCommandDto CreateHandoffCommand()
|
||||||
|
{
|
||||||
|
return new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = "turn-1",
|
||||||
|
SessionId = "session-1",
|
||||||
|
Pattern = new PatternDefinitionDto
|
||||||
|
{
|
||||||
|
Id = "pattern-handoff",
|
||||||
|
Name = "Handoff Flow",
|
||||||
|
Mode = "handoff",
|
||||||
|
Availability = "available",
|
||||||
|
Agents =
|
||||||
|
[
|
||||||
|
CreateAgent("agent-handoff-triage", "Triage"),
|
||||||
|
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateApprovalCommand()
|
private static RunTurnCommandDto CreateApprovalCommand()
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
|
|||||||
Reference in New Issue
Block a user