mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 13:23:57 +02:00
refactor: decompose CopilotWorkflowRunner
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
- Use Bun for dependency management and script execution.
|
||||||
- Prefer repository-local tooling over global machine state whenever possible.
|
- Prefer repository-local tooling over global machine state whenever possible.
|
||||||
- Keep changes focused and reviewable. Avoid mixing unrelated concerns into a single change.
|
- 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.
|
- 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.
|
- 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."),
|
new(ChatRole.Assistant, "Hi there."),
|
||||||
];
|
];
|
||||||
|
|
||||||
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
|
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
|
||||||
outputMessages,
|
outputMessages,
|
||||||
inputMessages);
|
inputMessages);
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
new(ChatRole.Assistant, "Hi there."),
|
new(ChatRole.Assistant, "Hi there."),
|
||||||
];
|
];
|
||||||
|
|
||||||
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
|
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
|
||||||
outputMessages,
|
outputMessages,
|
||||||
inputMessages);
|
inputMessages);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
new(ChatRole.Assistant, "Hi there."),
|
new(ChatRole.Assistant, "Hi there."),
|
||||||
];
|
];
|
||||||
|
|
||||||
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
|
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
|
||||||
outputMessages,
|
outputMessages,
|
||||||
inputMessages);
|
inputMessages);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
[],
|
[],
|
||||||
[
|
[
|
||||||
@@ -138,7 +138,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
[
|
[
|
||||||
new ChatMessage(ChatRole.Assistant, "Hello")
|
new ChatMessage(ChatRole.Assistant, "Hello")
|
||||||
@@ -177,7 +177,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
[
|
[
|
||||||
new ChatMessage(ChatRole.Assistant, "The button is in place.")
|
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,
|
command,
|
||||||
[
|
[
|
||||||
new ChatMessage(ChatRole.Assistant, string.Empty)
|
new ChatMessage(ChatRole.Assistant, string.Empty)
|
||||||
@@ -234,6 +234,52 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("Real content", message.Content);
|
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]
|
[Fact]
|
||||||
public void RequiresToolCallApproval_HonorsAutoApprovedToolNames()
|
public void RequiresToolCallApproval_HonorsAutoApprovedToolNames()
|
||||||
{
|
{
|
||||||
@@ -250,18 +296,18 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
AutoApprovedToolNames = ["lsp_ts_hover", "web_fetch"],
|
AutoApprovedToolNames = ["lsp_ts_hover", "web_fetch"],
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover"));
|
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover"));
|
||||||
Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "web_fetch"));
|
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "web_fetch"));
|
||||||
Assert.True(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition"));
|
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition"));
|
||||||
Assert.True(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", null));
|
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", null));
|
||||||
Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition"));
|
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryGetApprovalToolName_ReadsMcpCustomAndHookRequests()
|
public void TryGetApprovalToolName_ReadsMcpCustomAndHookRequests()
|
||||||
{
|
{
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestMcp
|
new PermissionRequestMcp
|
||||||
{
|
{
|
||||||
Kind = "mcp",
|
Kind = "mcp",
|
||||||
@@ -274,7 +320,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("git.status", mcpToolName);
|
Assert.Equal("git.status", mcpToolName);
|
||||||
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestCustomTool
|
new PermissionRequestCustomTool
|
||||||
{
|
{
|
||||||
Kind = "custom tool",
|
Kind = "custom tool",
|
||||||
@@ -285,7 +331,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("lsp_ts_hover", customToolName);
|
Assert.Equal("lsp_ts_hover", customToolName);
|
||||||
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestHook
|
new PermissionRequestHook
|
||||||
{
|
{
|
||||||
Kind = "hook",
|
Kind = "hook",
|
||||||
@@ -297,7 +343,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("web_fetch", hookToolName);
|
Assert.Equal("web_fetch", hookToolName);
|
||||||
|
|
||||||
Assert.False(
|
Assert.False(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestShell
|
new PermissionRequestShell
|
||||||
{
|
{
|
||||||
Kind = "shell",
|
Kind = "shell",
|
||||||
@@ -324,7 +370,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestUrl
|
new PermissionRequestUrl
|
||||||
{
|
{
|
||||||
Kind = "url",
|
Kind = "url",
|
||||||
@@ -337,7 +383,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("web_fetch", urlToolName);
|
Assert.Equal("web_fetch", urlToolName);
|
||||||
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestShell
|
new PermissionRequestShell
|
||||||
{
|
{
|
||||||
Kind = "shell",
|
Kind = "shell",
|
||||||
@@ -355,7 +401,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("shell", shellToolName);
|
Assert.Equal("shell", shellToolName);
|
||||||
|
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestRead
|
new PermissionRequestRead
|
||||||
{
|
{
|
||||||
Kind = "read",
|
Kind = "read",
|
||||||
@@ -372,7 +418,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
public void TryGetApprovalToolName_FallsBackToWebFetchForUncorrelatedUrlRequests()
|
public void TryGetApprovalToolName_FallsBackToWebFetchForUncorrelatedUrlRequests()
|
||||||
{
|
{
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CopilotWorkflowRunner.TryGetApprovalToolName(
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
new PermissionRequestUrl
|
new PermissionRequestUrl
|
||||||
{
|
{
|
||||||
Kind = "url",
|
Kind = "url",
|
||||||
@@ -387,7 +433,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BuildPermissionApprovalEvent_IncludesToolContextWhenKnown()
|
public void BuildPermissionApprovalEvent_IncludesToolContextWhenKnown()
|
||||||
{
|
{
|
||||||
ApprovalRequestedEventDto approvalEvent = CopilotWorkflowRunner.BuildPermissionApprovalEvent(
|
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||||
new RunTurnCommandDto
|
new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
@@ -415,7 +461,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BuildPermissionApprovalEvent_IncludesRequestedUrlForUrlPermissions()
|
public void BuildPermissionApprovalEvent_IncludesRequestedUrlForUrlPermissions()
|
||||||
{
|
{
|
||||||
ApprovalRequestedEventDto approvalEvent = CopilotWorkflowRunner.BuildPermissionApprovalEvent(
|
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||||
new RunTurnCommandDto
|
new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
@@ -443,6 +489,98 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Contains("https://example.com/docs", approvalEvent.Detail);
|
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)
|
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
return new PatternAgentDefinitionDto
|
||||||
@@ -453,4 +591,36 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Instructions = "Help with the request.",
|
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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user