mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
refactor: decompose CopilotWorkflowRunner
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user