refactor: decompose CopilotWorkflowRunner

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 21:10:06 +01:00
co-authored by Copilot
parent b4de1346fd
commit f1a96773c8
9 changed files with 1425 additions and 890 deletions
+2
View File
@@ -52,5 +52,7 @@ These instructions apply to any automated or semi-automated agent working in thi
- Use Bun for dependency management and script execution.
- Prefer repository-local tooling over global machine state whenever possible.
- Keep changes focused and reviewable. Avoid mixing unrelated concerns into a single change.
- When the user asks only for a plan, stay in planning flow: analyze the codebase, clarify scope as needed, and write or update the plan, but do not begin implementation until the user explicitly asks you to start the work.
- Do not trigger workflow or mode transitions that implicitly approve or begin implementation unless the user has explicitly requested that transition.
- Always commit completed repository changes before handing work off. If unrelated pre-existing changes are present in the worktree, stop and ask the user how to proceed before creating the commit.
- Do not mark work as done until both the implementation and its verification are complete.
@@ -0,0 +1,161 @@
using GitHub.Copilot.SDK;
using Eryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.GitHub.Copilot;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services;
internal sealed class CopilotAgentBundle : IAsyncDisposable
{
private readonly List<IAsyncDisposable> _disposables = [];
private CopilotAgentBundle(IReadOnlyList<AIAgent> agents)
{
Agents = agents;
}
public IReadOnlyList<AIAgent> Agents { get; }
public static async Task<CopilotAgentBundle> CreateAsync(
RunTurnCommandDto command,
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
CancellationToken cancellationToken)
{
List<IAsyncDisposable> disposables = [];
List<AIAgent> agents = [];
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase);
SessionToolingBundle? toolingBundle = isScratchpad
? null
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
.ConfigureAwait(false);
if (toolingBundle is not null)
{
disposables.Add(toolingBundle);
}
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
{
CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
SessionConfig sessionConfig = new()
{
Model = definition.Model,
ReasoningEffort = definition.ReasoningEffort,
SystemMessage = new SystemMessageConfig
{
Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind),
},
WorkingDirectory = command.ProjectPath,
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
Streaming = true,
};
if (isScratchpad)
{
sessionConfig.AvailableTools = [];
}
else if (toolingBundle is not null)
{
if (toolingBundle.McpServers.Count > 0)
{
sessionConfig.McpServers = toolingBundle.McpServers;
}
if (toolingBundle.Tools.Count > 0)
{
sessionConfig.Tools = toolingBundle.Tools.ToList();
}
}
GitHubCopilotAgent agent = new(
client,
sessionConfig,
ownsClient: true,
id: definition.Id,
name: definition.Name,
description: definition.Description);
agents.Add(agent);
disposables.Add(agent);
}
CopilotAgentBundle bundle = new(agents);
bundle._disposables.AddRange(disposables);
return bundle;
}
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
{
return pattern.Mode switch
{
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents),
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents),
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, Agents),
"handoff" => BuildHandoffWorkflow(pattern),
"group-chat" => BuildGroupChatWorkflow(pattern),
"magentic" => throw new NotSupportedException(
pattern.UnavailabilityReason
?? "Magentic orchestration is not yet supported in the .NET Agent Framework."),
_ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."),
};
}
public async ValueTask DisposeAsync()
{
foreach (IAsyncDisposable disposable in _disposables)
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
}
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
{
AIAgent firstAgent = Agents[0];
PatternAgentDefinitionDto triageDefinition = pattern.Agents[0];
IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists =
Agents.Skip(1)
.Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition))
.ToList();
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent)
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists)
{
builder = builder.WithHandoff(
firstAgent,
specialist,
HandoffWorkflowGuidance.CreateForwardReason(definition));
}
foreach ((AIAgent specialist, _) in specialists)
{
builder = builder.WithHandoff(
specialist,
firstAgent,
HandoffWorkflowGuidance.CreateReturnReason(triageDefinition));
}
return builder.Build();
}
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
{
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
return AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents =>
new RoundRobinGroupChatManager(agents)
{
MaximumIterationCount = maximumIterations,
})
.AddParticipants(Agents.ToArray())
.Build();
}
}
@@ -0,0 +1,265 @@
using System.Collections.Concurrent;
using GitHub.Copilot.SDK;
using Eryx.AgentHost.Contracts;
namespace Eryx.AgentHost.Services;
internal sealed class CopilotApprovalCoordinator
{
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
public Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
if (string.IsNullOrWhiteSpace(command.ApprovalId))
{
throw new InvalidOperationException("Approval ID is required.");
}
if (!_pendingApprovals.TryGetValue(command.ApprovalId, out PendingApprovalRequest? pending))
{
throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is not pending.");
}
PermissionRequestResultKind decision = command.Decision.Trim().ToLowerInvariant() switch
{
"approved" => PermissionRequestResultKind.Approved,
"rejected" => PermissionRequestResultKind.DeniedInteractivelyByUser,
_ => throw new InvalidOperationException(
$"Unsupported approval decision \"{command.Decision}\"."),
};
if (!pending.Decision.TrySetResult(decision))
{
throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is no longer pending.");
}
return Task.CompletedTask;
}
public async Task<PermissionRequestResult> RequestApprovalAsync(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken)
{
TryGetApprovalToolName(request, toolNamesByCallId, out string? toolName);
if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName))
{
return new PermissionRequestResult
{
Kind = PermissionRequestResultKind.Approved,
};
}
string approvalId = CreateApprovalRequestId();
TaskCompletionSource<PermissionRequestResultKind> decisionSource =
new(TaskCreationOptions.RunContinuationsAsynchronously);
PendingApprovalRequest pending = new(
command.RequestId,
command.SessionId,
approvalId,
decisionSource);
if (!_pendingApprovals.TryAdd(approvalId, pending))
{
throw new InvalidOperationException($"Approval \"{approvalId}\" is already pending.");
}
try
{
await onApproval(BuildPermissionApprovalEvent(command, agent, request, invocation, approvalId, toolName))
.ConfigureAwait(false);
using CancellationTokenRegistration registration = cancellationToken.Register(
static state =>
{
((TaskCompletionSource<PermissionRequestResultKind>)state!)
.TrySetCanceled();
},
decisionSource);
PermissionRequestResultKind decision = await decisionSource.Task.ConfigureAwait(false);
return new PermissionRequestResult
{
Kind = decision,
};
}
finally
{
_pendingApprovals.TryRemove(approvalId, out _);
}
}
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
PermissionRequest request,
PermissionInvocation invocation,
string approvalId,
string? toolName)
{
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
? "tool access"
: request.Kind.Trim();
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string? sessionId = string.IsNullOrWhiteSpace(invocation.SessionId)
? null
: invocation.SessionId.Trim();
string? normalizedToolName = string.IsNullOrWhiteSpace(toolName)
? null
: toolName.Trim();
string? requestedUrl = request is PermissionRequestUrl urlRequest && !string.IsNullOrWhiteSpace(urlRequest.Url)
? urlRequest.Url.Trim()
: null;
string title = normalizedToolName is null
? $"Approve {permissionKind}"
: $"Approve {normalizedToolName}";
string detail = normalizedToolName is null
? $"{agentName} requested {permissionKind} permission"
: $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\"";
if (requestedUrl is not null)
{
detail = $"{detail} to access \"{requestedUrl}\"";
}
if (sessionId is not null)
{
detail = normalizedToolName is null
? $"{detail} for Copilot session {sessionId}"
: $"{detail} in Copilot session {sessionId}";
}
detail = $"{detail}.";
return new ApprovalRequestedEventDto
{
Type = "approval-requested",
RequestId = command.RequestId,
SessionId = command.SessionId,
ApprovalId = approvalId,
ApprovalKind = "tool-call",
AgentId = string.IsNullOrWhiteSpace(agent.Id) ? null : agent.Id,
AgentName = string.IsNullOrWhiteSpace(agentName) ? null : agentName,
ToolName = normalizedToolName,
PermissionKind = permissionKind,
Title = title,
Detail = detail,
};
}
internal static bool RequiresToolCallApproval(
ApprovalPolicyDto? approvalPolicy,
string agentId,
string? toolName)
{
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
{
return false;
}
bool matchesCheckpoint = false;
foreach (ApprovalCheckpointRuleDto rule in approvalPolicy.Rules)
{
if (!string.Equals(rule.Kind, "tool-call", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (rule.AgentIds.Count == 0)
{
matchesCheckpoint = true;
break;
}
if (rule.AgentIds.Any(candidate =>
string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase)))
{
matchesCheckpoint = true;
break;
}
}
if (!matchesCheckpoint)
{
return false;
}
if (string.IsNullOrWhiteSpace(toolName))
{
return true;
}
return !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
}
internal static bool TryGetApprovalToolName(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId,
out string? toolName)
{
toolName = request switch
{
PermissionRequestMcp mcp when !string.IsNullOrWhiteSpace(mcp.ToolName) => mcp.ToolName.Trim(),
PermissionRequestCustomTool customTool when !string.IsNullOrWhiteSpace(customTool.ToolName) => customTool.ToolName.Trim(),
PermissionRequestHook hook when !string.IsNullOrWhiteSpace(hook.ToolName) => hook.ToolName.Trim(),
_ => null,
};
if (!string.IsNullOrWhiteSpace(toolName))
{
return true;
}
string? toolCallId = NormalizeOptionalString(GetStringProperty(request, "ToolCallId"));
if (toolCallId is not null
&& toolNamesByCallId is not null
&& toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName)
&& !string.IsNullOrWhiteSpace(resolvedToolName))
{
toolName = resolvedToolName.Trim();
return true;
}
toolName = request switch
{
PermissionRequestUrl => "web_fetch",
_ => null,
};
return !string.IsNullOrWhiteSpace(toolName);
}
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
private static string CreateApprovalRequestId()
{
return $"approval-{Guid.NewGuid():N}";
}
private static string? GetStringProperty(object? instance, string propertyName)
{
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private sealed record PendingApprovalRequest(
string RequestId,
string SessionId,
string ApprovalId,
TaskCompletionSource<PermissionRequestResultKind> Decision);
}
@@ -0,0 +1,104 @@
using System.Collections.Concurrent;
using Eryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services;
internal sealed class CopilotTurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
public CopilotTurnExecutionState(RunTurnCommandDto command)
{
_command = command;
}
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; }
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<AgentActivityEventDto, Task> onActivity)
{
ActiveAgent = agent;
if (!_startedAgents.Add(agent.AgentId))
{
return;
}
await onActivity(new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "thinking",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
}).ConfigureAwait(false);
}
public void ApplyActivity(AgentActivityEventDto activity)
{
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
}
}
public string CreateMessageId(string? messageId)
{
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
}
public (string MessageId, string AuthorName, string Content) AppendDelta(
string messageId,
string authorName,
string delta)
{
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
}
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{
if (ActiveAgent.HasValue
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{
ActiveAgent = null;
}
}
public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages(
_command,
newMessages,
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
{
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages(
_command,
[],
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
return CompletedMessages;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
using System.Collections.Concurrent;
using Eryx.AgentHost.Contracts;
using Microsoft.Agents.AI.Workflows;
namespace Eryx.AgentHost.Services;
internal static class WorkflowRequestInfoInterpreter
{
private static readonly Type? HandoffTargetType = LoadType(
"Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows");
private static readonly Type? FunctionCallContentType = LoadType(
"Microsoft.Extensions.AI.FunctionCallContent, Microsoft.Extensions.AI.Abstractions");
private static readonly Type? McpServerToolCallContentType = LoadType(
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions");
private static readonly Type? CodeInterpreterToolCallContentType = LoadType(
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions");
private static readonly Type? ImageGenerationToolCallContentType = LoadType(
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions");
public static AgentActivityEventDto? TryCreateActivityFromRequest(
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId)
{
if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent))
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = "handoff",
AgentId = handoffAgent.AgentId,
AgentName = handoffAgent.AgentName,
SourceAgentId = activeAgent?.AgentId,
SourceAgentName = activeAgent?.AgentName,
};
}
if (!activeAgent.HasValue
|| !TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId))
{
return null;
}
if (!string.IsNullOrWhiteSpace(toolCallId))
{
toolNamesByCallId[toolCallId] = toolName;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = "tool-calling",
AgentId = activeAgent.Value.AgentId,
AgentName = activeAgent.Value.AgentName,
ToolName = toolName,
};
}
private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern,
RequestInfoEvent requestInfo,
out AgentIdentity agent)
{
agent = default;
if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget))
{
return false;
}
object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget);
agent = AgentIdentityResolver.ResolveAgentIdentity(
pattern,
GetStringProperty(target, "Id"),
GetStringProperty(target, "Name"));
return !string.IsNullOrWhiteSpace(agent.AgentName);
}
private static bool TryGetToolRequestInfo(
RequestInfoEvent requestInfo,
out string toolName,
out string? toolCallId)
{
if (TryReadPortableValue(requestInfo.Request.Data, FunctionCallContentType, out object? functionCall))
{
toolName = GetStringProperty(functionCall, "Name") ?? "function";
toolCallId = NormalizeOptionalString(GetStringProperty(functionCall, "CallId"));
return true;
}
if (TryReadPortableValue(requestInfo.Request.Data, McpServerToolCallContentType, out object? mcpToolCall))
{
toolName = GetStringProperty(mcpToolCall, "ToolName")
?? GetStringProperty(mcpToolCall, "ServerName")
?? string.Empty;
toolCallId = NormalizeOptionalString(GetStringProperty(mcpToolCall, "CallId"));
return !string.IsNullOrWhiteSpace(toolName);
}
if (TryReadPortableValue(requestInfo.Request.Data, CodeInterpreterToolCallContentType, out object? codeInterpreterToolCall))
{
toolName = "code interpreter";
toolCallId = NormalizeOptionalString(GetStringProperty(codeInterpreterToolCall, "CallId"));
return true;
}
if (TryReadPortableValue(requestInfo.Request.Data, ImageGenerationToolCallContentType, out _))
{
toolName = "image generation";
toolCallId = null;
return true;
}
toolName = string.Empty;
toolCallId = null;
return false;
}
private static Type? LoadType(string assemblyQualifiedName)
{
return Type.GetType(assemblyQualifiedName, throwOnError: false);
}
private static bool TryReadPortableValue(PortableValue portableValue, Type? targetType, out object? value)
{
value = null;
if (targetType is null || !portableValue.IsType(targetType))
{
return false;
}
value = portableValue.AsType(targetType);
return value is not null;
}
private static string? GetStringProperty(object? instance, string propertyName)
{
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -0,0 +1,230 @@
using System.Text;
using Eryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services;
internal static class WorkflowTranscriptProjector
{
public static ChatMessage ToChatMessage(ChatMessageDto message)
{
ChatMessage mapped = new(message.Role switch
{
"user" => ChatRole.User,
"system" => ChatRole.System,
_ => ChatRole.Assistant,
}, message.Content);
if (!string.IsNullOrWhiteSpace(message.AuthorName))
{
mapped.AuthorName = message.AuthorName;
}
return mapped;
}
public static List<ChatMessageDto> ProjectCompletedMessages(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments,
AgentIdentity? fallbackAgent = null)
{
List<ChatMessageDto> mapped = [];
int segmentIndex = 0;
int fallbackOutputIndex = 0;
string createdAt = DateTimeOffset.UtcNow.ToString("O");
foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User))
{
(string MessageId, string AuthorName, string Content)? segment =
segmentIndex < segments.Count ? segments[segmentIndex] : null;
string content = message.Text ?? segment?.Content ?? string.Empty;
if (string.IsNullOrWhiteSpace(content))
{
continue;
}
if (segment.HasValue)
{
segmentIndex++;
}
fallbackOutputIndex++;
mapped.Add(new ChatMessageDto
{
Id = segment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}",
Role = message.Role == ChatRole.System ? "system" : "assistant",
AuthorName = ResolveProjectedAuthorName(
command.Pattern,
message.AuthorName,
segment?.AuthorName,
fallbackAgent),
Content = content,
CreatedAt = createdAt,
});
}
if (mapped.Count == 0 && segments.Count > 0)
{
mapped.AddRange(segments.Select(segment => new ChatMessageDto
{
Id = segment.MessageId,
Role = "assistant",
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
Content = segment.Content,
CreatedAt = createdAt,
}));
}
return mapped;
}
public static List<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
if (outputMessages.Count == 0)
{
return [];
}
if (inputMessages.Count == 0)
{
return outputMessages.ToList();
}
int overlapLength = FindOutputInputOverlapLength(outputMessages, inputMessages);
return outputMessages.Skip(overlapLength).ToList();
}
private static int FindOutputInputOverlapLength(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
int maxOverlap = Math.Min(outputMessages.Count, inputMessages.Count);
for (int overlapLength = maxOverlap; overlapLength > 0; overlapLength--)
{
int inputStart = inputMessages.Count - overlapLength;
bool matches = true;
for (int index = 0; index < overlapLength; index++)
{
if (!ChatMessagesMatch(inputMessages[inputStart + index], outputMessages[index]))
{
matches = false;
break;
}
}
if (matches)
{
return overlapLength;
}
}
return 0;
}
private static bool ChatMessagesMatch(ChatMessage inputMessage, ChatMessage outputMessage)
{
if (inputMessage.Role != outputMessage.Role)
{
return false;
}
if (!string.Equals(inputMessage.Text, outputMessage.Text, StringComparison.Ordinal))
{
return false;
}
return string.IsNullOrWhiteSpace(inputMessage.AuthorName)
|| string.IsNullOrWhiteSpace(outputMessage.AuthorName)
|| string.Equals(inputMessage.AuthorName, outputMessage.AuthorName, StringComparison.Ordinal);
}
private static string ResolveProjectedAuthorName(
PatternDefinitionDto pattern,
string? primaryIdentifier,
string? fallbackIdentifier,
AgentIdentity? fallbackAgent)
{
if (fallbackAgent.HasValue && AgentIdentityResolver.IsGenericAssistantIdentifier(primaryIdentifier))
{
return fallbackAgent.Value.AgentName;
}
return AgentIdentityResolver.ResolveDisplayAuthorName(
pattern,
primaryIdentifier,
fallbackIdentifier);
}
}
internal sealed class StreamingTranscriptBuffer
{
private readonly List<StreamingSegment> _segments = [];
public int Count => _segments.Count;
public (string MessageId, string AuthorName, string Content) AppendDelta(
string messageId,
string authorName,
string delta)
{
StreamingSegment segment = GetOrCreateSegment(messageId, authorName);
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
segment.SetAuthorName(authorName);
return segment.ToSnapshot();
}
public IReadOnlyList<(string MessageId, string AuthorName, string Content)> Snapshot()
{
return _segments.Select(segment => segment.ToSnapshot()).ToList();
}
private StreamingSegment GetOrCreateSegment(string messageId, string authorName)
{
StreamingSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
if (existing is not null)
{
return existing;
}
StreamingSegment created = new(messageId, authorName);
_segments.Add(created);
return created;
}
private sealed class StreamingSegment
{
public StreamingSegment(string messageId, string authorName)
{
MessageId = messageId;
AuthorName = authorName;
}
public string MessageId { get; }
public string AuthorName { get; private set; }
public StringBuilder Content { get; } = new();
public void SetContent(string value)
{
Content.Clear();
Content.Append(value);
}
public void SetAuthorName(string value)
{
AuthorName = value;
}
public (string MessageId, string AuthorName, string Content) ToSnapshot()
{
return (MessageId, AuthorName, Content.ToString());
}
}
}
@@ -20,7 +20,7 @@ public sealed class CopilotWorkflowRunnerTests
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
outputMessages,
inputMessages);
@@ -43,7 +43,7 @@ public sealed class CopilotWorkflowRunnerTests
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
outputMessages,
inputMessages);
@@ -64,7 +64,7 @@ public sealed class CopilotWorkflowRunnerTests
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
outputMessages,
inputMessages);
@@ -94,7 +94,7 @@ public sealed class CopilotWorkflowRunnerTests
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[],
[
@@ -138,7 +138,7 @@ public sealed class CopilotWorkflowRunnerTests
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Hello")
@@ -177,7 +177,7 @@ public sealed class CopilotWorkflowRunnerTests
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "The button is in place.")
@@ -214,7 +214,7 @@ public sealed class CopilotWorkflowRunnerTests
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, string.Empty)
@@ -234,6 +234,52 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Real content", message.Content);
}
[Fact]
public void StreamingTranscriptBuffer_MergesUpdatesPerMessageId()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "Hello");
(string messageId, string authorName, string content) = buffer.AppendDelta("msg-1", "Architect", " world");
Assert.Equal("msg-1", messageId);
Assert.Equal("Architect", authorName);
Assert.Equal("Hello world", content);
Assert.Collection(
buffer.Snapshot(),
segment =>
{
Assert.Equal("msg-1", segment.MessageId);
Assert.Equal("Architect", segment.AuthorName);
Assert.Equal("Hello world", segment.Content);
});
}
[Fact]
public void StreamingTranscriptBuffer_PreservesInsertionOrderAcrossMessages()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "A");
buffer.AppendDelta("msg-2", "Implementer", "B");
buffer.AppendDelta("msg-1", "Architect", " plus");
Assert.Collection(
buffer.Snapshot(),
first =>
{
Assert.Equal("msg-1", first.MessageId);
Assert.Equal("Architect", first.AuthorName);
Assert.Equal("A plus", first.Content);
},
second =>
{
Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content);
});
}
[Fact]
public void RequiresToolCallApproval_HonorsAutoApprovedToolNames()
{
@@ -250,18 +296,18 @@ public sealed class CopilotWorkflowRunnerTests
AutoApprovedToolNames = ["lsp_ts_hover", "web_fetch"],
};
Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover"));
Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "web_fetch"));
Assert.True(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition"));
Assert.True(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", null));
Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "web_fetch"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", null));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition"));
}
[Fact]
public void TryGetApprovalToolName_ReadsMcpCustomAndHookRequests()
{
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestMcp
{
Kind = "mcp",
@@ -274,7 +320,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("git.status", mcpToolName);
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestCustomTool
{
Kind = "custom tool",
@@ -285,7 +331,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("lsp_ts_hover", customToolName);
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestHook
{
Kind = "hook",
@@ -297,7 +343,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("web_fetch", hookToolName);
Assert.False(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestShell
{
Kind = "shell",
@@ -324,7 +370,7 @@ public sealed class CopilotWorkflowRunnerTests
};
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestUrl
{
Kind = "url",
@@ -337,7 +383,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("web_fetch", urlToolName);
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestShell
{
Kind = "shell",
@@ -355,7 +401,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("shell", shellToolName);
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestRead
{
Kind = "read",
@@ -372,7 +418,7 @@ public sealed class CopilotWorkflowRunnerTests
public void TryGetApprovalToolName_FallsBackToWebFetchForUncorrelatedUrlRequests()
{
Assert.True(
CopilotWorkflowRunner.TryGetApprovalToolName(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestUrl
{
Kind = "url",
@@ -387,7 +433,7 @@ public sealed class CopilotWorkflowRunnerTests
[Fact]
public void BuildPermissionApprovalEvent_IncludesToolContextWhenKnown()
{
ApprovalRequestedEventDto approvalEvent = CopilotWorkflowRunner.BuildPermissionApprovalEvent(
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
@@ -415,7 +461,7 @@ public sealed class CopilotWorkflowRunnerTests
[Fact]
public void BuildPermissionApprovalEvent_IncludesRequestedUrlForUrlPermissions()
{
ApprovalRequestedEventDto approvalEvent = CopilotWorkflowRunner.BuildPermissionApprovalEvent(
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
@@ -443,6 +489,98 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Contains("https://example.com/docs", approvalEvent.Detail);
}
[Fact]
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
{
CopilotApprovalCoordinator coordinator = new();
ApprovalRequestedEventDto? observedApproval = null;
RunTurnCommandDto command = CreateApprovalCommand();
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
command,
command.Pattern.Agents[0],
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "lsp_ts_definition",
ToolDescription = "Go to definition",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
approval =>
{
observedApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(pending.IsCompleted);
Assert.NotNull(observedApproval);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = observedApproval!.ApprovalId,
Decision = "approved",
},
CancellationToken.None);
PermissionRequestResult result = await pending;
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
}
[Fact]
public async Task RequestApprovalAsync_AutoApprovesToolsThatDoNotRequireApproval()
{
CopilotApprovalCoordinator coordinator = new();
bool sawApproval = false;
RunTurnCommandDto command = CreateApprovalCommand();
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
command,
command.Pattern.Agents[0],
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "web_fetch",
ToolDescription = "Fetch documentation",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
approval =>
{
sawApproval = true;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(sawApproval);
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
}
[Fact]
public async Task ResolveApprovalAsync_RejectsUnknownApprovalIds()
{
CopilotApprovalCoordinator coordinator = new();
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = "approval-missing",
Decision = "approved",
},
CancellationToken.None));
Assert.Contains("is not pending", error.Message);
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
@@ -453,4 +591,36 @@ public sealed class CopilotWorkflowRunnerTests
Instructions = "Help with the request.",
};
}
private static RunTurnCommandDto CreateApprovalCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Approval Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = ["web_fetch"],
},
Agents =
[
CreateAgent("agent-1", "Primary"),
],
},
};
}
}
@@ -0,0 +1,218 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Tests;
public sealed class WorkflowRequestInfoInterpreterTests
{
[Fact]
public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("agent-1", activity.AgentId);
Assert.Equal("Primary", activity.AgentName);
Assert.Equal("view", activity.ToolName);
Assert.Equal("view", toolNamesByCallId["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("code interpreter", activity.ToolName);
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall());
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName);
Assert.Empty(toolNamesByCallId);
}
[Fact]
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateHandoffCommand(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
Assert.Equal("UX Specialist", activity.AgentName);
Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
Assert.Equal("Triage", activity.SourceAgentName);
Assert.Null(activity.ToolName);
Assert.Empty(toolNamesByCallId);
}
private static RunTurnCommandDto CreateSingleAgentCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-single",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent("agent-1", "Primary"),
],
},
};
}
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 PatternAgentDefinitionDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
};
}
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{
RequestPort port = RequestPort.Create<object, object>("test-port");
ExternalRequest request = ExternalRequest.Create(port, payload, "request-1");
return new RequestInfoEvent(request);
}
private static object CreateCodeInterpreterToolCall(string callId)
{
Type type = Type.GetType(
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
object instance = Activator.CreateInstance(type)!;
type.GetProperty("CallId")!.SetValue(instance, callId);
return instance;
}
private static object CreateImageGenerationToolCall()
{
Type type = Type.GetType(
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
return Activator.CreateInstance(type)!;
}
private static object CreateHandoffTarget(string id, string name)
{
Type type = Type.GetType(
"Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows",
throwOnError: true)!;
return Activator.CreateInstance(type, CreateChatClientAgent(id, name), "Handle the UX work.")!;
}
private static ChatClientAgent CreateChatClientAgent(string id, string name)
{
return new ChatClientAgent(
new StubChatClient(),
id,
name,
"Stub agent for handoff tests.",
[],
null!,
null!);
}
private sealed class StubChatClient : IChatClient
{
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
[EnumeratorCancellation]
CancellationToken cancellationToken)
{
yield break;
}
public object? GetService(Type serviceType, object? serviceKey)
{
return null;
}
public void Dispose()
{
}
}
}