feat: scaffold electron orchestrator foundation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot CLI
2026-03-21 09:27:28 +01:00
co-authored by Copilot
parent 1ed3d3f652
commit 9e509593d6
46 changed files with 3870 additions and 13 deletions
@@ -0,0 +1,113 @@
using System.Text.Json.Serialization;
namespace Kopaya.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 SidecarCapabilitiesDto
{
public string Runtime { get; init; } = "dotnet-maf";
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
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 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 CommandErrorEventDto : SidecarEventDto
{
public string Message { get; init; } = string.Empty;
}
public sealed class CommandCompleteEventDto : SidecarEventDto;
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<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 Kopaya.AgentHost.Services;
if (!args.Contains("--stdio", StringComparer.Ordinal))
{
Console.Error.WriteLine("Kopaya.AgentHost expects the --stdio flag.");
return;
}
SidecarProtocolHost host = new();
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
@@ -0,0 +1,269 @@
using System.Text;
using GitHub.Copilot.SDK;
using Kopaya.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 Kopaya.AgentHost.Services;
public sealed class CopilotWorkflowRunner
{
private readonly PatternValidator _patternValidator;
public CopilotWorkflowRunner(PatternValidator patternValidator)
{
_patternValidator = patternValidator;
}
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
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.Pattern, command.ProjectPath, cancellationToken);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(ToChatMessage).ToList();
List<StreamingSegment> segments = [];
int fallbackMessageIndex = 0;
List<ChatMessageDto> completedMessages = [];
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 AgentResponseUpdateEvent update && !string.IsNullOrEmpty(update.Update.Text))
{
string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}";
StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId);
segment.Content.Append(update.Update.Text);
await onDelta(new TurnDeltaEventDto
{
Type = "turn-delta",
RequestId = command.RequestId,
SessionId = command.SessionId,
MessageId = messageId,
AuthorName = update.ExecutorId,
ContentDelta = update.Update.Text,
}).ConfigureAwait(false);
}
else if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
List<ChatMessage> newMessages = allMessages.Skip(inputMessages.Count).ToList();
completedMessages = ConvertOutputMessages(command, newMessages, segments);
}
}
return completedMessages;
}
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;
}
private static List<ChatMessageDto> ConvertOutputMessages(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<StreamingSegment> segments)
{
List<ChatMessageDto> mapped = [];
int segmentIndex = 0;
foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User))
{
StreamingSegment? segment = segmentIndex < segments.Count ? segments[segmentIndex] : null;
segmentIndex++;
mapped.Add(new ChatMessageDto
{
Id = segment?.MessageId ?? $"{command.RequestId}-final-{segmentIndex}",
Role = message.Role == ChatRole.System ? "system" : "assistant",
AuthorName = message.AuthorName ?? segment?.AuthorName ?? "assistant",
Content = message.Text ?? segment?.Content.ToString() ?? string.Empty,
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 = segment.AuthorName,
Content = segment.Content.ToString(),
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
}));
}
return mapped;
}
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();
}
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(
PatternDefinitionDto pattern,
string projectPath,
CancellationToken cancellationToken)
{
List<IAsyncDisposable> disposables = [];
List<AIAgent> agents = [];
foreach (PatternAgentDefinitionDto definition in pattern.Agents)
{
CopilotClient client = new();
await client.StartAsync(cancellationToken).ConfigureAwait(false);
SessionConfig sessionConfig = new()
{
Model = definition.Model,
ReasoningEffort = definition.ReasoningEffort,
SystemMessage = new SystemMessageConfig
{
Content = definition.Instructions,
},
WorkingDirectory = projectPath,
OnPermissionRequest = ApprovePermissionAsync,
Streaming = true,
};
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];
IReadOnlyList<AIAgent> specialists = Agents.Skip(1).ToList();
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent)
.WithHandoffs(firstAgent, specialists)
.WithHandoffs(specialists, firstAgent);
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 = "approved",
});
}
}
}
@@ -0,0 +1,98 @@
using Kopaya.AgentHost.Contracts;
namespace Kopaya.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,171 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Kopaya.AgentHost.Contracts;
namespace Kopaya.AgentHost.Services;
public sealed class SidecarProtocolHost
{
private readonly PatternValidator _patternValidator;
private readonly CopilotWorkflowRunner _workflowRunner;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
public SidecarProtocolHost()
{
_patternValidator = new PatternValidator();
_workflowRunner = new CopilotWorkflowRunner(_patternValidator);
_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 = BuildCapabilities(),
}, 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),
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 SidecarCapabilitiesDto BuildCapabilities()
{
return new SidecarCapabilitiesDto
{
Modes = 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#.",
},
},
};
}
}