refactor: rename sidecar host to Aryx

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-26 00:14:12 +01:00
co-authored by Copilot
parent 4e3c74497f
commit 1edd3aed55
48 changed files with 104 additions and 104 deletions
@@ -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}")]
+10
View File
@@ -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());
}
}
}