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:
David Kaya
2026-03-23 22:01:23 +01:00
co-authored by Copilot
parent fd120b6f60
commit 50a8e5dfbe
50 changed files with 157 additions and 157 deletions
@@ -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>
+10
View File
@@ -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();
}