mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
refactor: rename sidecar host to Aryx
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260311.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-rc4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Aryx.AgentHost.Tests")]
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
public sealed class PatternAgentDefinitionDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string Instructions { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public string? ReasoningEffort { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphPositionDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphNodeDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public PatternGraphPositionDto Position { get; init; } = new();
|
||||
public string? AgentId { get; init; }
|
||||
public int? Order { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphEdgeDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string Target { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PatternGraphDto
|
||||
{
|
||||
public IReadOnlyList<PatternGraphNodeDto> Nodes { get; init; } = [];
|
||||
public IReadOnlyList<PatternGraphEdgeDto> Edges { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PatternDefinitionDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
public string Availability { get; init; } = "available";
|
||||
public string? UnavailabilityReason { get; init; }
|
||||
public int MaxIterations { get; init; }
|
||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
||||
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
||||
public PatternGraphDto? Graph { get; init; }
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public string UpdatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ApprovalPolicyDto
|
||||
{
|
||||
public IReadOnlyList<ApprovalCheckpointRuleDto> Rules { get; init; } = [];
|
||||
public IReadOnlyList<string> AutoApprovedToolNames { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ApprovalCheckpointRuleDto
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> AgentIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChatMessageDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Role { get; init; } = string.Empty;
|
||||
public string AuthorName { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PatternValidationIssueDto
|
||||
{
|
||||
public string Level { get; init; } = "error";
|
||||
public string? Field { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SidecarModeCapabilityDto
|
||||
{
|
||||
public bool Available { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarModelCapabilityDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> SupportedReasoningEfforts { get; init; } = [];
|
||||
public string? DefaultReasoningEffort { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarRuntimeToolDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
public string Status { get; init; } = "copilot-error";
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public string? CopilotCliPath { get; init; }
|
||||
public SidecarCopilotCliVersionDiagnosticsDto? CopilotCliVersion { get; init; }
|
||||
public SidecarCopilotAccountDiagnosticsDto? Account { get; init; }
|
||||
public string CheckedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
public string Status { get; init; } = "unknown";
|
||||
public string? InstalledVersion { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
public bool Authenticated { get; init; }
|
||||
public string? Login { get; init; }
|
||||
public string? Host { get; init; }
|
||||
public string? AuthType { get; init; }
|
||||
public string? StatusMessage { get; init; }
|
||||
public IReadOnlyList<string>? Organizations { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarCapabilitiesDto
|
||||
{
|
||||
public string Runtime { get; init; } = "dotnet-maf";
|
||||
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
|
||||
public IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools { get; init; } = [];
|
||||
public SidecarConnectionDiagnosticsDto Connection { get; init; } = new();
|
||||
}
|
||||
|
||||
public class SidecarCommandEnvelope
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string RequestId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DescribeCapabilitiesCommandDto : SidecarCommandEnvelope;
|
||||
|
||||
public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ProjectPath { get; init; } = string.Empty;
|
||||
public string WorkspaceKind { get; init; } = "project";
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string TargetRequestId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string ApprovalId { get; init; } = string.Empty;
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RunTurnToolingConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
public IReadOnlyList<RunTurnLspProfileConfigDto> LspProfiles { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RunTurnMcpServerConfigDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Transport { get; init; } = "local";
|
||||
public IReadOnlyList<string> Tools { get; init; } = [];
|
||||
public int? TimeoutMs { get; init; }
|
||||
public string? Command { get; init; }
|
||||
public IReadOnlyList<string>? Args { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Env { get; init; }
|
||||
public string? Cwd { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RunTurnLspProfileConfigDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Command { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> Args { get; init; } = [];
|
||||
public string LanguageId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> FileExtensions { get; init; } = [];
|
||||
}
|
||||
|
||||
public abstract class SidecarEventDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string RequestId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CapabilitiesEventDto : SidecarEventDto
|
||||
{
|
||||
public SidecarCapabilitiesDto Capabilities { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class PatternValidationEventDto : SidecarEventDto
|
||||
{
|
||||
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class TurnDeltaEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string MessageId { get; init; } = string.Empty;
|
||||
public string AuthorName { get; init; } = string.Empty;
|
||||
public string ContentDelta { get; init; } = string.Empty;
|
||||
public string? Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TurnCompleteEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public bool Cancelled { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AgentActivityEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ActivityType { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? SourceAgentId { get; init; }
|
||||
public string? SourceAgentName { get; init; }
|
||||
public string? ToolName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ApprovalId { get; init; } = string.Empty;
|
||||
public string ApprovalKind { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? ToolName { get; init; }
|
||||
public string? PermissionKind { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CommandCompleteEventDto : SidecarEventDto;
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
[assembly: Experimental(
|
||||
"MEAI001",
|
||||
UrlFormat = "https://aka.ms/dotnet-extensions-warnings/{0}")]
|
||||
@@ -0,0 +1,10 @@
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
if (!args.Contains("--stdio", StringComparer.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine("Aryx.AgentHost expects the --stdio flag.");
|
||||
return;
|
||||
}
|
||||
|
||||
SidecarProtocolHost host = new();
|
||||
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Text;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
|
||||
|
||||
internal static class AgentIdentityResolver
|
||||
{
|
||||
private const string GenericAssistantIdentifier = "assistant";
|
||||
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier);
|
||||
if (match is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
agent = ToAgentIdentity(match);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryResolveObservedAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fallbackAgent.HasValue && IsGenericAssistantIdentifier(agentIdentifier))
|
||||
{
|
||||
agent = fallbackAgent.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
agent = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static AgentIdentity ResolveAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
string? agentId,
|
||||
string? agentName)
|
||||
{
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId)
|
||||
?? FindKnownAgent(pattern, agentName)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName);
|
||||
|
||||
return match is not null
|
||||
? ToAgentIdentity(match)
|
||||
: CreateFallbackIdentity(agentId, agentName);
|
||||
}
|
||||
|
||||
public static string ResolveDisplayAuthorName(
|
||||
PatternDefinitionDto pattern,
|
||||
string? primaryIdentifier,
|
||||
string? fallbackIdentifier = null)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, primaryIdentifier, out AgentIdentity primaryAgent))
|
||||
{
|
||||
return primaryAgent.AgentName;
|
||||
}
|
||||
|
||||
if (TryResolveKnownAgentIdentity(pattern, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
||||
{
|
||||
return fallbackAgent.AgentName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(primaryIdentifier))
|
||||
{
|
||||
return primaryIdentifier;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||
{
|
||||
return fallbackIdentifier;
|
||||
}
|
||||
|
||||
return GenericAssistantIdentifier;
|
||||
}
|
||||
|
||||
internal static bool IsGenericAssistantIdentifier(string? candidate)
|
||||
{
|
||||
return string.Equals(
|
||||
NormalizeComparisonKey(candidate),
|
||||
GenericAssistantIdentifier,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias(
|
||||
PatternDefinitionDto pattern,
|
||||
params string?[] agentIdentifiers)
|
||||
{
|
||||
return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
||||
? pattern.Agents[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
|
||||
{
|
||||
return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
}
|
||||
|
||||
private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent)
|
||||
{
|
||||
return new AgentIdentity(
|
||||
agent.Id,
|
||||
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name);
|
||||
}
|
||||
|
||||
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
|
||||
{
|
||||
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
|
||||
? agentId
|
||||
: agentName ?? "agent";
|
||||
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName)
|
||||
? agentName
|
||||
: resolvedAgentId;
|
||||
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
|
||||
}
|
||||
|
||||
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string normalizedCandidate = NormalizeComparisonKey(candidate);
|
||||
string normalizedId = NormalizeComparisonKey(agent.Id);
|
||||
string normalizedName = NormalizeComparisonKey(agent.Name);
|
||||
if (normalizedCandidate.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedCandidate == normalizedId
|
||||
|| normalizedCandidate == normalizedName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizedId.Length > 0
|
||||
&& normalizedName.Length > 0
|
||||
&& (normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal)
|
||||
|| normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal)
|
||||
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string NormalizeComparisonKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
StringBuilder builder = new(value.Length);
|
||||
foreach (char character in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(character));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class AgentInstructionComposer
|
||||
{
|
||||
public static string Compose(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternAgentDefinitionDto agent,
|
||||
int agentIndex,
|
||||
string workspaceKind = "project")
|
||||
{
|
||||
string baseInstructions = agent.Instructions.Trim();
|
||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
||||
? """
|
||||
You are operating in scratchpad mode.
|
||||
Treat this session as ad-hoc work inside the scratchpad workspace rather than repository automation against a connected user project.
|
||||
You may use the available tools and files inside the scratchpad workspace when they help answer the request.
|
||||
Do not assume there is a connected repository, checked-out branch, or project-specific context unless the user provides it in the conversation.
|
||||
Answer conversationally and focus on the user's question directly.
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string groupChatGuidance = agentIndex == 0
|
||||
? """
|
||||
You are participating in a collaborative multi-turn group chat under a round-robin manager.
|
||||
On your first turn, produce the initial draft for the user.
|
||||
On later turns, refine your earlier draft based on the other agents' feedback instead of restarting from scratch.
|
||||
Do not greet the user again or reset the conversation once work is underway.
|
||||
"""
|
||||
: """
|
||||
You are participating in a collaborative multi-turn group chat under a round-robin manager.
|
||||
Build on the latest draft from the other agents and contribute specific critique or improvements.
|
||||
Do not restart the conversation, greet the user again, or answer as though no draft exists yet.
|
||||
Focus on refining the answer already in progress.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, groupChatGuidance);
|
||||
}
|
||||
|
||||
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance);
|
||||
}
|
||||
|
||||
string runtimeGuidance = agentIndex == 0
|
||||
? """
|
||||
You are the routing gate for this handoff workflow.
|
||||
Your job is to classify the request and hand it off to the most appropriate specialist as soon as you know who should own the substantive work.
|
||||
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
|
||||
Do not inspect files, call tools, draft the implementation, or produce the final user-facing answer yourself once a specialist is appropriate.
|
||||
Do not claim that you handed work off unless you actually executed the handoff.
|
||||
Only answer directly if the user is asking for pure triage or a minimal clarification that must happen before delegation.
|
||||
"""
|
||||
: """
|
||||
You are a specialist participating in a handoff workflow.
|
||||
Once the triage agent hands work to you, you own the substantive answer within your specialty and should carry it through.
|
||||
Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, runtimeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
{
|
||||
return string.Join(
|
||||
"\n\n",
|
||||
blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.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 Aryx.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,
|
||||
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||
? 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),
|
||||
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
|
||||
Streaming = true,
|
||||
};
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
internal static void ApplySessionTooling(
|
||||
SessionConfig sessionConfig,
|
||||
Dictionary<string, object>? mcpServers,
|
||||
IReadOnlyList<AIFunction>? tools)
|
||||
{
|
||||
if (mcpServers is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.McpServers = mcpServers;
|
||||
}
|
||||
|
||||
if (tools is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.Tools = tools.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"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)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||
Dictionary<string, PatternAgentDefinitionDto> definitionMap = pattern.Agents.ToDictionary(
|
||||
definition => definition.Id,
|
||||
definition => definition,
|
||||
StringComparer.Ordinal);
|
||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(topology.EntryAgentId) ?? Agents[0];
|
||||
|
||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
|
||||
foreach (PatternHandoffRoute route in topology.Routes)
|
||||
{
|
||||
if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent)
|
||||
|| !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent)
|
||||
|| !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string handoffReason = string.Equals(
|
||||
route.TargetAgentId,
|
||||
topology.EntryAgentId,
|
||||
StringComparison.Ordinal)
|
||||
? HandoffWorkflowGuidance.CreateReturnReason(targetDefinition)
|
||||
: HandoffWorkflowGuidance.CreateForwardReason(targetDefinition);
|
||||
|
||||
builder = builder.WithHandoff(
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
handoffReason);
|
||||
}
|
||||
|
||||
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(ResolveOrderedAgents(pattern).ToArray())
|
||||
.Build();
|
||||
}
|
||||
|
||||
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||
List<AIAgent> orderedAgents = PatternGraphResolver.ResolveOrderedAgentIds(pattern)
|
||||
.Select(agentId => agentMap.TryGetValue(agentId, out AIAgent? agent) ? agent : null)
|
||||
.Where(agent => agent is not null)
|
||||
.Cast<AIAgent>()
|
||||
.ToList();
|
||||
|
||||
return orderedAgents.Count == Agents.Count ? orderedAgents : Agents;
|
||||
}
|
||||
|
||||
private Dictionary<string, AIAgent> BuildAgentMap(PatternDefinitionDto pattern)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = new(StringComparer.Ordinal);
|
||||
foreach ((PatternAgentDefinitionDto definition, AIAgent agent) in pattern.Agents.Zip(Agents))
|
||||
{
|
||||
agentMap[definition.Id] = agent;
|
||||
}
|
||||
|
||||
return agentMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotApprovalCoordinator
|
||||
{
|
||||
private const string ApprovedDecision = "approved";
|
||||
private const string RejectedDecision = "rejected";
|
||||
private const string ToolCallApprovalKind = "tool-call";
|
||||
private const string WebFetchToolName = "web_fetch";
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string approvalId = RequireApprovalId(command.ApprovalId);
|
||||
PendingApprovalRequest pending = GetPendingApproval(approvalId);
|
||||
PermissionRequestResultKind decision = ParseDecision(command.Decision);
|
||||
|
||||
if (!pending.Decision.TrySetResult(decision))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{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)
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
|
||||
PendingApprovalRequest pending = CreatePendingApproval(command);
|
||||
if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onApproval(BuildPermissionApprovalEvent(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
pending.ApprovalId,
|
||||
toolName))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<PermissionRequestResultKind>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
pending.Decision);
|
||||
|
||||
PermissionRequestResultKind decision = await pending.Decision.Task.ConfigureAwait(false);
|
||||
return CreateApprovalResult(decision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingApprovals.TryRemove(pending.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 = NormalizeOptionalString(invocation.SessionId);
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
||||
? NormalizeOptionalString(urlRequest.Url)
|
||||
: 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 = ToolCallApprovalKind,
|
||||
AgentId = NormalizeOptionalString(agent.Id),
|
||||
AgentName = NormalizeOptionalString(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;
|
||||
}
|
||||
|
||||
if (!HasMatchingToolCallCheckpoint(approvalPolicy.Rules, agentId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(toolName)
|
||||
|| !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
|
||||
string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId,
|
||||
out string? toolName)
|
||||
{
|
||||
toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
return toolName is not null;
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
|
||||
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
|
||||
|
||||
private static bool HasMatchingToolCallCheckpoint(
|
||||
IReadOnlyList<ApprovalCheckpointRuleDto> rules,
|
||||
string agentId)
|
||||
{
|
||||
foreach (ApprovalCheckpointRuleDto rule in rules)
|
||||
{
|
||||
if (!string.Equals(rule.Kind, ToolCallApprovalKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.AgentIds.Count == 0
|
||||
|| rule.AgentIds.Any(candidate =>
|
||||
string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PendingApprovalRequest CreatePendingApproval(RunTurnCommandDto command)
|
||||
{
|
||||
return new PendingApprovalRequest(
|
||||
command.RequestId,
|
||||
command.SessionId,
|
||||
CreateApprovalRequestId(),
|
||||
new TaskCompletionSource<PermissionRequestResultKind>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
private static PermissionRequestResult CreateApprovalResult(PermissionRequestResultKind decision)
|
||||
{
|
||||
return new PermissionRequestResult
|
||||
{
|
||||
Kind = decision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId)
|
||||
{
|
||||
return GetDirectToolName(request)
|
||||
?? ResolveToolNameFromLookup(request, toolNamesByCallId)
|
||||
?? GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private static string? GetDirectToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolName),
|
||||
PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolName),
|
||||
PermissionRequestHook hook => NormalizeOptionalString(hook.ToolName),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveToolNameFromLookup(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId)
|
||||
{
|
||||
if (toolNamesByCallId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? toolCallId = GetToolCallId(request);
|
||||
if (toolCallId is null
|
||||
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(resolvedToolName);
|
||||
}
|
||||
|
||||
private static string? GetToolCallId(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestShell shell => NormalizeOptionalString(shell.ToolCallId),
|
||||
PermissionRequestWrite write => NormalizeOptionalString(write.ToolCallId),
|
||||
PermissionRequestRead read => NormalizeOptionalString(read.ToolCallId),
|
||||
PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolCallId),
|
||||
PermissionRequestUrl url => NormalizeOptionalString(url.ToolCallId),
|
||||
PermissionRequestMemory memory => NormalizeOptionalString(memory.ToolCallId),
|
||||
PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolCallId),
|
||||
PermissionRequestHook hook => NormalizeOptionalString(hook.ToolCallId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetFallbackToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestUrl => WebFetchToolName,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingApprovalRequest GetPendingApproval(string approvalId)
|
||||
{
|
||||
if (_pendingApprovals.TryGetValue(approvalId, out PendingApprovalRequest? pending))
|
||||
{
|
||||
return pending;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is not pending.");
|
||||
}
|
||||
|
||||
private static string RequireApprovalId(string? approvalId)
|
||||
{
|
||||
string? normalizedApprovalId = NormalizeOptionalString(approvalId);
|
||||
return normalizedApprovalId
|
||||
?? throw new InvalidOperationException("Approval ID is required.");
|
||||
}
|
||||
|
||||
private static PermissionRequestResultKind ParseDecision(string? decision)
|
||||
{
|
||||
return NormalizeOptionalString(decision)?.ToLowerInvariant() switch
|
||||
{
|
||||
ApprovedDecision => PermissionRequestResultKind.Approved,
|
||||
RejectedDecision => PermissionRequestResultKind.DeniedInteractivelyByUser,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported approval decision \"{decision}\"."),
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateApprovalRequestId()
|
||||
{
|
||||
return $"approval-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
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,239 @@
|
||||
using System.Collections;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotCliPathResolver
|
||||
{
|
||||
private const string CopilotCommandName = "copilot";
|
||||
private const string DefaultWindowsCommandProcessor = "cmd.exe";
|
||||
private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD";
|
||||
private const char WindowsSearchPathSeparator = ';';
|
||||
private const char UnixSearchPathSeparator = ':';
|
||||
private const char WindowsDirectorySeparator = '\\';
|
||||
private const char UnixDirectorySeparator = '/';
|
||||
|
||||
private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"];
|
||||
|
||||
public static CopilotClientOptions CreateClientOptions()
|
||||
{
|
||||
return CreateClientOptions(ResolveCliContext());
|
||||
}
|
||||
|
||||
internal static CopilotCliContext ResolveCliContext()
|
||||
{
|
||||
string? cliPath = Resolve(
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
Environment.GetEnvironmentVariable("PATHEXT"),
|
||||
OperatingSystem.IsWindows(),
|
||||
File.Exists);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cliPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Aryx requires the system-installed 'copilot' command on PATH. Install the GitHub Copilot CLI and ensure it is available in the current environment.");
|
||||
}
|
||||
|
||||
CopilotCliLaunch launch = ResolveCliLaunch(
|
||||
cliPath,
|
||||
OperatingSystem.IsWindows(),
|
||||
Environment.GetEnvironmentVariable("ComSpec"));
|
||||
|
||||
return new CopilotCliContext(
|
||||
cliPath,
|
||||
launch.Path,
|
||||
launch.Args,
|
||||
ResolveCliEnvironment(GetCurrentEnvironmentVariables()));
|
||||
}
|
||||
|
||||
internal static CopilotClientOptions CreateClientOptions(CopilotCliContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
return new CopilotClientOptions
|
||||
{
|
||||
CliPath = context.LaunchPath,
|
||||
CliArgs = context.LaunchArgs,
|
||||
Environment = context.Environment,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? Resolve(
|
||||
string? pathValue,
|
||||
string? pathExtValue,
|
||||
bool isWindows,
|
||||
Func<string, bool> fileExists)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileExists);
|
||||
return ResolveCliPath(pathValue, pathExtValue, isWindows, fileExists);
|
||||
}
|
||||
|
||||
internal static IReadOnlyDictionary<string, string> ResolveCliEnvironment(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environmentVariables);
|
||||
|
||||
Dictionary<string, string> sanitizedEnvironment = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, string?> entry in environmentVariables)
|
||||
{
|
||||
if (ShouldSkipEnvironmentEntry(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitizedEnvironment[entry.Key] = entry.Value!;
|
||||
}
|
||||
|
||||
return sanitizedEnvironment;
|
||||
}
|
||||
|
||||
internal static CopilotCliLaunch ResolveCliLaunch(string cliPath, bool isWindows, string? commandProcessorPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cliPath);
|
||||
|
||||
if (!isWindows)
|
||||
{
|
||||
return new CopilotCliLaunch(cliPath, []);
|
||||
}
|
||||
|
||||
return new CopilotCliLaunch(
|
||||
ResolveCommandProcessorPath(commandProcessorPath),
|
||||
["/d", "/s", "/c", CopilotCommandName]);
|
||||
}
|
||||
|
||||
private static bool ShouldSkipEnvironmentEntry(KeyValuePair<string, string?> entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Key) || entry.Value is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string normalizedKey = entry.Key.ToUpperInvariant();
|
||||
return BlockedCliEnvironmentPrefixes.Any(prefix => normalizedKey.StartsWith(prefix, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string ResolveCommandProcessorPath(string? commandProcessorPath)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(commandProcessorPath)
|
||||
? DefaultWindowsCommandProcessor
|
||||
: commandProcessorPath;
|
||||
}
|
||||
|
||||
private static string? ResolveCliPath(
|
||||
string? pathValue,
|
||||
string? pathExtValue,
|
||||
bool isWindows,
|
||||
Func<string, bool> fileExists)
|
||||
{
|
||||
foreach (string directory in EnumerateDistinctSearchDirectories(pathValue, isWindows))
|
||||
{
|
||||
foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows))
|
||||
{
|
||||
string candidatePath = CombineSearchPath(directory, candidateName, isWindows);
|
||||
if (fileExists(candidatePath))
|
||||
{
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateDistinctSearchDirectories(string? pathValue, bool isWindows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pathValue))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
foreach (string directory in pathValue
|
||||
.Split(GetSearchPathSeparator(isWindows), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(segment => segment.Trim('"'))
|
||||
.Where(segment => !string.IsNullOrWhiteSpace(segment))
|
||||
.Distinct(comparer))
|
||||
{
|
||||
yield return directory;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetCandidateFileNames(string? pathExtValue, bool isWindows)
|
||||
{
|
||||
yield return CopilotCommandName;
|
||||
|
||||
if (!isWindows)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
HashSet<string> yielded = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
CopilotCommandName,
|
||||
};
|
||||
|
||||
string extensions = string.IsNullOrWhiteSpace(pathExtValue)
|
||||
? DefaultWindowsPathExtensions
|
||||
: pathExtValue;
|
||||
|
||||
foreach (string extension in extensions
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(extension => extension.StartsWith('.')))
|
||||
{
|
||||
string candidateName = CopilotCommandName + extension;
|
||||
if (yielded.Add(candidateName))
|
||||
{
|
||||
yield return candidateName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static char GetSearchPathSeparator(bool isWindows)
|
||||
{
|
||||
return isWindows ? WindowsSearchPathSeparator : UnixSearchPathSeparator;
|
||||
}
|
||||
|
||||
private static string CombineSearchPath(string directory, string fileName, bool isWindows)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
|
||||
if (EndsWithDirectorySeparator(directory, isWindows))
|
||||
{
|
||||
return directory + fileName;
|
||||
}
|
||||
|
||||
return directory + GetDirectorySeparator(isWindows) + fileName;
|
||||
}
|
||||
|
||||
private static bool EndsWithDirectorySeparator(string path, bool isWindows)
|
||||
{
|
||||
char lastCharacter = path[^1];
|
||||
return lastCharacter == GetDirectorySeparator(isWindows)
|
||||
|| (isWindows && lastCharacter == UnixDirectorySeparator);
|
||||
}
|
||||
|
||||
private static char GetDirectorySeparator(bool isWindows)
|
||||
{
|
||||
return isWindows ? WindowsDirectorySeparator : UnixDirectorySeparator;
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string?>> GetCurrentEnvironmentVariables()
|
||||
{
|
||||
return Environment.GetEnvironmentVariables()
|
||||
.Cast<DictionaryEntry>()
|
||||
.Select(entry => new KeyValuePair<string, string?>(
|
||||
entry.Key?.ToString() ?? string.Empty,
|
||||
entry.Value?.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CopilotCliContext(
|
||||
string CliPath,
|
||||
string LaunchPath,
|
||||
string[] LaunchArgs,
|
||||
IReadOnlyDictionary<string, string> Environment);
|
||||
|
||||
internal sealed record CopilotCliLaunch(string Path, string[] Args);
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static partial class CopilotConnectionMetadataResolver
|
||||
{
|
||||
private static readonly string[] LatestVersionIndicators =
|
||||
[
|
||||
"running the latest version",
|
||||
"already up to date",
|
||||
"is up to date",
|
||||
];
|
||||
|
||||
private static readonly string[] OutdatedVersionIndicators =
|
||||
[
|
||||
"newer version",
|
||||
"new version available",
|
||||
"update available",
|
||||
"download link",
|
||||
];
|
||||
|
||||
private static readonly TimeSpan CopilotVersionTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan GitHubContextTimeout = TimeSpan.FromSeconds(4);
|
||||
|
||||
internal static async Task<SidecarCopilotCliVersionDiagnosticsDto> GetCliVersionDiagnosticsAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
(string executablePath, string[] arguments) = CreateCliCommand(cliContext, "version");
|
||||
CommandResult result = await RunProcessAsync(
|
||||
executablePath: executablePath,
|
||||
arguments: arguments,
|
||||
environment: cliContext.Environment,
|
||||
timeout: CopilotVersionTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseCliVersionOutput(result.StandardOutput, result.StandardError, result.ExitCode);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "unknown",
|
||||
Detail = "Timed out while checking the installed GitHub Copilot CLI version.",
|
||||
};
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "unknown",
|
||||
Detail = $"Failed to check the installed GitHub Copilot CLI version: {exception.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static (string ExecutablePath, string[] Arguments) CreateCliCommand(
|
||||
CopilotCliContext cliContext,
|
||||
params string[] arguments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cliContext);
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
|
||||
return (
|
||||
cliContext.LaunchPath,
|
||||
[.. cliContext.LaunchArgs, .. arguments]);
|
||||
}
|
||||
|
||||
internal static SidecarCopilotCliVersionDiagnosticsDto ParseCliVersionOutput(
|
||||
string? standardOutput,
|
||||
string? standardError = null,
|
||||
int exitCode = 0)
|
||||
{
|
||||
string output = NormalizeOutput(standardOutput, standardError);
|
||||
string? installedVersion = ExtractInstalledVersion(output);
|
||||
string status = ClassifyCliVersionStatus(output, exitCode);
|
||||
string? latestVersion = status switch
|
||||
{
|
||||
"latest" => installedVersion,
|
||||
"outdated" => ExtractLatestVersion(output, installedVersion),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
string? detail = string.IsNullOrWhiteSpace(output)
|
||||
? null
|
||||
: output;
|
||||
|
||||
if (detail is null && status == "unknown")
|
||||
{
|
||||
detail = exitCode == 0
|
||||
? "GitHub Copilot CLI version could not be determined."
|
||||
: $"GitHub Copilot CLI version check exited with code {exitCode}.";
|
||||
}
|
||||
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
InstalledVersion = installedVersion,
|
||||
LatestVersion = latestVersion,
|
||||
Detail = detail,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyCliVersionStatus(string output, int exitCode = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (LatestVersionIndicators.Any(indicator =>
|
||||
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "latest";
|
||||
}
|
||||
|
||||
if (OutdatedVersionIndicators.Any(indicator =>
|
||||
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "outdated";
|
||||
}
|
||||
|
||||
return exitCode == 0 && ExtractInstalledVersion(output) is not null
|
||||
? "unknown"
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
internal static string? ExtractInstalledVersion(string output)
|
||||
{
|
||||
Match match = SemanticVersionPattern().Match(output);
|
||||
return match.Success ? match.Groups["version"].Value : null;
|
||||
}
|
||||
|
||||
internal static string? ExtractLatestVersion(string output, string? installedVersion)
|
||||
{
|
||||
return SemanticVersionPattern()
|
||||
.Matches(output)
|
||||
.Select(match => match.Groups["version"].Value)
|
||||
.FirstOrDefault(version =>
|
||||
!string.Equals(version, installedVersion, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static async Task<GetAuthStatusResponse?> TryGetAuthStatusAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await client.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to inspect Copilot auth status: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<SidecarCopilotAccountDiagnosticsDto?> CreateAccountDiagnosticsAsync(
|
||||
GetAuthStatusResponse? authStatus,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (authStatus is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? normalizedHost = NormalizeHost(authStatus.Host);
|
||||
IReadOnlyList<string>? organizations = null;
|
||||
|
||||
if (authStatus.IsAuthenticated
|
||||
&& !string.IsNullOrWhiteSpace(authStatus.Login)
|
||||
&& !string.IsNullOrWhiteSpace(normalizedHost))
|
||||
{
|
||||
organizations = await TryListOrganizationsAsync(
|
||||
authStatus.Login,
|
||||
normalizedHost,
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
Authenticated = authStatus.IsAuthenticated,
|
||||
Login = authStatus.Login,
|
||||
Host = normalizedHost,
|
||||
AuthType = authStatus.AuthType,
|
||||
StatusMessage = authStatus.StatusMessage,
|
||||
Organizations = organizations,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? NormalizeHost(string? host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string trimmed = host.Trim();
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.TrimEnd('/');
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> ParseOrganizationsOutput(string? output)
|
||||
{
|
||||
return (output ?? string.Empty)
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>?> TryListOrganizationsAsync(
|
||||
string login,
|
||||
string host,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CommandResult? loginResult = await TryRunGhCommandAsync(
|
||||
["api", "--hostname", host, "user", "--jq", ".login"],
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? resolvedLogin = loginResult is { ExitCode: 0 }
|
||||
? loginResult.StandardOutput.Trim()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedLogin)
|
||||
|| !string.Equals(resolvedLogin, login, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CommandResult? organizationsResult = await TryRunGhCommandAsync(
|
||||
["api", "--hostname", host, "user/orgs", "--jq", ".[].login"],
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return organizationsResult is { ExitCode: 0 }
|
||||
? ParseOrganizationsOutput(organizationsResult.StandardOutput)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<CommandResult?> TryRunGhCommandAsync(
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await RunProcessAsync(
|
||||
executablePath: OperatingSystem.IsWindows() ? "gh.exe" : "gh",
|
||||
arguments,
|
||||
environment,
|
||||
GitHubContextTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunProcessAsync(
|
||||
string executablePath,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
using Process process = new()
|
||||
{
|
||||
StartInfo = CreateProcessStartInfo(executablePath, arguments, environment),
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to start command '{executablePath}'.");
|
||||
}
|
||||
|
||||
process.StandardInput.Close();
|
||||
Task<string> standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token);
|
||||
Task<string> standardErrorTask = process.StandardError.ReadToEndAsync(timeoutSource.Token);
|
||||
|
||||
await process.WaitForExitAsync(timeoutSource.Token).ConfigureAwait(false);
|
||||
|
||||
return new CommandResult(
|
||||
process.ExitCode,
|
||||
await standardOutputTask.ConfigureAwait(false),
|
||||
await standardErrorTask.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateProcessStartInfo(
|
||||
string executablePath,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows() && RequiresWindowsShell(executablePath))
|
||||
{
|
||||
startInfo.FileName = string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ComSpec"))
|
||||
? "cmd.exe"
|
||||
: Environment.GetEnvironmentVariable("ComSpec");
|
||||
|
||||
startInfo.ArgumentList.Add("/d");
|
||||
startInfo.ArgumentList.Add("/s");
|
||||
startInfo.ArgumentList.Add("/c");
|
||||
startInfo.ArgumentList.Add(BuildWindowsShellCommand(executablePath, arguments));
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = executablePath;
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
}
|
||||
|
||||
startInfo.Environment.Clear();
|
||||
foreach (KeyValuePair<string, string> entry in environment)
|
||||
{
|
||||
startInfo.Environment[entry.Key] = entry.Value;
|
||||
}
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static bool RequiresWindowsShell(string executablePath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(executablePath);
|
||||
return extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".bat", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildWindowsShellCommand(string executablePath, IReadOnlyList<string> arguments)
|
||||
{
|
||||
IEnumerable<string> tokens = [executablePath, .. arguments];
|
||||
return string.Join(" ", tokens.Select(QuoteWindowsShellToken));
|
||||
}
|
||||
|
||||
private static string QuoteWindowsShellToken(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
bool needsQuotes = value.Any(character =>
|
||||
char.IsWhiteSpace(character)
|
||||
|| character is '&' or '(' or ')' or '[' or ']' or '{' or '}' or '^' or '=' or ';' or '!' or '+' or ',' or '`' or '~');
|
||||
|
||||
if (!needsQuotes)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
|
||||
}
|
||||
|
||||
private static string NormalizeOutput(string? standardOutput, string? standardError)
|
||||
{
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
new[] { standardOutput, standardError }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!.Trim()));
|
||||
}
|
||||
|
||||
private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError);
|
||||
|
||||
[GeneratedRegex(@"\b(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)]
|
||||
private static partial Regex SemanticVersionPattern();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
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 void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
|
||||
{
|
||||
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
_command.Pattern,
|
||||
agentDefinition.Id,
|
||||
agentDefinition.Name);
|
||||
|
||||
switch (sessionEvent)
|
||||
{
|
||||
case AssistantMessageDeltaEvent messageDelta when !string.IsNullOrWhiteSpace(messageDelta.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, messageDelta.Data!.MessageId);
|
||||
break;
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||
break;
|
||||
case AssistantReasoningDeltaEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
return !string.IsNullOrWhiteSpace(messageId)
|
||||
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
||||
}
|
||||
|
||||
public string CreateMessageId(string? messageId)
|
||||
{
|
||||
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
||||
}
|
||||
|
||||
public TranscriptSegment 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;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
_observedAgentsByMessageId[messageId] = agent;
|
||||
}
|
||||
|
||||
public void UpdateCompletedMessages(
|
||||
IReadOnlyList<ChatMessage> allMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages)
|
||||
{
|
||||
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
|
||||
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
newMessages,
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
|
||||
{
|
||||
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
|
||||
{
|
||||
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
[],
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
return CompletedMessages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
if (validationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException(validationError.Message);
|
||||
}
|
||||
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
onApproval,
|
||||
cancellationToken),
|
||||
(agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent),
|
||||
cancellationToken);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
||||
.ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked
|
||||
&& AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
command.Pattern,
|
||||
invoked.ExecutorId,
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
command,
|
||||
requestInfo,
|
||||
state.ActiveAgent,
|
||||
state.ToolNamesByCallId);
|
||||
|
||||
if (activity is null)
|
||||
{
|
||||
return WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
|
||||
}
|
||||
|
||||
state.ApplyActivity(activity);
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is ExecutorCompletedEvent completed
|
||||
&& AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
completed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity completedAgent))
|
||||
{
|
||||
state.ClearActiveAgentIfMatching(completedAgent);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
|
||||
state.UpdateCompletedMessages(allMessages, inputMessages);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task HandleAgentResponseUpdateAsync(
|
||||
RunTurnCommandDto command,
|
||||
AgentResponseUpdateEvent update,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
{
|
||||
AgentIdentity? updateAgent = null;
|
||||
string authorName = update.ExecutorId;
|
||||
if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent))
|
||||
{
|
||||
updateAgent = observedMessageAgent;
|
||||
authorName = observedMessageAgent.AgentName;
|
||||
}
|
||||
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
update.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedUpdateAgent))
|
||||
{
|
||||
updateAgent = resolvedUpdateAgent;
|
||||
authorName = resolvedUpdateAgent.AgentName;
|
||||
}
|
||||
|
||||
if (updateAgent.HasValue)
|
||||
{
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(update.Update.Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string messageId = state.CreateMessageId(update.Update.MessageId);
|
||||
(string _, string currentAuthorName, string currentContent) = state.AppendDelta(
|
||||
messageId,
|
||||
authorName,
|
||||
update.Update.Text);
|
||||
|
||||
await onDelta(new TurnDeltaEventDto
|
||||
{
|
||||
Type = "turn-delta",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
MessageId = messageId,
|
||||
AuthorName = currentAuthorName,
|
||||
ContentDelta = update.Update.Text,
|
||||
Content = currentContent,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class HandoffWorkflowGuidance
|
||||
{
|
||||
public static string CreateWorkflowInstructions()
|
||||
{
|
||||
return """
|
||||
This workflow uses explicit handoffs to transfer ownership between agents.
|
||||
If another agent should do the substantive work, perform an actual handoff instead of answering as though the handoff already happened.
|
||||
Do not claim that you delegated unless you actually executed the handoff.
|
||||
The triage agent should route to the best specialist promptly once ownership is clear.
|
||||
In a specialist workflow, the triage agent should hand off before inspecting files, calling tools, or drafting the substantive implementation.
|
||||
Do not narrate a handoff in plain text without executing the handoff itself.
|
||||
Specialists should complete the substantive work after handoff and only hand control back when the task needs re-routing, broader coordination, or is outside their specialty.
|
||||
""";
|
||||
}
|
||||
|
||||
public static string CreateForwardReason(PatternAgentDefinitionDto target)
|
||||
{
|
||||
string specialty = string.IsNullOrWhiteSpace(target.Description)
|
||||
? target.Name
|
||||
: target.Description.TrimEnd('.');
|
||||
|
||||
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.Name} own the substantive response.";
|
||||
}
|
||||
|
||||
public static string CreateReturnReason(PatternAgentDefinitionDto triageAgent)
|
||||
{
|
||||
return $"Hand off back to {triageAgent.Name} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public interface ITurnWorkflowRunner
|
||||
{
|
||||
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class LspToolSession : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonSerializerOptions();
|
||||
|
||||
private readonly RunTurnLspProfileConfigDto _profile;
|
||||
private readonly string _projectPath;
|
||||
private readonly Process _process;
|
||||
private readonly Stream _stdin;
|
||||
private readonly Stream _stdout;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonElement?>> _pending =
|
||||
new();
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _documentLock = new(1, 1);
|
||||
private readonly HashSet<string> _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentQueue<string> _stderrLines = new();
|
||||
private readonly Task _stdoutReaderTask;
|
||||
private readonly Task _stderrReaderTask;
|
||||
private int _nextRequestId;
|
||||
|
||||
private LspToolSession(
|
||||
RunTurnLspProfileConfigDto profile,
|
||||
string projectPath,
|
||||
Process process)
|
||||
{
|
||||
_profile = profile;
|
||||
_projectPath = Path.GetFullPath(projectPath);
|
||||
_process = process;
|
||||
_stdin = process.StandardInput.BaseStream;
|
||||
_stdout = process.StandardOutput.BaseStream;
|
||||
_stdoutReaderTask = Task.Run(() => ReadLoopAsync(_cts.Token));
|
||||
_stderrReaderTask = Task.Run(() => ReadErrorLoopAsync(_cts.Token));
|
||||
Tools =
|
||||
[
|
||||
CreateTool(
|
||||
methodName: nameof(WorkspaceSymbolsToolAsync),
|
||||
toolName: $"{BuildToolPrefix(_profile.Id)}_workspace_symbols",
|
||||
description: $"Search workspace symbols using the {_profile.Name} language server. Use this when you know a symbol name or partial symbol name."),
|
||||
CreateTool(
|
||||
methodName: nameof(DocumentSymbolsToolAsync),
|
||||
toolName: $"{BuildToolPrefix(_profile.Id)}_document_symbols",
|
||||
description: $"List document symbols for a file using the {_profile.Name} language server. Pass a path relative to the current project root."),
|
||||
CreateTool(
|
||||
methodName: nameof(DefinitionToolAsync),
|
||||
toolName: $"{BuildToolPrefix(_profile.Id)}_definition",
|
||||
description: $"Resolve the definition location for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
|
||||
CreateTool(
|
||||
methodName: nameof(HoverToolAsync),
|
||||
toolName: $"{BuildToolPrefix(_profile.Id)}_hover",
|
||||
description: $"Fetch hover information for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
|
||||
CreateTool(
|
||||
methodName: nameof(ReferencesToolAsync),
|
||||
toolName: $"{BuildToolPrefix(_profile.Id)}_references",
|
||||
description: $"Find references for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
|
||||
];
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIFunction> Tools { get; }
|
||||
|
||||
internal static JsonSerializerOptions CreateJsonSerializerOptions()
|
||||
{
|
||||
JsonSerializerOptions options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
public static async Task<LspToolSession> StartAsync(
|
||||
RunTurnLspProfileConfigDto profile,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = profile.Command,
|
||||
WorkingDirectory = projectPath,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
foreach (string arg in ResolveProcessArguments(profile))
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
Process? process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (process is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".");
|
||||
}
|
||||
|
||||
LspToolSession session = new(profile, projectPath, process);
|
||||
await session.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
return session;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> ResolveProcessArguments(RunTurnLspProfileConfigDto profile)
|
||||
{
|
||||
List<string> args = profile.Args.ToList();
|
||||
|
||||
if (UsesTypeScriptLanguageServer(profile.Command)
|
||||
&& !args.Any(arg => string.Equals(arg, "--stdio", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
args.Add("--stdio");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stdoutReaderTask.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stderrReaderTask.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_writeLock.Dispose();
|
||||
_documentLock.Dispose();
|
||||
_stdin.Dispose();
|
||||
_stdout.Dispose();
|
||||
_process.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string rootUri = ToFileUri(_projectPath);
|
||||
await SendRequestAsync(
|
||||
"initialize",
|
||||
new
|
||||
{
|
||||
processId = Environment.ProcessId,
|
||||
rootUri,
|
||||
capabilities = new { },
|
||||
workspaceFolders = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
uri = rootUri,
|
||||
name = Path.GetFileName(_projectPath),
|
||||
},
|
||||
},
|
||||
clientInfo = new
|
||||
{
|
||||
name = "Aryx",
|
||||
version = "1.0.0",
|
||||
},
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await SendNotificationAsync("initialized", new { }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private AIFunction CreateTool(string methodName, string toolName, string description)
|
||||
{
|
||||
MethodInfo method = GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"LSP tool method \"{methodName}\" was not found.");
|
||||
|
||||
return AIFunctionFactory.Create(
|
||||
method,
|
||||
this,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = toolName,
|
||||
Description = description,
|
||||
SerializerOptions = JsonOptions,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> WorkspaceSymbolsToolAsync(string query, int limit = 20)
|
||||
{
|
||||
JsonElement? result = await SendRequestAsync(
|
||||
"workspace/symbol",
|
||||
new
|
||||
{
|
||||
query,
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeResult(result, limit);
|
||||
}
|
||||
|
||||
private async Task<string> DocumentSymbolsToolAsync(string relativePath)
|
||||
{
|
||||
string documentPath = ResolveProjectPath(relativePath);
|
||||
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
JsonElement? result = await SendRequestAsync(
|
||||
"textDocument/documentSymbol",
|
||||
new
|
||||
{
|
||||
textDocument = new
|
||||
{
|
||||
uri = documentUri,
|
||||
},
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeResult(result);
|
||||
}
|
||||
|
||||
private async Task<string> DefinitionToolAsync(string relativePath, int line, int character)
|
||||
{
|
||||
string documentPath = ResolveProjectPath(relativePath);
|
||||
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
JsonElement? result = await SendRequestAsync(
|
||||
"textDocument/definition",
|
||||
new
|
||||
{
|
||||
textDocument = new
|
||||
{
|
||||
uri = documentUri,
|
||||
},
|
||||
position = new
|
||||
{
|
||||
line = NormalizePosition(line),
|
||||
character = NormalizePosition(character),
|
||||
},
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeResult(result);
|
||||
}
|
||||
|
||||
private async Task<string> HoverToolAsync(string relativePath, int line, int character)
|
||||
{
|
||||
string documentPath = ResolveProjectPath(relativePath);
|
||||
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
JsonElement? result = await SendRequestAsync(
|
||||
"textDocument/hover",
|
||||
new
|
||||
{
|
||||
textDocument = new
|
||||
{
|
||||
uri = documentUri,
|
||||
},
|
||||
position = new
|
||||
{
|
||||
line = NormalizePosition(line),
|
||||
character = NormalizePosition(character),
|
||||
},
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeResult(result);
|
||||
}
|
||||
|
||||
private async Task<string> ReferencesToolAsync(string relativePath, int line, int character)
|
||||
{
|
||||
string documentPath = ResolveProjectPath(relativePath);
|
||||
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
JsonElement? result = await SendRequestAsync(
|
||||
"textDocument/references",
|
||||
new
|
||||
{
|
||||
textDocument = new
|
||||
{
|
||||
uri = documentUri,
|
||||
},
|
||||
position = new
|
||||
{
|
||||
line = NormalizePosition(line),
|
||||
character = NormalizePosition(character),
|
||||
},
|
||||
context = new
|
||||
{
|
||||
includeDeclaration = true,
|
||||
},
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeResult(result);
|
||||
}
|
||||
|
||||
private async Task<string> EnsureDocumentOpenedAsync(string documentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
string documentUri = ToFileUri(documentPath);
|
||||
|
||||
if (_openedDocumentUris.Contains(documentUri))
|
||||
{
|
||||
return documentUri;
|
||||
}
|
||||
|
||||
await _documentLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_openedDocumentUris.Contains(documentUri))
|
||||
{
|
||||
return documentUri;
|
||||
}
|
||||
|
||||
string text = await File.ReadAllTextAsync(documentPath, cancellationToken).ConfigureAwait(false);
|
||||
await SendNotificationAsync(
|
||||
"textDocument/didOpen",
|
||||
new
|
||||
{
|
||||
textDocument = new
|
||||
{
|
||||
uri = documentUri,
|
||||
languageId = ResolveLanguageId(documentPath),
|
||||
version = 1,
|
||||
text,
|
||||
},
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_openedDocumentUris.Add(documentUri);
|
||||
return documentUri;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_documentLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> SendRequestAsync(
|
||||
string method,
|
||||
object? parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int requestId = Interlocked.Increment(ref _nextRequestId);
|
||||
TaskCompletionSource<JsonElement?> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[requestId] = tcs;
|
||||
|
||||
await WriteMessageAsync(
|
||||
new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id = requestId,
|
||||
method,
|
||||
@params = parameters,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<JsonElement?>)state!).TrySetCanceled();
|
||||
},
|
||||
tcs);
|
||||
|
||||
try
|
||||
{
|
||||
return await tcs.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private Task SendNotificationAsync(string method, object? parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
return WriteMessageAsync(
|
||||
new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
method,
|
||||
@params = parameters,
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WriteMessageAsync(object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
byte[] body = Encoding.UTF8.GetBytes(json);
|
||||
byte[] header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
|
||||
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stdin.WriteAsync(header, cancellationToken).ConfigureAwait(false);
|
||||
await _stdin.WriteAsync(body, cancellationToken).ConfigureAwait(false);
|
||||
await _stdin.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Dictionary<string, string>? headers = await ReadHeadersAsync(_stdout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (headers is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!headers.TryGetValue("Content-Length", out string? contentLengthHeader)
|
||||
|| !int.TryParse(contentLengthHeader, out int contentLength))
|
||||
{
|
||||
throw new InvalidOperationException("LSP response was missing a valid Content-Length header.");
|
||||
}
|
||||
|
||||
byte[] body = await ReadExactAsync(_stdout, contentLength, cancellationToken).ConfigureAwait(false);
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
if (root.TryGetProperty("id", out JsonElement idElement)
|
||||
&& TryReadRequestId(idElement, out int requestId)
|
||||
&& _pending.TryGetValue(requestId, out TaskCompletionSource<JsonElement?>? tcs))
|
||||
{
|
||||
if (root.TryGetProperty("error", out JsonElement errorElement))
|
||||
{
|
||||
string message = errorElement.TryGetProperty("message", out JsonElement messageElement)
|
||||
? messageElement.GetString() ?? "Unknown LSP error."
|
||||
: "Unknown LSP error.";
|
||||
tcs.TrySetException(new InvalidOperationException(message));
|
||||
}
|
||||
else if (root.TryGetProperty("result", out JsonElement resultElement))
|
||||
{
|
||||
tcs.TrySetResult(resultElement.Clone());
|
||||
}
|
||||
else
|
||||
{
|
||||
tcs.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FailPendingRequests(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FailPendingRequests(CreatePendingRequestInterruptedException());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadErrorLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
string? line = await _process.StandardError.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
RecordStderrLine(line);
|
||||
Console.Error.WriteLine($"[aryx lsp:{_profile.Id}] {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, string>?> ReadHeadersAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<byte> bytes = [];
|
||||
byte[] buffer = new byte[1];
|
||||
|
||||
while (true)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return bytes.Count == 0
|
||||
? null
|
||||
: throw new EndOfStreamException("LSP stream ended mid-header.");
|
||||
}
|
||||
|
||||
bytes.Add(buffer[0]);
|
||||
int count = bytes.Count;
|
||||
if (count >= 4
|
||||
&& bytes[count - 4] == '\r'
|
||||
&& bytes[count - 3] == '\n'
|
||||
&& bytes[count - 2] == '\r'
|
||||
&& bytes[count - 1] == '\n')
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string headerText = Encoding.ASCII.GetString(bytes.Take(bytes.Count - 4).ToArray());
|
||||
Dictionary<string, string> headers = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string line in headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
int separatorIndex = line.IndexOf(':');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = line[..separatorIndex].Trim();
|
||||
string value = line[(separatorIndex + 1)..].Trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadExactAsync(
|
||||
Stream stream,
|
||||
int length,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] buffer = new byte[length];
|
||||
int offset = 0;
|
||||
|
||||
while (offset < length)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException("LSP stream ended mid-message.");
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static bool TryReadRequestId(JsonElement idElement, out int requestId)
|
||||
{
|
||||
requestId = default;
|
||||
return idElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => idElement.TryGetInt32(out requestId),
|
||||
JsonValueKind.String => int.TryParse(idElement.GetString(), out requestId),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private void FailPendingRequests(Exception exception)
|
||||
{
|
||||
foreach ((_, TaskCompletionSource<JsonElement?> tcs) in _pending.ToArray())
|
||||
{
|
||||
tcs.TrySetException(exception);
|
||||
}
|
||||
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
private void RecordStderrLine(string line)
|
||||
{
|
||||
_stderrLines.Enqueue(line);
|
||||
while (_stderrLines.Count > 8 && _stderrLines.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private InvalidOperationException CreatePendingRequestInterruptedException()
|
||||
{
|
||||
string message = $"LSP profile \"{_profile.Name}\" stopped before a pending request completed.";
|
||||
string[] stderrLines = _stderrLines.ToArray();
|
||||
|
||||
if (stderrLines.Length == 0)
|
||||
{
|
||||
return new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
string detail = string.Join(" ", stderrLines.TakeLast(3));
|
||||
return new InvalidOperationException($"{message} Last stderr: {detail}");
|
||||
}
|
||||
|
||||
private string ResolveProjectPath(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new InvalidOperationException("A project-relative path is required.");
|
||||
}
|
||||
|
||||
string fullPath = Path.IsPathRooted(relativePath)
|
||||
? Path.GetFullPath(relativePath)
|
||||
: Path.GetFullPath(Path.Combine(_projectPath, relativePath));
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Could not find \"{relativePath}\" in the current project.", fullPath);
|
||||
}
|
||||
|
||||
string normalizedProjectPath = Path.TrimEndingDirectorySeparator(_projectPath);
|
||||
if (!fullPath.StartsWith(normalizedProjectPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("LSP tools can only access files inside the current project.");
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private string ResolveLanguageId(string documentPath)
|
||||
{
|
||||
string extension = Path.GetExtension(documentPath);
|
||||
if (_profile.FileExtensions.Any(candidate =>
|
||||
string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return _profile.LanguageId;
|
||||
}
|
||||
|
||||
return _profile.LanguageId;
|
||||
}
|
||||
|
||||
private static string BuildToolPrefix(string value)
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
foreach (char ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length == 0 || builder[^1] == '_')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('_');
|
||||
}
|
||||
|
||||
string prefix = builder.ToString().Trim('_');
|
||||
return string.IsNullOrWhiteSpace(prefix) ? "lsp" : $"lsp_{prefix}";
|
||||
}
|
||||
|
||||
private static int NormalizePosition(int value)
|
||||
{
|
||||
return Math.Max(value - 1, 0);
|
||||
}
|
||||
|
||||
private static bool UsesTypeScriptLanguageServer(string command)
|
||||
{
|
||||
string executableName = Path.GetFileName(command.Trim()).ToLowerInvariant();
|
||||
return executableName is "typescript-language-server"
|
||||
or "typescript-language-server.cmd"
|
||||
or "typescript-language-server.exe";
|
||||
}
|
||||
|
||||
private static string ToFileUri(string path)
|
||||
{
|
||||
return new Uri(path).AbsoluteUri;
|
||||
}
|
||||
|
||||
private static string SerializeResult(JsonElement? result, int? maxItems = null)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
JsonElement output = result.Value;
|
||||
if (maxItems.HasValue && output.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
JsonElement[] limited = output.EnumerateArray().Take(Math.Max(maxItems.Value, 0)).ToArray();
|
||||
return JsonSerializer.Serialize(limited, JsonOptions);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(output, JsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed record PatternHandoffRoute(string SourceAgentId, string TargetAgentId);
|
||||
|
||||
internal sealed record PatternHandoffTopology(string EntryAgentId, IReadOnlyList<PatternHandoffRoute> Routes);
|
||||
|
||||
internal static class PatternGraphResolver
|
||||
{
|
||||
private const string UserInputKind = "user-input";
|
||||
private const string UserOutputKind = "user-output";
|
||||
private const string AgentKind = "agent";
|
||||
private const string DistributorKind = "distributor";
|
||||
private const string CollectorKind = "collector";
|
||||
private const string OrchestratorKind = "orchestrator";
|
||||
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static PatternGraphDto Resolve(PatternDefinitionDto pattern)
|
||||
=> pattern.Graph ?? CreateDefault(pattern);
|
||||
|
||||
public static IReadOnlyList<string> ResolveOrderedAgentIds(PatternDefinitionDto pattern)
|
||||
{
|
||||
PatternGraphDto graph = Resolve(pattern);
|
||||
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" or "sequential" or "magentic" => ResolveLinearAgentIds(pattern, graph),
|
||||
"concurrent" or "group-chat" or "handoff" => ResolveAgentOrder(pattern, graph),
|
||||
_ => pattern.Agents.Select(agent => agent.Id).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public static PatternHandoffTopology ResolveHandoff(PatternDefinitionDto pattern)
|
||||
{
|
||||
return TryResolveHandoff(pattern, Resolve(pattern))
|
||||
?? TryResolveHandoff(pattern, CreateDefault(pattern))
|
||||
?? new PatternHandoffTopology(
|
||||
pattern.Agents.FirstOrDefault()?.Id ?? string.Empty,
|
||||
[]);
|
||||
}
|
||||
|
||||
public static PatternGraphDto CreateDefault(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" or "sequential" or "magentic" => CreateLinearGraph(pattern.Agents),
|
||||
"concurrent" => CreateConcurrentGraph(pattern.Agents),
|
||||
"handoff" => CreateHandoffGraph(pattern.Agents),
|
||||
"group-chat" => CreateGroupChatGraph(pattern.Agents),
|
||||
_ => CreateLinearGraph(pattern.Agents)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveLinearAgentIds(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||
{
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, UserOutputKind);
|
||||
if (inputNode is null || outputNode is null)
|
||||
{
|
||||
return pattern.Agents.Select(agent => agent.Id).ToList();
|
||||
}
|
||||
|
||||
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<string> orderedAgentIds = [];
|
||||
HashSet<string> visitedNodeIds = [];
|
||||
string currentNodeId = inputNode.Id;
|
||||
|
||||
while (visitedNodeIds.Add(currentNodeId))
|
||||
{
|
||||
if (!outgoing.TryGetValue(currentNodeId, out List<PatternGraphEdgeDto>? edges) || edges.Count != 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string nextNodeId = edges[0].Target;
|
||||
if (!nodesById.TryGetValue(nextNodeId, out PatternGraphNodeDto? nextNode))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(nextNode.Id, outputNode.Id))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(nextNode.Kind, AgentKind) && !string.IsNullOrWhiteSpace(nextNode.AgentId))
|
||||
{
|
||||
orderedAgentIds.Add(nextNode.AgentId);
|
||||
}
|
||||
|
||||
currentNodeId = nextNodeId;
|
||||
}
|
||||
|
||||
return orderedAgentIds.Count == pattern.Agents.Count
|
||||
? orderedAgentIds
|
||||
: pattern.Agents.Select(agent => agent.Id).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveAgentOrder(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, int> fallbackOrder = pattern.Agents
|
||||
.Select((agent, index) => new { agent.Id, Index = index })
|
||||
.ToDictionary(item => item.Id, item => item.Index);
|
||||
|
||||
List<string> orderedAgentIds = graph.Nodes
|
||||
.Where(node => Comparer.Equals(node.Kind, AgentKind) && !string.IsNullOrWhiteSpace(node.AgentId))
|
||||
.OrderBy(node => node.Order ?? int.MaxValue)
|
||||
.ThenBy(node => fallbackOrder.GetValueOrDefault(node.AgentId!, int.MaxValue))
|
||||
.Select(node => node.AgentId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return orderedAgentIds.Count == pattern.Agents.Count
|
||||
? orderedAgentIds
|
||||
: pattern.Agents.Select(agent => agent.Id).ToList();
|
||||
}
|
||||
|
||||
private static PatternHandoffTopology? TryResolveHandoff(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
||||
string? entryAgentId = null;
|
||||
|
||||
if (inputNode is not null)
|
||||
{
|
||||
entryAgentId = graph.Edges
|
||||
.Where(edge => Comparer.Equals(edge.Source, inputNode.Id))
|
||||
.Select(edge => nodesById.TryGetValue(edge.Target, out PatternGraphNodeDto? targetNode)
|
||||
? targetNode.AgentId
|
||||
: null)
|
||||
.FirstOrDefault(agentId => !string.IsNullOrWhiteSpace(agentId));
|
||||
}
|
||||
|
||||
List<PatternHandoffRoute> routes = graph.Edges
|
||||
.Select(edge => (SourceNode: nodesById.GetValueOrDefault(edge.Source), TargetNode: nodesById.GetValueOrDefault(edge.Target)))
|
||||
.Where(item =>
|
||||
item.SourceNode is not null
|
||||
&& item.TargetNode is not null
|
||||
&& Comparer.Equals(item.SourceNode.Kind, AgentKind)
|
||||
&& Comparer.Equals(item.TargetNode.Kind, AgentKind)
|
||||
&& !string.IsNullOrWhiteSpace(item.SourceNode.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(item.TargetNode.AgentId))
|
||||
.Select(item => new PatternHandoffRoute(item.SourceNode!.AgentId!, item.TargetNode!.AgentId!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entryAgentId) || routes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PatternHandoffTopology(entryAgentId!, routes);
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> lookup = new(StringComparer.Ordinal);
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
lookup[node.Id] = [];
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!lookup.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
||||
{
|
||||
edges = [];
|
||||
lookup[edge.Source] = edges;
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
||||
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
||||
|
||||
private static PatternGraphDto CreateLinearGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 220 * Math.Max(agents.Count + 1, 2), 0);
|
||||
List<PatternGraphNodeDto> agentNodes = agents
|
||||
.Select((agent, index) => CreateAgentNode(agent, index, 220 * (index + 1), 0))
|
||||
.ToList();
|
||||
List<PatternGraphEdgeDto> edges = [];
|
||||
List<string> path = [inputNode.Id, .. agentNodes.Select(node => node.Id), outputNode.Id];
|
||||
for (int index = 0; index < path.Count - 1; index += 1)
|
||||
{
|
||||
edges.Add(CreateEdge(path[index], path[index + 1]));
|
||||
}
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = [inputNode, .. agentNodes, outputNode],
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphDto CreateConcurrentGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto distributorNode = CreateSystemNode("system-distributor", DistributorKind, 190, 0);
|
||||
PatternGraphNodeDto collectorNode = CreateSystemNode("system-collector", CollectorKind, 650, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
||||
List<PatternGraphNodeDto> agentNodes = agents
|
||||
.Select((agent, index) => CreateAgentNode(agent, index, 430, SpreadY(index, Math.Max(agents.Count, 1), 170)))
|
||||
.ToList();
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = [inputNode, distributorNode, .. agentNodes, collectorNode, outputNode],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge(inputNode.Id, distributorNode.Id),
|
||||
.. agentNodes.Select(node => CreateEdge(distributorNode.Id, node.Id)),
|
||||
.. agentNodes.Select(node => CreateEdge(node.Id, collectorNode.Id)),
|
||||
CreateEdge(collectorNode.Id, outputNode.Id)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphDto CreateHandoffGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
||||
PatternAgentDefinitionDto? entryAgent = agents.FirstOrDefault();
|
||||
PatternGraphNodeDto? entryNode = entryAgent is null
|
||||
? null
|
||||
: CreateAgentNode(entryAgent, 0, 220, 0);
|
||||
List<PatternGraphNodeDto> specialistNodes = agents
|
||||
.Skip(1)
|
||||
.Select((agent, index) => CreateAgentNode(agent, index + 1, 540, SpreadY(index, Math.Max(agents.Count - 1, 1), 220)))
|
||||
.ToList();
|
||||
|
||||
List<PatternGraphEdgeDto> edges = [];
|
||||
if (entryNode is not null)
|
||||
{
|
||||
edges.Add(CreateEdge(inputNode.Id, entryNode.Id));
|
||||
edges.Add(CreateEdge(entryNode.Id, outputNode.Id));
|
||||
|
||||
foreach (PatternGraphNodeDto specialistNode in specialistNodes)
|
||||
{
|
||||
edges.Add(CreateEdge(entryNode.Id, specialistNode.Id));
|
||||
edges.Add(CreateEdge(specialistNode.Id, entryNode.Id));
|
||||
edges.Add(CreateEdge(specialistNode.Id, outputNode.Id));
|
||||
}
|
||||
}
|
||||
|
||||
List<PatternGraphNodeDto> nodes = [inputNode];
|
||||
if (entryNode is not null)
|
||||
{
|
||||
nodes.Add(entryNode);
|
||||
}
|
||||
nodes.AddRange(specialistNodes);
|
||||
nodes.Add(outputNode);
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = nodes,
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphDto CreateGroupChatGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto orchestratorNode = CreateSystemNode("system-orchestrator", OrchestratorKind, 250, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 900, 0);
|
||||
const double centerX = 560;
|
||||
const double centerY = 0;
|
||||
const double radiusX = 190;
|
||||
const double radiusY = 170;
|
||||
|
||||
List<PatternGraphNodeDto> agentNodes = agents
|
||||
.Select((agent, index) =>
|
||||
{
|
||||
double angle = agents.Count <= 1
|
||||
? 0
|
||||
: (Math.PI * 2 * index) / agents.Count - (Math.PI / 2);
|
||||
return CreateAgentNode(
|
||||
agent,
|
||||
index,
|
||||
Math.Round(centerX + Math.Cos(angle) * radiusX),
|
||||
Math.Round(centerY + Math.Sin(angle) * radiusY));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = [inputNode, orchestratorNode, .. agentNodes, outputNode],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge(inputNode.Id, orchestratorNode.Id),
|
||||
.. agentNodes.SelectMany(node => new[]
|
||||
{
|
||||
CreateEdge(orchestratorNode.Id, node.Id),
|
||||
CreateEdge(node.Id, orchestratorNode.Id)
|
||||
}),
|
||||
CreateEdge(orchestratorNode.Id, outputNode.Id)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphNodeDto CreateSystemNode(string id, string kind, double x, double y)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Position = new PatternGraphPositionDto
|
||||
{
|
||||
X = x,
|
||||
Y = y
|
||||
}
|
||||
};
|
||||
|
||||
private static PatternGraphNodeDto CreateAgentNode(PatternAgentDefinitionDto agent, int order, double x, double y)
|
||||
=> new()
|
||||
{
|
||||
Id = $"agent-node-{agent.Id}",
|
||||
Kind = AgentKind,
|
||||
AgentId = agent.Id,
|
||||
Order = order,
|
||||
Position = new PatternGraphPositionDto
|
||||
{
|
||||
X = x,
|
||||
Y = y
|
||||
}
|
||||
};
|
||||
|
||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||
=> new()
|
||||
{
|
||||
Id = $"edge-{source}-to-{target}",
|
||||
Source = source,
|
||||
Target = target
|
||||
};
|
||||
|
||||
private static double SpreadY(int index, int count, double gap)
|
||||
=> (index - ((count - 1) / 2d)) * gap;
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class PatternValidator
|
||||
{
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
|
||||
{
|
||||
List<PatternValidationIssueDto> issues = [];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Name))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "name",
|
||||
Message = "Pattern name is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Availability, "unavailable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "availability",
|
||||
Message = pattern.UnavailabilityReason ?? "This orchestration mode is currently unavailable.",
|
||||
});
|
||||
}
|
||||
|
||||
if (pattern.Agents.Count == 0)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "At least one agent is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "single", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count != 1)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Single-agent chat requires exactly one agent.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Handoff orchestration requires at least two agents.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Group chat requires at least two agents.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "magentic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "mode",
|
||||
Message = pattern.UnavailabilityReason
|
||||
?? "Magentic orchestration is currently documented as unsupported in the .NET Agent Framework.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents.name",
|
||||
Message = "Every agent needs a name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agent.Model))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents.model",
|
||||
Message = $"Agent \"{agent.Name}\" requires a model identifier.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), issues);
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static void ValidateGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
if (graph.Nodes.Count == 0)
|
||||
{
|
||||
AddGraphIssue(issues, "Pattern graph must include nodes.");
|
||||
return;
|
||||
}
|
||||
|
||||
HashSet<string> nodeIds = new(StringComparer.Ordinal);
|
||||
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
||||
HashSet<string> agentIds = pattern.Agents.Select(agent => agent.Id).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> seenAgentIds = new(StringComparer.Ordinal);
|
||||
HashSet<int> seenAgentOrders = [];
|
||||
Dictionary<string, PatternGraphNodeDto> nodesById = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
if (!nodeIds.Add(node.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains duplicate node \"{node.Id}\".");
|
||||
}
|
||||
|
||||
nodesById[node.Id] = node;
|
||||
|
||||
if (Comparer.Equals(node.Kind, "agent"))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.AgentId) || !agentIds.Contains(node.AgentId))
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must reference a known agent.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node.AgentId) && !seenAgentIds.Add(node.AgentId))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains multiple nodes for agent \"{node.AgentId}\".");
|
||||
}
|
||||
|
||||
if (!node.Order.HasValue)
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must define an order.");
|
||||
}
|
||||
else if (!seenAgentOrders.Add(node.Order.Value))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains duplicate agent order \"{node.Order.Value}\".");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(node.AgentId))
|
||||
{
|
||||
AddGraphIssue(issues, $"System node \"{node.Id}\" cannot reference an agent.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
||||
{
|
||||
if (!seenAgentIds.Contains(agent.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph is missing node metadata for agent \"{agent.Id}\".");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!edgeIds.Add(edge.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains duplicate edge \"{edge.Id}\".");
|
||||
}
|
||||
|
||||
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph edge \"{edge.Id}\" must connect known nodes.");
|
||||
}
|
||||
}
|
||||
|
||||
switch (pattern.Mode)
|
||||
{
|
||||
case "single":
|
||||
case "sequential":
|
||||
case "magentic":
|
||||
ValidateLinearGraph(pattern, graph, issues);
|
||||
break;
|
||||
case "concurrent":
|
||||
ValidateConcurrentGraph(pattern, graph, issues);
|
||||
break;
|
||||
case "handoff":
|
||||
ValidateHandoffGraph(graph, issues);
|
||||
break;
|
||||
case "group-chat":
|
||||
ValidateGroupChatGraph(pattern, graph, issues);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLinearGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
|
||||
if (graph.Edges.Count != pattern.Agents.Count + 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Linear orchestration graphs must be a single path from user input through every agent to user output.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "User input must start exactly one path.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(outputNode.Id, []).Count != 1 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
||||
{
|
||||
AddGraphIssue(issues, "User output must terminate exactly one path.");
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto node in agentNodes)
|
||||
{
|
||||
if (incoming.GetValueOrDefault(node.Id, []).Count != 1 || outgoing.GetValueOrDefault(node.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Each agent in a linear orchestration must have exactly one incoming and one outgoing edge.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
string currentNodeId = inputNode.Id;
|
||||
while (visited.Add(currentNodeId))
|
||||
{
|
||||
List<PatternGraphEdgeDto> nextEdges = outgoing.GetValueOrDefault(currentNodeId, []);
|
||||
if (nextEdges.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextEdges.Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Linear orchestration nodes may only branch to one next step.");
|
||||
break;
|
||||
}
|
||||
|
||||
currentNodeId = nextEdges[0].Target;
|
||||
if (Comparer.Equals(currentNodeId, outputNode.Id))
|
||||
{
|
||||
visited.Add(currentNodeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> expectedVisited = new(StringComparer.Ordinal)
|
||||
{
|
||||
inputNode.Id,
|
||||
outputNode.Id
|
||||
};
|
||||
foreach (PatternGraphNodeDto node in agentNodes)
|
||||
{
|
||||
expectedVisited.Add(node.Id);
|
||||
}
|
||||
|
||||
if (!expectedVisited.SetEquals(visited))
|
||||
{
|
||||
AddGraphIssue(issues, "Linear orchestration graphs must visit every agent exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateConcurrentGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "distributor", "collector", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? distributorNode = GetNodeByKind(graph, "distributor");
|
||||
PatternGraphNodeDto? collectorNode = GetNodeByKind(graph, "collector");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || distributorNode is null || collectorNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
HashSet<string> distributorTargets = outgoing.GetValueOrDefault(distributorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> collectorSources = incoming.GetValueOrDefault(collectorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
||||
{
|
||||
AddGraphIssue(issues, "Concurrent orchestration graphs must fan out from the distributor and fan back into the collector.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "User input must connect only to the distributor.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(distributorNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Distributor must receive exactly one edge from user input.");
|
||||
}
|
||||
|
||||
if (outgoing.GetValueOrDefault(collectorNode.Id, []).Count != 1 || incoming.GetValueOrDefault(outputNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Collector must forward exactly one edge to user output.");
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||
{
|
||||
if (!distributorTargets.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Distributor must connect to agent \"{agentNode.AgentId}\".");
|
||||
}
|
||||
|
||||
if (!collectorSources.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect to the collector.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateHandoffGraph(
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
HashSet<string> agentNodeIds = agentNodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
|
||||
List<PatternGraphEdgeDto> entryEdges = outgoing.GetValueOrDefault(inputNode.Id, []);
|
||||
List<PatternGraphEdgeDto> completionEdges = incoming.GetValueOrDefault(outputNode.Id, []);
|
||||
|
||||
if (entryEdges.Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff graphs must connect user input to exactly one entry agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agentNodeIds.Contains(entryEdges[0].Target))
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff entry edges must target an agent node.");
|
||||
}
|
||||
|
||||
if (completionEdges.Count == 0)
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff graphs must allow at least one agent to complete back to user output.");
|
||||
}
|
||||
|
||||
bool hasAgentToAgentRoute = false;
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (Comparer.Equals(edge.Source, inputNode.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(edge.Target, outputNode.Id))
|
||||
{
|
||||
if (!agentNodeIds.Contains(edge.Source))
|
||||
{
|
||||
AddGraphIssue(issues, "Only agent nodes may complete to user output.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentNodeIds.Contains(edge.Source) || !agentNodeIds.Contains(edge.Target))
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff routes may only connect agents to agents or agents to user output.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(edge.Source, edge.Target))
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff routes cannot target the same agent node.");
|
||||
}
|
||||
|
||||
hasAgentToAgentRoute = true;
|
||||
}
|
||||
|
||||
if (!hasAgentToAgentRoute && agentNodes.Count > 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff graphs must include at least one agent-to-agent handoff route.");
|
||||
}
|
||||
|
||||
HashSet<string> reachable = new(StringComparer.Ordinal);
|
||||
Stack<string> stack = new Stack<string>([entryEdges[0].Target]);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
string nodeId = stack.Pop();
|
||||
if (!reachable.Add(nodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in outgoing.GetValueOrDefault(nodeId, []))
|
||||
{
|
||||
if (agentNodeIds.Contains(edge.Target) && !reachable.Contains(edge.Target))
|
||||
{
|
||||
stack.Push(edge.Target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||
{
|
||||
if (!reachable.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Handoff entry agent must be able to reach \"{agentNode.AgentId}\".");
|
||||
}
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
||||
{
|
||||
AddGraphIssue(issues, "User input cannot have incoming edges and user output cannot have outgoing edges.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateGroupChatGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "orchestrator", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? orchestratorNode = GetNodeByKind(graph, "orchestrator");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || orchestratorNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
HashSet<string> orchestratorTargets = outgoing.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> orchestratorSources = incoming.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
||||
{
|
||||
AddGraphIssue(issues, "Group chat graphs must connect the orchestrator to every participant and then back to user output.");
|
||||
}
|
||||
|
||||
if (outgoing.GetValueOrDefault(inputNode.Id, []).Any(edge => !Comparer.Equals(edge.Target, orchestratorNode.Id)))
|
||||
{
|
||||
AddGraphIssue(issues, "User input must only connect to the orchestrator.");
|
||||
}
|
||||
|
||||
if (!outgoing.GetValueOrDefault(orchestratorNode.Id, []).Any(edge => Comparer.Equals(edge.Target, outputNode.Id)))
|
||||
{
|
||||
AddGraphIssue(issues, "Group chat orchestrator must connect to user output.");
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||
{
|
||||
if (!orchestratorTargets.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Orchestrator must connect to agent \"{agentNode.AgentId}\".");
|
||||
}
|
||||
|
||||
if (!orchestratorSources.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect back to the orchestrator.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSystemNodeCounts(
|
||||
PatternGraphDto graph,
|
||||
IReadOnlyList<string> expectedKinds,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
Dictionary<string, int> counts = graph.Nodes
|
||||
.GroupBy(node => node.Kind, Comparer)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), Comparer);
|
||||
HashSet<string> expected = expectedKinds.ToHashSet(Comparer);
|
||||
|
||||
foreach (string kind in expectedKinds)
|
||||
{
|
||||
if (counts.GetValueOrDefault(kind, 0) != 1)
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph must include exactly one \"{kind}\" node.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((string kind, int count) in counts)
|
||||
{
|
||||
if (Comparer.Equals(kind, "agent"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!expected.Contains(kind) && count > 0)
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph does not allow \"{kind}\" nodes in this mode.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
||||
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
||||
|
||||
private static List<PatternGraphNodeDto> GetAgentNodes(PatternGraphDto graph)
|
||||
=> graph.Nodes.Where(node => Comparer.Equals(node.Kind, "agent")).ToList();
|
||||
|
||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildIncomingLookup(PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = new(StringComparer.Ordinal);
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
incoming[node.Id] = [];
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!incoming.TryGetValue(edge.Target, out List<PatternGraphEdgeDto>? edges))
|
||||
{
|
||||
edges = [];
|
||||
incoming[edge.Target] = edges;
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
return incoming;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = new(StringComparer.Ordinal);
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
outgoing[node.Id] = [];
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!outgoing.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
||||
{
|
||||
edges = [];
|
||||
outgoing[edge.Source] = edges;
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
private static void AddGraphIssue(List<PatternValidationIssueDto> issues, string message)
|
||||
=> issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "graph",
|
||||
Message = message,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class SessionToolingBundle : IAsyncDisposable
|
||||
{
|
||||
private const string LocalTransport = "local";
|
||||
private const string WildcardToolName = "*";
|
||||
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
private SessionToolingBundle(
|
||||
Dictionary<string, object> mcpServers,
|
||||
IReadOnlyList<AIFunction> tools)
|
||||
{
|
||||
McpServers = mcpServers;
|
||||
Tools = tools;
|
||||
}
|
||||
|
||||
public Dictionary<string, object> McpServers { get; }
|
||||
|
||||
public IReadOnlyList<AIFunction> Tools { get; }
|
||||
|
||||
public static async Task<SessionToolingBundle> CreateAsync(
|
||||
RunTurnToolingConfigDto? tooling,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []);
|
||||
(List<AIFunction> tools, List<IAsyncDisposable> disposables) =
|
||||
await BuildLspToolingAsync(tooling?.LspProfiles ?? [], projectPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
SessionToolingBundle bundle = new(mcpServers, tools);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
{
|
||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Dictionary<string, object> BuildMcpServerConfigurations(
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto> servers)
|
||||
{
|
||||
Dictionary<string, object> configurations = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (RunTurnMcpServerConfigDto server in servers)
|
||||
{
|
||||
configurations[ResolveServerName(server)] = CreateServerConfiguration(server);
|
||||
}
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
private static async Task<(List<AIFunction> Tools, List<IAsyncDisposable> Disposables)> BuildLspToolingAsync(
|
||||
IReadOnlyList<RunTurnLspProfileConfigDto> profiles,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<AIFunction> tools = [];
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
|
||||
foreach (RunTurnLspProfileConfigDto profile in profiles)
|
||||
{
|
||||
LspToolSession session = await LspToolSession.StartAsync(profile, projectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
disposables.Add(session);
|
||||
tools.AddRange(session.Tools);
|
||||
}
|
||||
|
||||
return (tools, disposables);
|
||||
}
|
||||
|
||||
private static object CreateServerConfiguration(RunTurnMcpServerConfigDto server)
|
||||
{
|
||||
return string.Equals(server.Transport, LocalTransport, StringComparison.OrdinalIgnoreCase)
|
||||
? CreateLocalServerConfiguration(server)
|
||||
: CreateRemoteServerConfiguration(server);
|
||||
}
|
||||
|
||||
private static McpLocalServerConfig CreateLocalServerConfiguration(RunTurnMcpServerConfigDto server)
|
||||
{
|
||||
string serverName = ResolveServerName(server);
|
||||
if (string.IsNullOrWhiteSpace(server.Command))
|
||||
{
|
||||
throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a command.");
|
||||
}
|
||||
|
||||
return new McpLocalServerConfig
|
||||
{
|
||||
Type = LocalTransport,
|
||||
Timeout = server.TimeoutMs,
|
||||
Command = server.Command,
|
||||
Args = server.Args?.ToList() ?? [],
|
||||
Env = server.Env is null ? null : new Dictionary<string, string>(server.Env, StringComparer.Ordinal),
|
||||
Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd,
|
||||
Tools = ResolveTools(server),
|
||||
};
|
||||
}
|
||||
|
||||
private static McpRemoteServerConfig CreateRemoteServerConfiguration(RunTurnMcpServerConfigDto server)
|
||||
{
|
||||
string serverName = ResolveServerName(server);
|
||||
if (string.IsNullOrWhiteSpace(server.Url))
|
||||
{
|
||||
throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a URL.");
|
||||
}
|
||||
|
||||
return new McpRemoteServerConfig
|
||||
{
|
||||
Type = server.Transport,
|
||||
Timeout = server.TimeoutMs,
|
||||
Url = server.Url,
|
||||
Headers = server.Headers is null ? null : new Dictionary<string, string>(server.Headers, StringComparer.Ordinal),
|
||||
Tools = ResolveTools(server),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveServerName(RunTurnMcpServerConfigDto server)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim();
|
||||
}
|
||||
|
||||
private static List<string> ResolveTools(RunTurnMcpServerConfigDto server)
|
||||
{
|
||||
return server.Tools.Count == 0 ? [WildcardToolName] : server.Tools.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class SidecarProtocolHost
|
||||
{
|
||||
private const string DescribeCapabilitiesCommandType = "describe-capabilities";
|
||||
private const string ValidatePatternCommandType = "validate-pattern";
|
||||
private const string RunTurnCommandType = "run-turn";
|
||||
private const string CancelTurnCommandType = "cancel-turn";
|
||||
private const string ResolveApprovalCommandType = "resolve-approval";
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"not signed in",
|
||||
"not logged in",
|
||||
"reauth",
|
||||
"credential",
|
||||
];
|
||||
|
||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = new(StringComparer.Ordinal);
|
||||
|
||||
public SidecarProtocolHost()
|
||||
: this(new PatternValidator())
|
||||
{
|
||||
}
|
||||
|
||||
public SidecarProtocolHost(
|
||||
PatternValidator patternValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
|
||||
{
|
||||
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
||||
[ValidatePatternCommandType] = HandleValidatePatternAsync,
|
||||
[RunTurnCommandType] = HandleRunTurnAsync,
|
||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task RunAsync(TextReader input, TextWriter output, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
string? line = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SidecarCommandEnvelope envelope = DeserializeEnvelope(line);
|
||||
TrackInFlightRequest(
|
||||
envelope.RequestId,
|
||||
HandleCommandAsync(line, envelope, output, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(_inFlight.Values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private SidecarCommandEnvelope DeserializeEnvelope(string line)
|
||||
{
|
||||
return JsonSerializer.Deserialize<SidecarCommandEnvelope>(line, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Could not deserialize sidecar command envelope.");
|
||||
}
|
||||
|
||||
private void TrackInFlightRequest(string requestId, Task task)
|
||||
{
|
||||
_inFlight[requestId] = task;
|
||||
_ = task.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
_inFlight.TryRemove(requestId, out Task? removedTask);
|
||||
return removedTask is not null;
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private async Task HandleCommandAsync(
|
||||
string rawCommand,
|
||||
SidecarCommandEnvelope envelope,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CommandContext context = new(rawCommand, envelope, output, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await ExecuteCommandAsync(context).ConfigureAwait(false);
|
||||
await WriteCommandCompleteAsync(context).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteCommandErrorAsync(context, ex.Message).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task ExecuteCommandAsync(CommandContext context)
|
||||
{
|
||||
if (_commandHandlers.TryGetValue(context.Envelope.Type, out Func<CommandContext, Task>? handler))
|
||||
{
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Unknown sidecar command type '{context.Envelope.Type}'.");
|
||||
}
|
||||
|
||||
private async Task HandleDescribeCapabilitiesAsync(CommandContext context)
|
||||
{
|
||||
await WriteAsync(context.Output, new CapabilitiesEventDto
|
||||
{
|
||||
Type = "capabilities",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Capabilities = await _capabilitiesProvider(context.CancellationToken).ConfigureAwait(false),
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleValidatePatternAsync(CommandContext context)
|
||||
{
|
||||
ValidatePatternCommandDto command = DeserializeCommand<ValidatePatternCommandDto>(context);
|
||||
|
||||
await WriteAsync(context.Output, new PatternValidationEventDto
|
||||
{
|
||||
Type = "pattern-validation",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Issues = _patternValidator.Validate(command.Pattern),
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleRunTurnAsync(CommandContext context)
|
||||
{
|
||||
RunTurnCommandDto command = DeserializeCommand<RunTurnCommandDto>(context);
|
||||
using CancellationTokenSource turnCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
|
||||
if (!_turnCancellations.TryAdd(context.Envelope.RequestId, turnCancellation))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
|
||||
command,
|
||||
delta => WriteAsync(context.Output, delta, turnCancellation.Token),
|
||||
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
||||
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
||||
turnCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await WriteTurnCompleteAsync(
|
||||
context.Output,
|
||||
context.Envelope.RequestId,
|
||||
command.SessionId,
|
||||
messages,
|
||||
cancelled: false,
|
||||
context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (turnCancellation.IsCancellationRequested)
|
||||
{
|
||||
await WriteTurnCompleteAsync(
|
||||
context.Output,
|
||||
context.Envelope.RequestId,
|
||||
command.SessionId,
|
||||
[],
|
||||
cancelled: true,
|
||||
context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleCancelTurnAsync(CommandContext context)
|
||||
{
|
||||
CancelTurnCommandDto command = DeserializeCommand<CancelTurnCommandDto>(context);
|
||||
if (_turnCancellations.TryGetValue(command.TargetRequestId, out CancellationTokenSource? turnCancellation))
|
||||
{
|
||||
try
|
||||
{
|
||||
turnCancellation.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The turn completed between lookup and cancellation.
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleResolveApprovalAsync(CommandContext context)
|
||||
{
|
||||
ResolveApprovalCommandDto command = DeserializeCommand<ResolveApprovalCommandDto>(context);
|
||||
await _workflowRunner.ResolveApprovalAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TCommand DeserializeCommand<TCommand>(CommandContext context)
|
||||
where TCommand : SidecarCommandEnvelope
|
||||
{
|
||||
return JsonSerializer.Deserialize<TCommand>(context.RawCommand, _jsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Could not deserialize {context.Envelope.Type} command.");
|
||||
}
|
||||
|
||||
private Task WriteCommandCompleteAsync(CommandContext context)
|
||||
{
|
||||
return WriteAsync(context.Output, new CommandCompleteEventDto
|
||||
{
|
||||
Type = "command-complete",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
}, context.CancellationToken);
|
||||
}
|
||||
|
||||
private Task WriteTurnCompleteAsync(
|
||||
TextWriter output,
|
||||
string requestId,
|
||||
string sessionId,
|
||||
IReadOnlyList<ChatMessageDto> messages,
|
||||
bool cancelled,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return WriteAsync(output, new TurnCompleteEventDto
|
||||
{
|
||||
Type = "turn-complete",
|
||||
RequestId = requestId,
|
||||
SessionId = sessionId,
|
||||
Messages = messages,
|
||||
Cancelled = cancelled,
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private Task WriteCommandErrorAsync(CommandContext context, string message)
|
||||
{
|
||||
return WriteAsync(context.Output, new CommandErrorEventDto
|
||||
{
|
||||
Type = "command-error",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Message = message,
|
||||
}, context.CancellationToken);
|
||||
}
|
||||
|
||||
private async Task WriteAsync(TextWriter output, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await output.WriteLineAsync(json).ConfigureAwait(false);
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
|
||||
return CreateCapabilities([], [], connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
GetAuthStatusResponse? authStatus =
|
||||
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
|
||||
authStatus,
|
||||
cliContext.Environment,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
|
||||
}
|
||||
}
|
||||
|
||||
private static SidecarCapabilitiesDto CreateCapabilities(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
|
||||
SidecarConnectionDiagnosticsDto connection)
|
||||
{
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
RuntimeTools = runtimeTools,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
|
||||
{
|
||||
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
["sequential"] = new() { Available = true },
|
||||
["concurrent"] = new() { Available = true },
|
||||
["handoff"] = new() { Available = true },
|
||||
["group-chat"] = new() { Available = true },
|
||||
["magentic"] = new()
|
||||
{
|
||||
Available = false,
|
||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return models
|
||||
.Select(model => new SidecarModelCapabilityDto
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name,
|
||||
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
|
||||
.Where(IsReasoningEffort)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
|
||||
? model.DefaultReasoningEffort
|
||||
: null,
|
||||
})
|
||||
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
|
||||
return result.Tools
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
|
||||
.Select(tool => new SidecarRuntimeToolDto
|
||||
{
|
||||
Id = tool.Name.Trim(),
|
||||
Label = tool.Name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
|
||||
})
|
||||
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsReasoningEffort(string? value)
|
||||
{
|
||||
return value is "low" or "medium" or "high" or "xhigh";
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
|
||||
{
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "copilot-cli-missing",
|
||||
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
|
||||
Detail = exception.Message,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
0 => "Connected to GitHub Copilot, but no models were reported.",
|
||||
1 => "Connected to GitHub Copilot. 1 model is available.",
|
||||
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
|
||||
};
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
? "GitHub Copilot requires authentication before Aryx can load models."
|
||||
: "GitHub Copilot was found, but Aryx could not load its model list.";
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyConnectionStatus(Exception exception)
|
||||
{
|
||||
string message = exception.Message;
|
||||
if (AuthenticationErrorIndicators.Any(indicator =>
|
||||
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "copilot-auth-required";
|
||||
}
|
||||
|
||||
return "copilot-error";
|
||||
}
|
||||
|
||||
private sealed record CommandContext(
|
||||
string RawCommand,
|
||||
SidecarCommandEnvelope Envelope,
|
||||
TextWriter Output,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed record CapabilityProbeResult(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> Models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
|
||||
SidecarConnectionDiagnosticsDto Connection);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static partial class StreamingTextMerger
|
||||
{
|
||||
private const double SnapshotReplacementMinLengthRatio = 0.6;
|
||||
private const int SnapshotReplacementMinTokenCount = 3;
|
||||
private const double SnapshotReplacementSharedTokenRatio = 0.5;
|
||||
private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`";
|
||||
|
||||
public static string Merge(string current, string incoming)
|
||||
{
|
||||
if (string.IsNullOrEmpty(current))
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(incoming))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
if (TryMergeSnapshotVariants(current, incoming, out string merged)
|
||||
|| TryMergeByOverlap(current, incoming, out merged))
|
||||
{
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (ShouldReplaceWithSnapshot(current, incoming))
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return current + ResolveBoundarySeparator(current, incoming) + incoming;
|
||||
}
|
||||
|
||||
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
|
||||
{
|
||||
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||
{
|
||||
merged = incoming;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||
{
|
||||
merged = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
|
||||
{
|
||||
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
|
||||
if (overlapLength == 0)
|
||||
{
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
merged = current + incoming[overlapLength..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ResolveBoundarySeparator(string current, string incoming)
|
||||
{
|
||||
if (ShouldInsertNewlineBoundary(current, incoming))
|
||||
{
|
||||
return "\n";
|
||||
}
|
||||
|
||||
return ShouldInsertSpaceBoundary(current, incoming)
|
||||
? " "
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
|
||||
{
|
||||
int maxOverlap = Math.Min(current.Length, incoming.Length);
|
||||
for (int length = maxOverlap; length > 0; length--)
|
||||
{
|
||||
if (string.CompareOrdinal(current, current.Length - length, incoming, 0, length) == 0)
|
||||
{
|
||||
return length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool ShouldReplaceWithSnapshot(string current, string incoming)
|
||||
{
|
||||
if (!HasViableSnapshotLength(current, incoming))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<string> currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> incomingTokens = Tokenize(incoming).ToHashSet(StringComparer.Ordinal);
|
||||
if (!HasEnoughTokensForSnapshotComparison(currentTokens, incomingTokens))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int sharedTokenCount = incomingTokens.Count(token => currentTokens.Contains(token));
|
||||
double sharedTokenRatio = sharedTokenCount / (double)Math.Min(currentTokens.Count, incomingTokens.Count);
|
||||
return sharedTokenRatio >= SnapshotReplacementSharedTokenRatio;
|
||||
}
|
||||
|
||||
private static bool HasViableSnapshotLength(string current, string incoming)
|
||||
{
|
||||
return incoming.Length >= Math.Floor(current.Length * SnapshotReplacementMinLengthRatio);
|
||||
}
|
||||
|
||||
private static bool HasEnoughTokensForSnapshotComparison(
|
||||
HashSet<string> currentTokens,
|
||||
HashSet<string> incomingTokens)
|
||||
{
|
||||
return currentTokens.Count >= SnapshotReplacementMinTokenCount
|
||||
&& incomingTokens.Count >= SnapshotReplacementMinTokenCount;
|
||||
}
|
||||
|
||||
private static bool ShouldInsertNewlineBoundary(string current, string incoming)
|
||||
{
|
||||
return !current.EndsWith('\n')
|
||||
&& MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
|
||||
}
|
||||
|
||||
private static bool ShouldInsertSpaceBoundary(string current, string incoming)
|
||||
{
|
||||
char lastCharacter = current[^1];
|
||||
char firstCharacter = incoming[0];
|
||||
if (HasExistingBoundary(lastCharacter, firstCharacter)
|
||||
|| CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ClosingPunctuationRegex().IsMatch(incoming))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|
||||
|| LooksLikeWordBoundary(current, incoming);
|
||||
}
|
||||
|
||||
private static bool HasExistingBoundary(char lastCharacter, char firstCharacter)
|
||||
{
|
||||
return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter);
|
||||
}
|
||||
|
||||
private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming)
|
||||
{
|
||||
return MarkdownInlinePrefixRegex().IsMatch(incoming)
|
||||
|| char.IsUpper(firstCharacter)
|
||||
|| char.IsDigit(firstCharacter);
|
||||
}
|
||||
|
||||
private static bool LooksLikeWordBoundary(string current, string incoming)
|
||||
{
|
||||
string[] currentTokens = Tokenize(current).ToArray();
|
||||
string[] incomingTokens = Tokenize(incoming).ToArray();
|
||||
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
return currentTokens.Length >= 2
|
||||
&& incomingTokens.Length >= 2
|
||||
&& firstIncomingToken.Length >= 2;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string value)
|
||||
{
|
||||
return TokenRegex()
|
||||
.Matches(value.ToLowerInvariant())
|
||||
.Select(match => match.Value)
|
||||
.Where(token => token.Length > 0);
|
||||
}
|
||||
|
||||
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TokenRegex();
|
||||
|
||||
[GeneratedRegex(@"^[.,!?;:%)\]}]")]
|
||||
private static partial Regex ClosingPunctuationRegex();
|
||||
|
||||
[GeneratedRegex(@"^[*_`~\[]")]
|
||||
private static partial Regex MarkdownInlinePrefixRegex();
|
||||
|
||||
[GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)]
|
||||
private static partial Regex MarkdownBlockPrefixRegex();
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class WorkflowRequestInfoInterpreter
|
||||
{
|
||||
private const string HandoffActivityType = "handoff";
|
||||
private const string ToolCallingActivityType = "tool-calling";
|
||||
private const string CodeInterpreterToolName = "code interpreter";
|
||||
private const string ImageGenerationToolName = "image generation";
|
||||
|
||||
public static AgentActivityEventDto? TryCreateActivityFromRequest(
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo,
|
||||
AgentIdentity? activeAgent,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo);
|
||||
return interpretation switch
|
||||
{
|
||||
HandoffRequestInterpretation handoff =>
|
||||
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
|
||||
ToolRequestInterpretation tool when activeAgent.HasValue =>
|
||||
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool RequiresUserInputTurnBoundary(
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)
|
||||
&& InterpretRequest(command.Pattern, requestInfo) is UnknownRequestInterpretation;
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateHandoffActivity(
|
||||
RunTurnCommandDto command,
|
||||
AgentIdentity handoffAgent,
|
||||
AgentIdentity? activeAgent)
|
||||
{
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ActivityType = HandoffActivityType,
|
||||
AgentId = handoffAgent.AgentId,
|
||||
AgentName = handoffAgent.AgentName,
|
||||
SourceAgentId = activeAgent?.AgentId,
|
||||
SourceAgentName = activeAgent?.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateToolCallingActivity(
|
||||
RunTurnCommandDto command,
|
||||
AgentIdentity activeAgent,
|
||||
ToolRequestInterpretation tool,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ActivityType = ToolCallingActivityType,
|
||||
AgentId = activeAgent.AgentId,
|
||||
AgentName = activeAgent.AgentName,
|
||||
ToolName = tool.ToolName,
|
||||
};
|
||||
}
|
||||
|
||||
private static void TrackToolCallId(
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId,
|
||||
string? toolCallId,
|
||||
string toolName)
|
||||
{
|
||||
if (toolCallId is not null)
|
||||
{
|
||||
toolNamesByCallId[toolCallId] = toolName;
|
||||
}
|
||||
}
|
||||
|
||||
private static RequestInterpretation InterpretRequest(
|
||||
PatternDefinitionDto pattern,
|
||||
RequestInfoEvent requestInfo)
|
||||
{
|
||||
if (TryGetHandoffTarget(pattern, requestInfo, out AgentIdentity handoffAgent))
|
||||
{
|
||||
return new HandoffRequestInterpretation(handoffAgent);
|
||||
}
|
||||
|
||||
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)
|
||||
? new ToolRequestInterpretation(toolName, toolCallId)
|
||||
: new UnknownRequestInterpretation();
|
||||
}
|
||||
|
||||
private static bool TryGetHandoffTarget(
|
||||
PatternDefinitionDto pattern,
|
||||
RequestInfoEvent requestInfo,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
|
||||
object? handoffValue = requestInfo.Request.Data.As<object>();
|
||||
if (handoffValue is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WorkflowRequestHandoffPayload? handoffTarget = DeserializeHandoffPayload(handoffValue);
|
||||
if (handoffTarget?.Target is not WorkflowRequestHandoffAgentPayload target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
pattern,
|
||||
target.Id,
|
||||
target.Name);
|
||||
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||
}
|
||||
|
||||
private static bool TryGetToolRequestInfo(
|
||||
RequestInfoEvent requestInfo,
|
||||
out string toolName,
|
||||
out string? toolCallId)
|
||||
{
|
||||
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)
|
||||
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId);
|
||||
}
|
||||
|
||||
private static bool TryGetStableToolRequestInfo(
|
||||
PortableValue requestData,
|
||||
out string toolName,
|
||||
out string? toolCallId)
|
||||
{
|
||||
if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall))
|
||||
{
|
||||
toolName = NormalizeOptionalString(functionCall.Name) ?? "function";
|
||||
toolCallId = NormalizeOptionalString(functionCall.CallId);
|
||||
return true;
|
||||
}
|
||||
|
||||
toolName = string.Empty;
|
||||
toolCallId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetEvaluationToolRequestInfo(
|
||||
PortableValue requestData,
|
||||
out string toolName,
|
||||
out string? toolCallId)
|
||||
{
|
||||
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
|
||||
{
|
||||
toolName = NormalizeOptionalString(mcpToolCall.ToolName)
|
||||
?? NormalizeOptionalString(mcpToolCall.ServerName)
|
||||
?? string.Empty;
|
||||
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
|
||||
return toolName.Length > 0;
|
||||
}
|
||||
|
||||
if (requestData.Is<CodeInterpreterToolCallContent>(out CodeInterpreterToolCallContent? codeInterpreterToolCall))
|
||||
{
|
||||
toolName = CodeInterpreterToolName;
|
||||
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requestData.Is<ImageGenerationToolCallContent>())
|
||||
{
|
||||
toolName = ImageGenerationToolName;
|
||||
toolCallId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
toolName = string.Empty;
|
||||
toolCallId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static WorkflowRequestHandoffPayload? DeserializeHandoffPayload(object handoffValue)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType());
|
||||
return JsonSerializer.Deserialize<WorkflowRequestHandoffPayload>(json);
|
||||
}
|
||||
|
||||
private abstract record RequestInterpretation;
|
||||
|
||||
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
|
||||
|
||||
private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation;
|
||||
|
||||
private sealed record UnknownRequestInterpretation : RequestInterpretation;
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRequestHandoffPayload
|
||||
{
|
||||
public WorkflowRequestHandoffAgentPayload? Target { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRequestHandoffAgentPayload
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.Text;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct TranscriptSegment(string MessageId, string AuthorName, string Content)
|
||||
{
|
||||
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
|
||||
=> new(segment.MessageId, segment.AuthorName, segment.Content);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return ProjectCompletedMessagesFromSegments(
|
||||
command,
|
||||
newMessages,
|
||||
segments.Select(TranscriptSegment.FromTuple).ToList(),
|
||||
fallbackAgent);
|
||||
}
|
||||
|
||||
internal static List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
IReadOnlyList<TranscriptSegment> segments,
|
||||
AgentIdentity? fallbackAgent = null)
|
||||
{
|
||||
List<ChatMessageDto> projectedMessages = [];
|
||||
int fallbackOutputIndex = 0;
|
||||
string createdAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments);
|
||||
List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
|
||||
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
|
||||
|
||||
for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++)
|
||||
{
|
||||
ChatMessage message = assistantMessages[messageIndex];
|
||||
TranscriptSegment? matchedSegment = TryMatchSegment(
|
||||
message,
|
||||
remainingSegments,
|
||||
assistantMessages.Count - messageIndex,
|
||||
command.Pattern,
|
||||
fallbackAgent);
|
||||
string content = message.Text ?? matchedSegment?.Content ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchedSegment.HasValue)
|
||||
{
|
||||
remainingSegments.Remove(matchedSegment.Value);
|
||||
}
|
||||
|
||||
fallbackOutputIndex++;
|
||||
projectedMessages.Add(CreateProjectedMessage(
|
||||
command,
|
||||
message,
|
||||
matchedSegment,
|
||||
fallbackAgent,
|
||||
createdAt,
|
||||
fallbackOutputIndex,
|
||||
content));
|
||||
}
|
||||
|
||||
if (projectedMessages.Count == 0 && preparedSegments.Count > 0)
|
||||
{
|
||||
projectedMessages.AddRange(preparedSegments.Select(segment =>
|
||||
CreateProjectedMessageFromSegment(command, segment, createdAt)));
|
||||
}
|
||||
|
||||
return projectedMessages;
|
||||
}
|
||||
|
||||
private static ChatMessageDto CreateProjectedMessage(
|
||||
RunTurnCommandDto command,
|
||||
ChatMessage message,
|
||||
TranscriptSegment? matchedSegment,
|
||||
AgentIdentity? fallbackAgent,
|
||||
string createdAt,
|
||||
int fallbackOutputIndex,
|
||||
string content)
|
||||
{
|
||||
return new ChatMessageDto
|
||||
{
|
||||
Id = matchedSegment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
||||
AuthorName = ResolveProjectedAuthorName(
|
||||
command.Pattern,
|
||||
message.AuthorName,
|
||||
matchedSegment?.AuthorName,
|
||||
fallbackAgent),
|
||||
Content = content,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessageDto CreateProjectedMessageFromSegment(
|
||||
RunTurnCommandDto command,
|
||||
TranscriptSegment segment,
|
||||
string createdAt)
|
||||
{
|
||||
return new ChatMessageDto
|
||||
{
|
||||
Id = segment.MessageId,
|
||||
Role = "assistant",
|
||||
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
|
||||
Content = segment.Content,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<TranscriptSegment> PrepareSegmentsForProjection(
|
||||
PatternDefinitionDto pattern,
|
||||
IReadOnlyList<TranscriptSegment> segments)
|
||||
{
|
||||
if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal)
|
||||
|| segments.Count <= 1)
|
||||
{
|
||||
return segments.ToList();
|
||||
}
|
||||
|
||||
// Agent Framework concurrent workflows aggregate the last message emitted by each agent.
|
||||
// Collapse streamed segments to the most recent segment per author, preserving the order
|
||||
// in which those authors most recently completed so positional fallback stays aligned.
|
||||
Dictionary<string, (TranscriptSegment Segment, int LastIndex)> latestSegmentByAuthor =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
for (int index = 0; index < segments.Count; index++)
|
||||
{
|
||||
TranscriptSegment segment = segments[index];
|
||||
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName);
|
||||
latestSegmentByAuthor[authorKey] = (segment, index);
|
||||
}
|
||||
|
||||
return latestSegmentByAuthor.Values
|
||||
.OrderBy(entry => entry.LastIndex)
|
||||
.Select(entry => entry.Segment)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static TranscriptSegment? TryMatchSegment(
|
||||
ChatMessage message,
|
||||
IReadOnlyList<TranscriptSegment> remainingSegments,
|
||||
int remainingMessageCount,
|
||||
PatternDefinitionDto pattern,
|
||||
AgentIdentity? fallbackAgent)
|
||||
{
|
||||
if (remainingSegments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? messageText = string.IsNullOrWhiteSpace(message.Text) ? null : message.Text;
|
||||
if (messageText is not null)
|
||||
{
|
||||
string resolvedAuthorName = ResolveProjectedAuthorName(
|
||||
pattern,
|
||||
message.AuthorName,
|
||||
fallbackIdentifier: null,
|
||||
fallbackAgent);
|
||||
|
||||
if (TryFindSegment(
|
||||
remainingSegments,
|
||||
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
|
||||
&& string.Equals(
|
||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
||||
resolvedAuthorName,
|
||||
StringComparison.Ordinal),
|
||||
out TranscriptSegment authorMatchedSegment))
|
||||
{
|
||||
return authorMatchedSegment;
|
||||
}
|
||||
|
||||
if (TryFindSegment(
|
||||
remainingSegments,
|
||||
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal),
|
||||
out TranscriptSegment contentMatchedSegment))
|
||||
{
|
||||
return contentMatchedSegment;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingMessageCount == 1)
|
||||
{
|
||||
if (fallbackAgent.HasValue
|
||||
&& AgentIdentityResolver.IsGenericAssistantIdentifier(message.AuthorName)
|
||||
&& TryFindLastSegment(
|
||||
remainingSegments,
|
||||
segment => string.Equals(
|
||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
||||
fallbackAgent.Value.AgentName,
|
||||
StringComparison.Ordinal),
|
||||
out TranscriptSegment fallbackMatchedSegment))
|
||||
{
|
||||
return fallbackMatchedSegment;
|
||||
}
|
||||
|
||||
return remainingSegments[^1];
|
||||
}
|
||||
|
||||
return remainingSegments.Count == remainingMessageCount
|
||||
? remainingSegments[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryFindSegment(
|
||||
IReadOnlyList<TranscriptSegment> segments,
|
||||
Func<TranscriptSegment, bool> predicate,
|
||||
out TranscriptSegment matchedSegment)
|
||||
{
|
||||
foreach (TranscriptSegment segment in segments)
|
||||
{
|
||||
if (predicate(segment))
|
||||
{
|
||||
matchedSegment = segment;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
matchedSegment = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryFindLastSegment(
|
||||
IReadOnlyList<TranscriptSegment> segments,
|
||||
Func<TranscriptSegment, bool> predicate,
|
||||
out TranscriptSegment matchedSegment)
|
||||
{
|
||||
for (int index = segments.Count - 1; index >= 0; index--)
|
||||
{
|
||||
TranscriptSegment segment = segments[index];
|
||||
if (predicate(segment))
|
||||
{
|
||||
matchedSegment = segment;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
matchedSegment = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
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<BufferedTranscriptSegment> _segments = [];
|
||||
|
||||
public int Count => _segments.Count;
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName);
|
||||
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
|
||||
segment.SetAuthorName(authorName);
|
||||
return segment.ToSnapshot();
|
||||
}
|
||||
|
||||
public IReadOnlyList<TranscriptSegment> Snapshot()
|
||||
{
|
||||
return _segments.Select(segment => segment.ToSnapshot()).ToList();
|
||||
}
|
||||
|
||||
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
|
||||
{
|
||||
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
BufferedTranscriptSegment created = new(messageId, authorName);
|
||||
_segments.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private sealed class BufferedTranscriptSegment
|
||||
{
|
||||
public BufferedTranscriptSegment(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 TranscriptSegment ToSnapshot()
|
||||
{
|
||||
return new TranscriptSegment(MessageId, AuthorName, Content.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user