feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence

Backend (sidecar):
- Extended ProtocolModels with DTOs for custom agents, hooks, skills,
  infinite sessions, session lifecycle, and 9 new event types
- Added CopilotManagedSessionIds for stable SDK session ID mapping
- Added CopilotSessionManager/ICopilotSessionManager for session lifecycle
- Added CopilotSessionHooks for hook registration
- Added CopilotMessageOptionsMetadata for mid-turn steering
- Extended CopilotAgentBundle to wire custom agents, hooks, skills,
  infinite sessions, and stable session IDs
- Extended CopilotTurnExecutionState to project 13 new SDK event types
- Widened ITurnWorkflowRunner callback to accept SidecarEventDto
- Added list/delete/disconnect session commands to SidecarProtocolHost
- Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total)

Frontend (renderer + main + shared):
- Added ChatMessageAttachment type and helpers (attachment.ts)
- Extended sidecar contracts with MessageMode, 3 new command types,
  9 new event types, and agent/session config DTOs
- Extended SessionEventRecord with 6 new event kinds and ~20 fields
- Added PatternAgentCopilotConfig to pattern domain
- Added attachments support to ChatMessageRecord
- Updated sidecar client with session lifecycle methods and
  turn-scoped event routing via onTurnScopedEvent callback
- Updated main process: handleTurnScopedEvent(), deleteSession(),
  steering bypass for mid-turn messages, attachment passthrough
- Added deleteSession IPC handler and preload binding
- Added TurnEventLog state tracker with format/apply/prune helpers
- ChatPane: always-enabled composer, steering indicator, attachment
  picker with preview, image thumbnails in message history,
  context-usage bar, amber steer mode for send button
- ActivityPanel: turn events section with sub-agent, hook, skill,
  and compaction event rendering
- Sidebar: delete session action in context menu
- App.tsx: wired sessionUsage, turnEventLogs, and deleteSession

Documentation:
- AGENTS.md: added glob safety rule for node_modules
- README.md: added steering, image input, and richer observability
- ARCHITECTURE.md: added turn-scoped events, steering, and attachments
- Website: added steering and image input feature cards, updated
  live visibility and session cards

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 12:28:20 +01:00
co-authored by Copilot
parent 0c2973c599
commit f1fa52f9c3
40 changed files with 2515 additions and 92 deletions
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -140,11 +141,16 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
try
{
string prompt = string.Join("\n", messages.Select(message => message.Text));
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, tempDir) = await ProcessMessageAttachmentsAsync(
messages,
cancellationToken).ConfigureAwait(false);
MessageOptions messageOptions = new() { Prompt = prompt };
MessageOptions messageOptions = new()
{
Prompt = prompt,
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
};
if (attachments is not null)
{
messageOptions.Attachments = [.. attachments];
@@ -474,36 +480,103 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
}
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
internal static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<UserMessageDataAttachmentsItem>? attachments = null;
string? messageMode = null;
string? tempDir = null;
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is not DataContent dataContent)
if (content is DataContent dataContent)
{
tempDir ??= Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath),
});
continue;
}
tempDir ??= Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath),
});
attachments ??= [];
attachments.Add(CreateProtocolAttachment(protocolAttachment));
continue;
}
if (content.RawRepresentation is CopilotMessageOptionsMetadata metadata
&& !string.IsNullOrWhiteSpace(metadata.MessageMode))
{
messageMode = metadata.MessageMode.Trim();
}
}
}
return (attachments, tempDir);
return (attachments, messageMode, tempDir);
}
private static UserMessageDataAttachmentsItem CreateProtocolAttachment(ChatMessageAttachmentDto attachment)
{
ArgumentNullException.ThrowIfNull(attachment);
return attachment.Type switch
{
"file" => CreateFileAttachment(attachment),
"blob" => CreateBlobAttachment(attachment),
_ => throw new NotSupportedException($"Unsupported attachment type '{attachment.Type}'."),
};
}
private static UserMessageDataAttachmentsItemFile CreateFileAttachment(ChatMessageAttachmentDto attachment)
{
if (string.IsNullOrWhiteSpace(attachment.Path))
{
throw new InvalidOperationException("File attachments require an absolute path.");
}
string path = attachment.Path.Trim();
if (!Path.IsPathRooted(path))
{
throw new InvalidOperationException($"File attachment path '{path}' must be absolute.");
}
return new UserMessageDataAttachmentsItemFile
{
Path = path,
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName)
? Path.GetFileName(path)
: attachment.DisplayName.Trim(),
};
}
private static UserMessageDataAttachmentsItemBlob CreateBlobAttachment(ChatMessageAttachmentDto attachment)
{
if (string.IsNullOrWhiteSpace(attachment.Data))
{
throw new InvalidOperationException("Blob attachments require base64-encoded data.");
}
if (string.IsNullOrWhiteSpace(attachment.MimeType))
{
throw new InvalidOperationException("Blob attachments require a MIME type.");
}
return new UserMessageDataAttachmentsItemBlob
{
Data = attachment.Data.Trim(),
MimeType = attachment.MimeType.Trim(),
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName) ? null : attachment.DisplayName.Trim(),
};
}
private static void CleanupTempDir(string? tempDir)
@@ -47,6 +47,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
SessionConfig sessionConfig = new()
{
SessionId = CopilotManagedSessionIds.Build(command.SessionId, definition.Id),
Model = definition.Model,
ReasoningEffort = definition.ReasoningEffort,
SystemMessage = new SystemMessageConfig
@@ -61,8 +62,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
WorkingDirectory = command.ProjectPath,
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation),
Hooks = CopilotSessionHooks.Create(command, definition),
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
Streaming = true,
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
};
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
@@ -100,6 +107,55 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
}
}
internal static List<CustomAgentConfig>? CreateCustomAgents(
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
{
if (customAgents is not { Count: > 0 })
{
return null;
}
return customAgents.Select(customAgent => new CustomAgentConfig
{
Name = customAgent.Name,
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
Description = NormalizeOptionalString(customAgent.Description),
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
Prompt = customAgent.Prompt,
McpServers = customAgent.McpServers.Count == 0
? null
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
Infer = customAgent.Infer,
}).ToList();
}
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
{
if (config is null)
{
return null;
}
return new InfiniteSessionConfig
{
Enabled = config.Enabled,
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
};
}
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
{
return values is { Count: > 0 }
? [.. values]
: null;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
{
return pattern.Mode switch
@@ -0,0 +1,50 @@
namespace Aryx.AgentHost.Services;
internal static class CopilotManagedSessionIds
{
private const string Prefix = "aryx::";
private const string Separator = "::";
public static string Build(string aryxSessionId, string agentId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(aryxSessionId);
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
return $"{Prefix}{Uri.EscapeDataString(aryxSessionId)}{Separator}{Uri.EscapeDataString(agentId)}";
}
public static bool IsManagedByAryx(string copilotSessionId)
=> TryParse(copilotSessionId, out _, out _);
public static bool IsManagedByAryx(string copilotSessionId, string aryxSessionId)
{
return TryParse(copilotSessionId, out string? parsedSessionId, out _)
&& string.Equals(parsedSessionId, aryxSessionId, StringComparison.Ordinal);
}
public static bool TryParse(string? copilotSessionId, out string aryxSessionId, out string agentId)
{
aryxSessionId = string.Empty;
agentId = string.Empty;
if (string.IsNullOrWhiteSpace(copilotSessionId)
|| !copilotSessionId.StartsWith(Prefix, StringComparison.Ordinal))
{
return false;
}
string payload = copilotSessionId[Prefix.Length..];
string[] parts = payload.Split(Separator, StringSplitOptions.None);
if (parts.Length != 2
|| string.IsNullOrWhiteSpace(parts[0])
|| string.IsNullOrWhiteSpace(parts[1]))
{
return false;
}
aryxSessionId = Uri.UnescapeDataString(parts[0]);
agentId = Uri.UnescapeDataString(parts[1]);
return true;
}
}
@@ -0,0 +1,3 @@
namespace Aryx.AgentHost.Services;
internal sealed record CopilotMessageOptionsMetadata(string MessageMode);
@@ -0,0 +1,47 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal static class CopilotSessionHooks
{
private const string AllowDecision = "allow";
private const string AskDecision = "ask";
public static SessionHooks Create(RunTurnCommandDto command, PatternAgentDefinitionDto agentDefinition)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agentDefinition);
return new SessionHooks
{
OnPreToolUse = (input, _) => Task.FromResult<PreToolUseHookOutput?>(
CreatePreToolUseOutput(command, agentDefinition, input)),
OnPostToolUse = static (_, _) => Task.FromResult<PostToolUseHookOutput?>(null),
OnUserPromptSubmitted = static (_, _) => Task.FromResult<UserPromptSubmittedHookOutput?>(null),
OnSessionStart = static (_, _) => Task.FromResult<SessionStartHookOutput?>(null),
OnSessionEnd = static (_, _) => Task.FromResult<SessionEndHookOutput?>(null),
OnErrorOccurred = static (_, _) => Task.FromResult<ErrorOccurredHookOutput?>(null),
};
}
private static PreToolUseHookOutput CreatePreToolUseOutput(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
PreToolUseHookInput input)
{
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Pattern.ApprovalPolicy,
agentDefinition.Id,
Normalize(input.ToolName),
Normalize(input.ToolName));
return new PreToolUseHookOutput
{
PermissionDecision = requiresApproval ? AskDecision : AllowDecision,
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,125 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotSessionManager : ICopilotSessionManager
{
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
CancellationToken cancellationToken)
{
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
List<SessionMetadata> sessions = await client.ListSessionsAsync(CreateFilter(filter), cancellationToken)
.ConfigureAwait(false);
return sessions
.Select(MapSession)
.OrderByDescending(session => session.ModifiedTime, StringComparer.Ordinal)
.ToList();
}
public async Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
string? aryxSessionId,
string? copilotSessionId,
CancellationToken cancellationToken)
{
string? normalizedAryxSessionId = Normalize(aryxSessionId);
string? normalizedCopilotSessionId = Normalize(copilotSessionId);
if (normalizedAryxSessionId is null && normalizedCopilotSessionId is null)
{
throw new InvalidOperationException("delete-session requires a sessionId or copilotSessionId.");
}
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
List<SessionMetadata> sessions = await client.ListSessionsAsync(null, cancellationToken).ConfigureAwait(false);
List<CopilotSessionInfoDto> targets = sessions
.Select(MapSession)
.Where(session =>
(normalizedCopilotSessionId is not null
&& string.Equals(session.CopilotSessionId, normalizedCopilotSessionId, StringComparison.Ordinal))
|| (normalizedAryxSessionId is not null
&& string.Equals(session.SessionId, normalizedAryxSessionId, StringComparison.Ordinal)
&& session.ManagedByAryx))
.ToList();
if (targets.Count == 0 && normalizedCopilotSessionId is not null)
{
targets.Add(CreateUnknownSessionInfo(normalizedCopilotSessionId));
}
foreach (CopilotSessionInfoDto target in targets)
{
await client.DeleteSessionAsync(target.CopilotSessionId, cancellationToken).ConfigureAwait(false);
}
return targets;
}
private static async Task<CopilotClient> CreateStartedClientAsync(CancellationToken cancellationToken)
{
CopilotClient client = new(CopilotCliPathResolver.CreateClientOptions());
await client.StartAsync(cancellationToken).ConfigureAwait(false);
return client;
}
private static SessionListFilter? CreateFilter(CopilotSessionListFilterDto? filter)
{
if (filter is null)
{
return null;
}
return new SessionListFilter
{
Cwd = Normalize(filter.Cwd),
GitRoot = Normalize(filter.GitRoot),
Repository = Normalize(filter.Repository),
Branch = Normalize(filter.Branch),
};
}
private static CopilotSessionInfoDto MapSession(SessionMetadata session)
{
bool managedByAryx = CopilotManagedSessionIds.TryParse(
session.SessionId,
out string aryxSessionId,
out string agentId);
return new CopilotSessionInfoDto
{
CopilotSessionId = session.SessionId,
ManagedByAryx = managedByAryx,
SessionId = managedByAryx ? aryxSessionId : null,
AgentId = managedByAryx ? agentId : null,
StartTime = session.StartTime.ToUniversalTime().ToString("O"),
ModifiedTime = session.ModifiedTime.ToUniversalTime().ToString("O"),
Summary = Normalize(session.Summary),
IsRemote = session.IsRemote,
Cwd = Normalize(session.Context?.Cwd),
GitRoot = Normalize(session.Context?.GitRoot),
Repository = Normalize(session.Context?.Repository),
Branch = Normalize(session.Context?.Branch),
};
}
private static CopilotSessionInfoDto CreateUnknownSessionInfo(string copilotSessionId)
{
bool managedByAryx = CopilotManagedSessionIds.TryParse(
copilotSessionId,
out string aryxSessionId,
out string agentId);
return new CopilotSessionInfoDto
{
CopilotSessionId = copilotSessionId,
ManagedByAryx = managedByAryx,
SessionId = managedByAryx ? aryxSessionId : null,
AgentId = managedByAryx ? agentId : null,
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -9,7 +9,7 @@ internal sealed class CopilotTurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<AgentActivityEventDto> _pendingActivityEvents = new();
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
@@ -30,7 +30,7 @@ internal sealed class CopilotTurnExecutionState
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null)
@@ -38,7 +38,7 @@ internal sealed class CopilotTurnExecutionState
return;
}
await onActivity(thinkingActivity).ConfigureAwait(false);
await onEvent(thinkingActivity).ConfigureAwait(false);
}
public void QueueThinkingIfNeeded(AgentIdentity agent)
@@ -46,13 +46,14 @@ internal sealed class CopilotTurnExecutionState
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null)
{
_pendingActivityEvents.Enqueue(thinkingActivity);
_pendingEvents.Enqueue(thinkingActivity);
}
}
public void ApplyActivity(AgentActivityEventDto activity)
public void ApplyEvent(SidecarEventDto evt)
{
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
if (evt is AgentActivityEventDto activity
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
@@ -86,6 +87,54 @@ internal sealed class CopilotTurnExecutionState
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
break;
case SubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentEvent(agent, "started", started.Data));
break;
case SubagentCompletedEvent completed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed.Data));
break;
case SubagentFailedEvent failed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed.Data));
break;
case SubagentSelectedEvent selected:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected.Data));
break;
case SubagentDeselectedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
break;
case SkillInvokedEvent skillInvoked:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked.Data));
break;
case HookStartEvent hookStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
break;
case HookEndEvent hookEnd:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
break;
case SessionUsageInfoEvent usageInfo:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
break;
case SessionCompactionStartEvent compactionStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart.Data));
break;
case SessionCompactionCompleteEvent compactionComplete:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete.Data));
break;
case PendingMessagesModifiedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
break;
case McpOauthRequiredEvent:
ActiveAgent = agent;
break;
@@ -96,12 +145,12 @@ internal sealed class CopilotTurnExecutionState
}
}
public IReadOnlyList<AgentActivityEventDto> DrainPendingActivityEvents()
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
{
List<AgentActivityEventDto> pending = [];
while (_pendingActivityEvents.TryDequeue(out AgentActivityEventDto? activity))
List<SidecarEventDto> pending = [];
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
{
pending.Add(activity);
pending.Add(pendingEvent);
}
return pending;
@@ -204,4 +253,229 @@ internal sealed class CopilotTurnExecutionState
return CompletedMessages;
}
private SubagentEventDto CreateSubagentEvent(
AgentIdentity agent,
string eventKind,
SubagentStartedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = eventKind,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
CustomAgentDescription = data?.AgentDescription,
};
}
private SubagentEventDto CreateSubagentCompletedEvent(
AgentIdentity agent,
SubagentCompletedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
};
}
private SubagentEventDto CreateSubagentFailedEvent(
AgentIdentity agent,
SubagentFailedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "failed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
Error = data?.Error,
};
}
private SubagentEventDto CreateSubagentSelectedEvent(
AgentIdentity agent,
SubagentSelectedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "selected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
Tools = data?.Tools,
};
}
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "deselected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent,
SkillInvokedData? data)
{
return new SkillInvokedEventDto
{
Type = "skill-invoked",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SkillName = data?.Name ?? string.Empty,
Path = data?.Path ?? string.Empty,
Content = data?.Content ?? string.Empty,
AllowedTools = data?.AllowedTools,
PluginName = data?.PluginName,
PluginVersion = data?.PluginVersion,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
HookStartData? data)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = data?.HookInvocationId ?? string.Empty,
HookType = data?.HookType ?? string.Empty,
Phase = phase,
Input = data?.Input,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
HookEndData? data)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = data?.HookInvocationId ?? string.Empty,
HookType = data?.HookType ?? string.Empty,
Phase = phase,
Success = data?.Success,
Output = data?.Output,
Error = data?.Error?.Message,
};
}
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
{
return new SessionUsageEventDto
{
Type = "session-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
TokenLimit = data?.TokenLimit ?? 0,
CurrentTokens = data?.CurrentTokens ?? 0,
MessagesLength = data?.MessagesLength ?? 0,
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
IsInitial = data?.IsInitial,
};
}
private SessionCompactionEventDto CreateCompactionStartEvent(
AgentIdentity agent,
SessionCompactionStartData? data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "start",
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
};
}
private SessionCompactionEventDto CreateCompactionCompleteEvent(
AgentIdentity agent,
SessionCompactionCompleteData? data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "complete",
Success = data?.Success,
Error = data?.Error,
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
PreCompactionTokens = data?.PreCompactionTokens,
PostCompactionTokens = data?.PostCompactionTokens,
PreCompactionMessagesLength = data?.PreCompactionMessagesLength,
MessagesRemoved = data?.MessagesRemoved,
TokensRemoved = data?.TokensRemoved,
SummaryContent = data?.SummaryContent,
CheckpointNumber = data?.CheckpointNumber,
CheckpointPath = data?.CheckpointPath,
};
}
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
{
return new PendingMessagesModifiedEventDto
{
Type = "pending-messages-modified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
}
@@ -23,7 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -77,15 +77,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
runCancellation.Token);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
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(runCancellation.Token).ConfigureAwait(false))
{
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
.ConfigureAwait(false);
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn)
{
@@ -93,13 +94,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
@@ -117,13 +118,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
private static async Task EmitPendingActivityEventsAsync(
private static async Task EmitPendingEventsAsync(
CopilotTurnExecutionState state,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
foreach (AgentActivityEventDto activity in state.DrainPendingActivityEvents())
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
{
await onActivity(activity).ConfigureAwait(false);
await onEvent(pendingEvent).ConfigureAwait(false);
}
}
@@ -157,7 +158,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
if (evt is ExecutorInvokedEvent invoked)
{
@@ -167,7 +168,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity invokedAgent))
{
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
}
else
{
@@ -194,13 +195,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return requiresBoundary;
}
await EmitActivityAsync(command, state, activity, onActivity).ConfigureAwait(false);
await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
return false;
}
if (evt is AgentResponseUpdateEvent update)
{
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
return false;
}
@@ -237,7 +238,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
AgentResponseUpdateEvent update,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
AgentIdentity? updateAgent = null;
string authorName = update.ExecutorId;
@@ -271,7 +272,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
}
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
}
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
{
@@ -307,13 +308,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
RunTurnCommandDto command,
CopilotTurnExecutionState state,
AgentActivityEventDto activity,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
state.ApplyActivity(activity);
state.ApplyEvent(activity);
TraceHandoff(
command,
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
await onActivity(activity).ConfigureAwait(false);
await onEvent(activity).ConfigureAwait(false);
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
@@ -324,7 +325,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName),
onActivity).ConfigureAwait(false);
onEvent).ConfigureAwait(false);
}
}
@@ -0,0 +1,16 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public interface ICopilotSessionManager
{
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
CancellationToken cancellationToken);
Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
string? aryxSessionId,
string? copilotSessionId,
CancellationToken cancellationToken);
}
@@ -7,7 +7,7 @@ public interface ITurnWorkflowRunner
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -15,6 +15,9 @@ public sealed class SidecarProtocolHost
private const string CancelTurnCommandType = "cancel-turn";
private const string ResolveApprovalCommandType = "resolve-approval";
private const string ResolveUserInputCommandType = "resolve-user-input";
private const string ListSessionsCommandType = "list-sessions";
private const string DeleteSessionCommandType = "delete-session";
private const string DisconnectSessionCommandType = "disconnect-session";
private const string AskUserToolName = "ask_user";
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
{
@@ -39,11 +42,14 @@ public sealed class SidecarProtocolHost
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly PatternValidator _patternValidator;
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _turnRequestIdsBySessionId =
new(StringComparer.Ordinal);
public SidecarProtocolHost()
: this(new PatternValidator())
@@ -53,11 +59,13 @@ public sealed class SidecarProtocolHost
public SidecarProtocolHost(
PatternValidator patternValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
{
_patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -71,6 +79,9 @@ public sealed class SidecarProtocolHost
[CancelTurnCommandType] = HandleCancelTurnAsync,
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
[ResolveUserInputCommandType] = HandleResolveUserInputAsync,
[ListSessionsCommandType] = HandleListSessionsAsync,
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
};
}
@@ -180,12 +191,13 @@ public sealed class SidecarProtocolHost
$"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
}
RegisterTurnRequest(command.SessionId, context.Envelope.RequestId);
try
{
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
command,
delta => WriteAsync(context.Output, delta, turnCancellation.Token),
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
evt => WriteAsync(context.Output, evt, turnCancellation.Token),
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
@@ -216,6 +228,7 @@ public sealed class SidecarProtocolHost
finally
{
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
UnregisterTurnRequest(command.SessionId, context.Envelope.RequestId);
}
}
@@ -249,6 +262,57 @@ public sealed class SidecarProtocolHost
await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleListSessionsAsync(CommandContext context)
{
ListSessionsCommandDto command = DeserializeCommand<ListSessionsCommandDto>(context);
IReadOnlyList<CopilotSessionInfoDto> sessions = await _sessionManager.ListSessionsAsync(
command.Filter,
context.CancellationToken).ConfigureAwait(false);
await WriteAsync(context.Output, new SessionsListedEventDto
{
Type = "sessions-listed",
RequestId = context.Envelope.RequestId,
Sessions = sessions,
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleDeleteSessionAsync(CommandContext context)
{
DeleteSessionCommandDto command = DeserializeCommand<DeleteSessionCommandDto>(context);
if (!string.IsNullOrWhiteSpace(command.SessionId))
{
CancelTurnRequestsForSession(command.SessionId);
}
IReadOnlyList<CopilotSessionInfoDto> deletedSessions = await _sessionManager.DeleteSessionsAsync(
command.SessionId,
command.CopilotSessionId,
context.CancellationToken).ConfigureAwait(false);
await WriteAsync(context.Output, new SessionsDeletedEventDto
{
Type = "sessions-deleted",
RequestId = context.Envelope.RequestId,
SessionId = string.IsNullOrWhiteSpace(command.SessionId) ? null : command.SessionId.Trim(),
Sessions = deletedSessions,
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleDisconnectSessionAsync(CommandContext context)
{
DisconnectSessionCommandDto command = DeserializeCommand<DisconnectSessionCommandDto>(context);
IReadOnlyList<string> cancelledRequestIds = CancelTurnRequestsForSession(command.SessionId);
await WriteAsync(context.Output, new SessionDisconnectedEventDto
{
Type = "session-disconnected",
RequestId = context.Envelope.RequestId,
SessionId = command.SessionId,
CancelledRequestIds = cancelledRequestIds,
}, context.CancellationToken).ConfigureAwait(false);
}
private TCommand DeserializeCommand<TCommand>(CommandContext context)
where TCommand : SidecarCommandEnvelope
{
@@ -309,6 +373,67 @@ public sealed class SidecarProtocolHost
}
}
private void RegisterTurnRequest(string sessionId, string requestId)
{
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
{
return;
}
ConcurrentDictionary<string, byte> requestIds = _turnRequestIdsBySessionId.GetOrAdd(
sessionId.Trim(),
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
requestIds[requestId.Trim()] = 0;
}
private void UnregisterTurnRequest(string sessionId, string requestId)
{
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
{
return;
}
if (!_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
{
return;
}
requestIds.TryRemove(requestId.Trim(), out _);
if (requestIds.IsEmpty)
{
_turnRequestIdsBySessionId.TryRemove(sessionId.Trim(), out _);
}
}
private IReadOnlyList<string> CancelTurnRequestsForSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId)
|| !_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
{
return [];
}
List<string> cancelledRequestIds = [];
foreach (string requestId in requestIds.Keys)
{
if (!_turnCancellations.TryGetValue(requestId, out CancellationTokenSource? turnCancellation))
{
continue;
}
try
{
turnCancellation.Cancel();
cancelledRequestIds.Add(requestId);
}
catch (ObjectDisposedException)
{
}
}
return cancelledRequestIds;
}
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
try
@@ -26,9 +26,30 @@ internal static class WorkflowTranscriptProjector
mapped.AuthorName = message.AuthorName;
}
foreach (ChatMessageAttachmentDto attachment in message.Attachments)
{
mapped.Contents.Add(new AIContent
{
RawRepresentation = attachment,
});
}
return mapped;
}
public static void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
{
if (messages.Count == 0 || string.IsNullOrWhiteSpace(messageMode))
{
return;
}
messages[^1].Contents.Add(new AIContent
{
RawRepresentation = new CopilotMessageOptionsMetadata(messageMode.Trim()),
});
}
public static List<ChatMessageDto> ProjectCompletedMessages(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,