mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 12:48:48 +02:00
refactor: rename Kopaya to Eryx
Rename the product, runtime surfaces, sidecar projects, docs, tests, and packaging outputs from Kopaya to Eryx across the repository. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Eryx.AgentHost.Tests")]
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Eryx.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 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 IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public string UpdatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
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 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 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 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 sealed class TurnCompleteEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ChatMessageDto> Messages { 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? ToolName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CommandCompleteEventDto : SidecarEventDto;
|
||||
@@ -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,10 @@
|
||||
using Eryx.AgentHost.Services;
|
||||
|
||||
if (!args.Contains("--stdio", StringComparer.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine("Eryx.AgentHost expects the --stdio flag.");
|
||||
return;
|
||||
}
|
||||
|
||||
SidecarProtocolHost host = new();
|
||||
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Text;
|
||||
using Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
|
||||
|
||||
internal static class AgentIdentityResolver
|
||||
{
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
if (string.IsNullOrWhiteSpace(agentIdentifier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier);
|
||||
if (match is null
|
||||
&& IsGenericAssistantIdentifier(agentIdentifier)
|
||||
&& pattern.Agents.Count == 1)
|
||||
{
|
||||
match = pattern.Agents[0];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (match is null
|
||||
&& pattern.Agents.Count == 1
|
||||
&& (IsGenericAssistantIdentifier(agentId) || IsGenericAssistantIdentifier(agentName)))
|
||||
{
|
||||
match = pattern.Agents[0];
|
||||
}
|
||||
|
||||
if (match is not null)
|
||||
{
|
||||
return ToAgentIdentity(match);
|
||||
}
|
||||
|
||||
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
|
||||
? agentId
|
||||
: agentName ?? "agent";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentName))
|
||||
{
|
||||
return new AgentIdentity(resolvedAgentId, agentName);
|
||||
}
|
||||
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentId);
|
||||
}
|
||||
|
||||
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 "assistant";
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
if (normalizedId.Length > 0
|
||||
&& normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizedId.Length > 0
|
||||
&& normalizedName.Length > 0
|
||||
&& normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal)
|
||||
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal static bool IsGenericAssistantIdentifier(string? candidate)
|
||||
{
|
||||
return string.Equals(
|
||||
NormalizeComparisonKey(candidate),
|
||||
"assistant",
|
||||
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,50 @@
|
||||
using Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.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 pure ad-hoc Q&A rather than repository automation.
|
||||
Do not inspect, modify, create, or delete files, and do not behave as though you are working inside a user project.
|
||||
Answer conversationally and focus on the user's question directly.
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
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.
|
||||
Do not perform the specialist's implementation, design, or execution work yourself once a specialist is appropriate.
|
||||
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,187 @@
|
||||
using System.Collections;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotCliPathResolver
|
||||
{
|
||||
private const string CopilotCommandName = "copilot";
|
||||
private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD";
|
||||
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(
|
||||
"Eryx 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 (string.IsNullOrWhiteSpace(entry.Key) || entry.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string normalizedKey = entry.Key.ToUpperInvariant();
|
||||
if (BlockedCliEnvironmentPrefixes.Any(prefix => normalizedKey.StartsWith(prefix, StringComparison.Ordinal)))
|
||||
{
|
||||
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, []);
|
||||
}
|
||||
|
||||
string launchPath = string.IsNullOrWhiteSpace(commandProcessorPath)
|
||||
? "cmd.exe"
|
||||
: commandProcessorPath;
|
||||
|
||||
return new CopilotCliLaunch(
|
||||
launchPath,
|
||||
["/d", "/s", "/c", CopilotCommandName]);
|
||||
}
|
||||
|
||||
private static string? ResolveCliPath(
|
||||
string? pathValue,
|
||||
string? pathExtValue,
|
||||
bool isWindows,
|
||||
Func<string, bool> fileExists)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pathValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
|
||||
foreach (string directory in pathValue
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(segment => segment.Trim('"'))
|
||||
.Where(segment => !string.IsNullOrWhiteSpace(segment))
|
||||
.Distinct(comparer))
|
||||
{
|
||||
foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows))
|
||||
{
|
||||
string candidatePath = Path.Combine(directory, candidateName);
|
||||
if (fileExists(candidatePath))
|
||||
{
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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 Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.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($"[eryx 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,630 @@
|
||||
using System.Text;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Eryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.GitHub.Copilot;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private static readonly Type? HandoffTargetType = LoadType(
|
||||
"Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows");
|
||||
private static readonly Type? FunctionCallContentType = LoadType(
|
||||
"Microsoft.Extensions.AI.FunctionCallContent, Microsoft.Extensions.AI.Abstractions");
|
||||
private static readonly Type? McpServerToolCallContentType = LoadType(
|
||||
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions");
|
||||
private static readonly Type? CodeInterpreterToolCallContentType = LoadType(
|
||||
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions");
|
||||
private static readonly Type? ImageGenerationToolCallContentType = LoadType(
|
||||
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions");
|
||||
private readonly PatternValidator _patternValidator;
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
if (validationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException(validationError.Message);
|
||||
}
|
||||
|
||||
await using AgentBundle bundle = await AgentBundle.CreateAsync(command, cancellationToken);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(ToChatMessage).ToList();
|
||||
|
||||
List<StreamingSegment> segments = [];
|
||||
int fallbackMessageIndex = 0;
|
||||
List<ChatMessageDto> completedMessages = [];
|
||||
AgentIdentity? activeAgent = null;
|
||||
HashSet<string> startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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))
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked
|
||||
&& AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
command.Pattern,
|
||||
invoked.ExecutorId,
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
activeAgent = invokedAgent;
|
||||
await EmitThinkingIfNeeded(
|
||||
command,
|
||||
invokedAgent,
|
||||
startedAgents,
|
||||
onActivity).ConfigureAwait(false);
|
||||
}
|
||||
else if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
AgentActivityEventDto? activity = TryCreateActivityFromRequest(
|
||||
command,
|
||||
requestInfo,
|
||||
activeAgent);
|
||||
|
||||
if (activity is not null)
|
||||
{
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
activeAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
|
||||
}
|
||||
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
AgentIdentity? updateAgent = null;
|
||||
string authorName = update.ExecutorId;
|
||||
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
update.ExecutorId,
|
||||
activeAgent,
|
||||
out AgentIdentity resolvedUpdateAgent))
|
||||
{
|
||||
updateAgent = resolvedUpdateAgent;
|
||||
authorName = resolvedUpdateAgent.AgentName;
|
||||
}
|
||||
|
||||
if (updateAgent.HasValue)
|
||||
{
|
||||
activeAgent = updateAgent.Value;
|
||||
await EmitThinkingIfNeeded(
|
||||
command,
|
||||
updateAgent.Value,
|
||||
startedAgents,
|
||||
onActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(update.Update.Text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}";
|
||||
StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName);
|
||||
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), update.Update.Text));
|
||||
|
||||
await onDelta(new TurnDeltaEventDto
|
||||
{
|
||||
Type = "turn-delta",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
MessageId = messageId,
|
||||
AuthorName = authorName,
|
||||
ContentDelta = update.Update.Text,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
else if (evt is ExecutorCompletedEvent completed
|
||||
&& AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
completed.ExecutorId,
|
||||
activeAgent,
|
||||
out AgentIdentity completedAgent))
|
||||
{
|
||||
if (activeAgent.HasValue
|
||||
&& string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
||||
{
|
||||
activeAgent = null;
|
||||
}
|
||||
}
|
||||
else if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
|
||||
List<ChatMessage> newMessages = SelectNewOutputMessages(allMessages, inputMessages);
|
||||
completedMessages = ProjectCompletedMessages(
|
||||
command,
|
||||
newMessages,
|
||||
segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList(),
|
||||
activeAgent);
|
||||
}
|
||||
}
|
||||
|
||||
if (completedMessages.Count == 0 && segments.Count > 0)
|
||||
{
|
||||
completedMessages = ProjectCompletedMessages(
|
||||
command,
|
||||
[],
|
||||
segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList(),
|
||||
activeAgent);
|
||||
}
|
||||
|
||||
return completedMessages;
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateActivityEvent(
|
||||
RunTurnCommandDto command,
|
||||
string activityType,
|
||||
AgentIdentity agent,
|
||||
string? toolName = null)
|
||||
{
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ActivityType = activityType,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolName = toolName,
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto? TryCreateActivityFromRequest(
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo,
|
||||
AgentIdentity? activeAgent)
|
||||
{
|
||||
if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent))
|
||||
{
|
||||
return CreateActivityEvent(
|
||||
command,
|
||||
activityType: "handoff",
|
||||
agent: handoffAgent);
|
||||
}
|
||||
|
||||
if (!activeAgent.HasValue || !TryGetToolName(requestInfo, out string toolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateActivityEvent(
|
||||
command,
|
||||
activityType: "tool-calling",
|
||||
agent: activeAgent.Value,
|
||||
toolName: toolName);
|
||||
}
|
||||
|
||||
private static async Task EmitThinkingIfNeeded(
|
||||
RunTurnCommandDto command,
|
||||
AgentIdentity agent,
|
||||
ISet<string> startedAgents,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
{
|
||||
if (!startedAgents.Add(agent.AgentId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onActivity(CreateActivityEvent(
|
||||
command,
|
||||
activityType: "thinking",
|
||||
agent: agent)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TryGetHandoffTarget(
|
||||
PatternDefinitionDto pattern,
|
||||
RequestInfoEvent requestInfo,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget);
|
||||
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
pattern,
|
||||
GetStringProperty(target, "Id"),
|
||||
GetStringProperty(target, "Name"));
|
||||
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||
}
|
||||
|
||||
private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName)
|
||||
{
|
||||
if (TryReadPortableValue(requestInfo.Request.Data, FunctionCallContentType, out object? functionCall))
|
||||
{
|
||||
toolName = GetStringProperty(functionCall, "Name") ?? "function";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPortableValue(requestInfo.Request.Data, McpServerToolCallContentType, out object? mcpToolCall))
|
||||
{
|
||||
toolName = GetStringProperty(mcpToolCall, "ToolName")
|
||||
?? GetStringProperty(mcpToolCall, "ServerName")
|
||||
?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(toolName);
|
||||
}
|
||||
|
||||
if (TryReadPortableValue(requestInfo.Request.Data, CodeInterpreterToolCallContentType, out _))
|
||||
{
|
||||
toolName = "code interpreter";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPortableValue(requestInfo.Request.Data, ImageGenerationToolCallContentType, out _))
|
||||
{
|
||||
toolName = "image generation";
|
||||
return true;
|
||||
}
|
||||
|
||||
toolName = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Type? LoadType(string assemblyQualifiedName)
|
||||
{
|
||||
return Type.GetType(assemblyQualifiedName, throwOnError: false);
|
||||
}
|
||||
|
||||
private static bool TryReadPortableValue(PortableValue portableValue, Type? targetType, out object? value)
|
||||
{
|
||||
value = null;
|
||||
if (targetType is null || !portableValue.IsType(targetType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = portableValue.AsType(targetType);
|
||||
return value is not null;
|
||||
}
|
||||
|
||||
private static string? GetStringProperty(object? instance, string propertyName)
|
||||
{
|
||||
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string;
|
||||
}
|
||||
|
||||
private static StreamingSegment GetOrCreateSegment(List<StreamingSegment> segments, string messageId, string authorName)
|
||||
{
|
||||
StreamingSegment? existing = segments.LastOrDefault(segment => segment.MessageId == messageId);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
StreamingSegment created = new(messageId, authorName);
|
||||
segments.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
internal static List<ChatMessageDto> ProjectCompletedMessages(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments,
|
||||
AgentIdentity? fallbackAgent = null)
|
||||
{
|
||||
List<ChatMessageDto> mapped = [];
|
||||
int segmentIndex = 0;
|
||||
int fallbackOutputIndex = 0;
|
||||
|
||||
foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User))
|
||||
{
|
||||
(string MessageId, string AuthorName, string Content)? segment =
|
||||
segmentIndex < segments.Count ? segments[segmentIndex] : null;
|
||||
string content = message.Text ?? segment?.Content ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.HasValue)
|
||||
{
|
||||
segmentIndex++;
|
||||
}
|
||||
|
||||
fallbackOutputIndex++;
|
||||
|
||||
mapped.Add(new ChatMessageDto
|
||||
{
|
||||
Id = segment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
||||
AuthorName = ResolveProjectedAuthorName(
|
||||
command.Pattern,
|
||||
message.AuthorName,
|
||||
segment?.AuthorName,
|
||||
fallbackAgent),
|
||||
Content = content,
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
});
|
||||
}
|
||||
|
||||
if (mapped.Count == 0 && segments.Count > 0)
|
||||
{
|
||||
mapped.AddRange(segments.Select(segment => new ChatMessageDto
|
||||
{
|
||||
Id = segment.MessageId,
|
||||
Role = "assistant",
|
||||
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
|
||||
Content = segment.Content,
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
}));
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
internal 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);
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private sealed class StreamingSegment
|
||||
{
|
||||
public StreamingSegment(string messageId, string authorName)
|
||||
{
|
||||
MessageId = messageId;
|
||||
AuthorName = authorName;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string AuthorName { get; }
|
||||
|
||||
public StringBuilder Content { get; } = new();
|
||||
|
||||
public void SetContent(string value)
|
||||
{
|
||||
Content.Clear();
|
||||
Content.Append(value);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AgentBundle : IAsyncDisposable
|
||||
{
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
private AgentBundle(IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
Agents = agents;
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public static async Task<AgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
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 = ApprovePermissionAsync,
|
||||
Streaming = true,
|
||||
};
|
||||
|
||||
if (isScratchpad)
|
||||
{
|
||||
sessionConfig.AvailableTools = [];
|
||||
}
|
||||
|
||||
GitHubCopilotAgent agent = new(
|
||||
client,
|
||||
sessionConfig,
|
||||
ownsClient: true,
|
||||
id: definition.Id,
|
||||
name: definition.Name,
|
||||
description: definition.Description);
|
||||
|
||||
agents.Add(agent);
|
||||
disposables.Add(agent);
|
||||
}
|
||||
|
||||
AgentBundle bundle = new(agents);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents),
|
||||
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents),
|
||||
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, Agents),
|
||||
"handoff" => BuildHandoffWorkflow(pattern),
|
||||
"group-chat" => BuildGroupChatWorkflow(pattern),
|
||||
"magentic" => throw new NotSupportedException(
|
||||
pattern.UnavailabilityReason
|
||||
?? "Magentic orchestration is not yet supported in the .NET Agent Framework."),
|
||||
_ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."),
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
{
|
||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
AIAgent firstAgent = Agents[0];
|
||||
PatternAgentDefinitionDto triageDefinition = pattern.Agents[0];
|
||||
IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists =
|
||||
Agents.Skip(1)
|
||||
.Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition))
|
||||
.ToList();
|
||||
|
||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
|
||||
foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists)
|
||||
{
|
||||
builder = builder.WithHandoff(
|
||||
firstAgent,
|
||||
specialist,
|
||||
HandoffWorkflowGuidance.CreateForwardReason(definition));
|
||||
}
|
||||
|
||||
foreach ((AIAgent specialist, _) in specialists)
|
||||
{
|
||||
builder = builder.WithHandoff(
|
||||
specialist,
|
||||
firstAgent,
|
||||
HandoffWorkflowGuidance.CreateReturnReason(triageDefinition));
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
|
||||
|
||||
return AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents =>
|
||||
new RoundRobinGroupChatManager(agents)
|
||||
{
|
||||
MaximumIterationCount = maximumIterations,
|
||||
})
|
||||
.AddParticipants(Agents.ToArray())
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Task<PermissionRequestResult> ApprovePermissionAsync(
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation)
|
||||
{
|
||||
return Task.FromResult(new PermissionRequestResult
|
||||
{
|
||||
Kind = PermissionRequestResultKind.Approved,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.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.
|
||||
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,12 @@
|
||||
using Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
public interface ITurnWorkflowRunner
|
||||
{
|
||||
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
public sealed class PatternValidator
|
||||
{
|
||||
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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Eryx.AgentHost.Contracts;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
public sealed class SidecarProtocolHost
|
||||
{
|
||||
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 SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, Task> _inFlight = 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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
Task task = HandleCommandAsync(line, envelope, output, cancellationToken);
|
||||
_inFlight[envelope.RequestId] = task;
|
||||
_ = task.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
_inFlight.TryRemove(envelope.RequestId, out Task? removedTask);
|
||||
return removedTask is not null;
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
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 async Task HandleCommandAsync(
|
||||
string rawCommand,
|
||||
SidecarCommandEnvelope envelope,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (envelope.Type)
|
||||
{
|
||||
case "describe-capabilities":
|
||||
await WriteAsync(output, new CapabilitiesEventDto
|
||||
{
|
||||
Type = "capabilities",
|
||||
RequestId = envelope.RequestId,
|
||||
Capabilities = await _capabilitiesProvider(cancellationToken).ConfigureAwait(false),
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case "validate-pattern":
|
||||
ValidatePatternCommandDto validateCommand =
|
||||
JsonSerializer.Deserialize<ValidatePatternCommandDto>(rawCommand, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Could not deserialize validate-pattern command.");
|
||||
|
||||
await WriteAsync(output, new PatternValidationEventDto
|
||||
{
|
||||
Type = "pattern-validation",
|
||||
RequestId = envelope.RequestId,
|
||||
Issues = _patternValidator.Validate(validateCommand.Pattern),
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case "run-turn":
|
||||
RunTurnCommandDto runTurnCommand =
|
||||
JsonSerializer.Deserialize<RunTurnCommandDto>(rawCommand, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Could not deserialize run-turn command.");
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
|
||||
runTurnCommand,
|
||||
delta => WriteAsync(output, delta, cancellationToken),
|
||||
activity => WriteAsync(output, activity, cancellationToken),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(output, new TurnCompleteEventDto
|
||||
{
|
||||
Type = "turn-complete",
|
||||
RequestId = envelope.RequestId,
|
||||
SessionId = runTurnCommand.SessionId,
|
||||
Messages = messages,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown sidecar command type '{envelope.Type}'.");
|
||||
}
|
||||
|
||||
await WriteAsync(output, new CommandCompleteEventDto
|
||||
{
|
||||
Type = "command-complete",
|
||||
RequestId = envelope.RequestId,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteAsync(output, new CommandErrorEventDto
|
||||
{
|
||||
Type = "command-error",
|
||||
RequestId = envelope.RequestId,
|
||||
Message = ex.Message,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
CopilotCliContext cliContext;
|
||||
SidecarConnectionDiagnosticsDto connection;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
|
||||
try
|
||||
{
|
||||
cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[eryx sidecar] {connection.Summary} {exception.Message}");
|
||||
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account);
|
||||
Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
}
|
||||
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
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 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 Eryx can load models."
|
||||
: "GitHub Copilot was found, but Eryx 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Eryx.AgentHost.Services;
|
||||
|
||||
internal static partial class StreamingTextMerger
|
||||
{
|
||||
public static string Merge(string current, string incoming)
|
||||
{
|
||||
if (string.IsNullOrEmpty(current))
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(incoming))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
int overlap = ComputeSuffixPrefixOverlap(current, incoming);
|
||||
if (overlap > 0)
|
||||
{
|
||||
return current + incoming[overlap..];
|
||||
}
|
||||
|
||||
if (ShouldReplaceWithSnapshot(current, incoming))
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return current + incoming;
|
||||
}
|
||||
|
||||
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 (incoming.Length < Math.Floor(current.Length * 0.6))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<string> currentTokens = Tokenize(current);
|
||||
HashSet<string> incomingTokens = Tokenize(incoming);
|
||||
if (currentTokens.Count < 3 || incomingTokens.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int shared = incomingTokens.Count(token => currentTokens.Contains(token));
|
||||
return shared / (double)Math.Min(currentTokens.Count, incomingTokens.Count) >= 0.5;
|
||||
}
|
||||
|
||||
private static HashSet<string> Tokenize(string value)
|
||||
{
|
||||
return TokenRegex()
|
||||
.Matches(value.ToLowerInvariant())
|
||||
.Select(match => match.Value)
|
||||
.Where(token => token.Length > 0)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TokenRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user