refactor: enrich provider turn stream model

Add turn-stream capability metadata and richer normalized provider events for reasoning blocks, tool lifecycle, and turn boundaries. Track those signals in turn execution state and cover the new Copilot adapter/state behavior with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-13 15:23:47 +02:00
co-authored by Copilot
parent fde82bef4d
commit 7b9c4d140c
9 changed files with 615 additions and 2 deletions
@@ -2,10 +2,13 @@ namespace Aryx.AgentHost.Contracts;
internal abstract record ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(string MessageId) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(
string MessageId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageEvent(
string MessageId,
string? Content,
bool HasToolRequests) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionStartEvent(
@@ -13,12 +16,35 @@ internal sealed record ProviderToolExecutionStartEvent(
string ToolName,
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionProgressEvent(
string ToolCallId,
string? ProgressMessage) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionPartialResultEvent(
string ToolCallId,
string? PartialOutput) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionCompleteEvent(
string ToolCallId,
bool Success,
string? ResultContent,
string? DetailedResultContent,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningDeltaEvent(
string? ReasoningId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningEvent(
string? ReasoningId,
string? Content) : ProviderSessionEvent;
internal sealed record ProviderAssistantTurnStartEvent(string? TurnId) : ProviderSessionEvent;
internal sealed record ProviderAssistantTurnEndEvent(string? TurnId) : ProviderSessionEvent;
internal sealed record ProviderSubagentStartedEvent(
string? ToolCallId,
string? AgentName,
@@ -0,0 +1,28 @@
namespace Aryx.AgentHost.Contracts;
internal sealed record ProviderTurnStreamCapabilities
{
public static ProviderTurnStreamCapabilities None { get; } = new();
public bool SupportsIntent { get; init; }
public bool SupportsReasoningDelta { get; init; }
public bool SupportsReasoningBlock { get; init; }
public bool SupportsToolExecutionProgress { get; init; }
public bool SupportsToolExecutionPartialResult { get; init; }
public bool SupportsToolExecutionCompletion { get; init; }
public bool SupportsSubagentLifecycle { get; init; }
public bool SupportsHookLifecycle { get; init; }
public bool SupportsSessionCompaction { get; init; }
public bool SupportsPendingMessagesMutation { get; init; }
public bool SupportsSessionTurnBoundaries { get; init; }
}
@@ -0,0 +1,38 @@
namespace Aryx.AgentHost.Contracts;
internal enum ProviderToolExecutionStatus
{
Running,
Completed,
Failed,
}
internal sealed record ProviderToolExecutionSnapshot
{
public string ToolCallId { get; init; } = string.Empty;
public string? ToolName { get; init; }
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
public ProviderToolExecutionStatus Status { get; init; }
public string? LatestProgressMessage { get; init; }
public string PartialOutput { get; init; } = string.Empty;
public string? ResultContent { get; init; }
public string? DetailedResultContent { get; init; }
public string? Error { get; init; }
}
internal sealed record ProviderReasoningSnapshot
{
public string ReasoningId { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public bool IsComplete { get; init; }
}
@@ -4,5 +4,7 @@ namespace Aryx.AgentHost.Services;
internal interface IProviderEventAdapter
{
ProviderTurnStreamCapabilities Capabilities { get; }
ProviderSessionEvent? TryAdapt(object rawEvent);
}
@@ -5,18 +5,34 @@ namespace Aryx.AgentHost.Services;
internal sealed class CopilotEventAdapter : IProviderEventAdapter
{
public ProviderTurnStreamCapabilities Capabilities { get; } = new()
{
SupportsIntent = true,
SupportsReasoningDelta = true,
SupportsReasoningBlock = true,
SupportsToolExecutionProgress = true,
SupportsToolExecutionPartialResult = true,
SupportsToolExecutionCompletion = true,
SupportsSubagentLifecycle = true,
SupportsHookLifecycle = true,
SupportsSessionCompaction = true,
SupportsPendingMessagesMutation = true,
SupportsSessionTurnBoundaries = true,
};
public ProviderSessionEvent? TryAdapt(object rawEvent)
{
return rawEvent switch
{
AssistantMessageDeltaEvent messageDelta
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageDeltaEvent(messageId),
new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent),
AssistantMessageEvent assistantMessage
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageEvent(
messageId,
assistantMessage.Data?.Content,
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
ToolExecutionStartEvent toolExecutionStart
@@ -27,6 +43,27 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
toolName,
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)),
ToolExecutionProgressEvent toolExecutionProgress
when NormalizeRequiredString(toolExecutionProgress.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionProgressEvent(
toolCallId,
NormalizeOptionalString(toolExecutionProgress.Data?.ProgressMessage)),
ToolExecutionPartialResultEvent toolExecutionPartialResult
when NormalizeRequiredString(toolExecutionPartialResult.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionPartialResultEvent(
toolCallId,
toolExecutionPartialResult.Data?.PartialOutput),
ToolExecutionCompleteEvent toolExecutionComplete
when NormalizeRequiredString(toolExecutionComplete.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionCompleteEvent(
toolCallId,
toolExecutionComplete.Data?.Success ?? false,
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.Content),
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.DetailedContent),
NormalizeOptionalString(toolExecutionComplete.Data?.Error?.Message)),
AssistantIntentEvent intentEvent =>
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
@@ -35,6 +72,17 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
reasoningDelta.Data?.DeltaContent),
AssistantReasoningEvent reasoning =>
new ProviderAssistantReasoningEvent(
NormalizeOptionalString(reasoning.Data?.ReasoningId),
reasoning.Data?.Content),
AssistantTurnStartEvent turnStart =>
new ProviderAssistantTurnStartEvent(NormalizeOptionalString(turnStart.Data?.TurnId)),
AssistantTurnEndEvent turnEnd =>
new ProviderAssistantTurnEndEvent(NormalizeOptionalString(turnEnd.Data?.TurnId)),
SubagentStartedEvent started =>
new ProviderSubagentStartedEvent(
started.Data?.ToolCallId,
@@ -20,6 +20,8 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
CancellationTokenSource runCancellation,
CancellationToken cancellationToken)
{
state.SetStreamCapabilities(_providerEventAdapter.Capabilities);
return await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
@@ -14,6 +15,9 @@ internal class TurnExecutionState
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
private string? _lastObservedMessageId;
@@ -35,8 +39,19 @@ internal class TurnExecutionState
public bool HasPendingExitPlanModeRequest { get; private set; }
public ProviderTurnStreamCapabilities StreamCapabilities { get; private set; } = ProviderTurnStreamCapabilities.None;
public string? CurrentProviderTurnId { get; private set; }
public string? LatestCompletedProviderTurnId { get; private set; }
public bool SuppressHookLifecycleEvents { get; set; }
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
{
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
}
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{
return AgentIdentityResolver.ResolveAgentIdentity(
@@ -162,9 +177,22 @@ internal class TurnExecutionState
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break;
case ProviderToolExecutionProgressEvent toolExecutionProgress:
ActiveAgent = agent;
TrackToolExecutionProgress(toolExecutionProgress.ToolCallId, toolExecutionProgress.ProgressMessage);
break;
case ProviderToolExecutionPartialResultEvent toolExecutionPartialResult:
ActiveAgent = agent;
TrackToolExecutionPartialResult(toolExecutionPartialResult.ToolCallId, toolExecutionPartialResult.PartialOutput);
break;
case ProviderToolExecutionCompleteEvent toolExecutionComplete:
ActiveAgent = agent;
TrackToolExecutionComplete(toolExecutionComplete);
break;
case ProviderAssistantIntentEvent intentEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
if (assistantIntent is not null)
{
@@ -174,6 +202,7 @@ internal class TurnExecutionState
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
agent,
reasoningDelta.ReasoningId,
@@ -183,6 +212,22 @@ internal class TurnExecutionState
_pendingEvents.Enqueue(reasoningDeltaEvent);
}
break;
case ProviderAssistantReasoningEvent reasoning:
ActiveAgent = agent;
TrackReasoningContent(reasoning.ReasoningId, reasoning.Content, isComplete: true);
break;
case ProviderAssistantTurnStartEvent turnStart:
ActiveAgent = agent;
CurrentProviderTurnId = turnStart.TurnId;
break;
case ProviderAssistantTurnEndEvent turnEnd:
ActiveAgent = agent;
LatestCompletedProviderTurnId = turnEnd.TurnId;
if (string.Equals(CurrentProviderTurnId, turnEnd.TurnId, StringComparison.Ordinal))
{
CurrentProviderTurnId = null;
}
break;
case ProviderSubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
@@ -291,6 +336,27 @@ internal class TurnExecutionState
return pending;
}
public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
{
snapshot = null;
return !string.IsNullOrWhiteSpace(toolCallId)
&& _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot);
}
public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot)
{
snapshot = null;
return !string.IsNullOrWhiteSpace(reasoningId)
&& _reasoningById.TryGetValue(reasoningId, out snapshot);
}
public bool TryGetLatestIntent(string? agentId, [NotNullWhen(true)] out string? intent)
{
intent = null;
return !string.IsNullOrWhiteSpace(agentId)
&& _latestIntentByAgentId.TryGetValue(agentId, out intent);
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
@@ -334,6 +400,129 @@ internal class TurnExecutionState
{
ToolNamesByCallId[toolCallId] = toolName;
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 };
TrackToolExecutionStart(toolCallId, toolName, toolArguments);
}
private void TrackToolExecutionStart(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
static (id, state) => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
static (_, existing, state) => existing with
{
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
(ToolName: toolName, ToolArguments: toolArguments));
}
private void TrackToolExecutionProgress(string toolCallId, string? progressMessage)
{
string? normalizedProgress = NormalizeOptionalString(progressMessage);
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage,
});
}
private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput)
{
if (string.IsNullOrEmpty(partialOutput))
{
return;
}
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
PartialOutput = partialOutput,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
PartialOutput = string.Concat(existing.PartialOutput, partialOutput),
});
}
private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution)
{
_toolExecutionsByCallId.AddOrUpdate(
toolExecution.ToolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent,
Error = toolExecution.Error,
},
(_, existing) => existing with
{
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent ?? existing.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent,
Error = toolExecution.Error ?? existing.Error,
});
}
private void TrackLatestIntent(string agentId, string? intent)
{
string? normalizedIntent = NormalizeOptionalString(intent);
if (normalizedIntent is null)
{
return;
}
_latestIntentByAgentId[agentId] = normalizedIntent;
}
private void TrackReasoningContent(string? reasoningId, string? content, bool isComplete)
{
string? normalizedReasoningId = NormalizeOptionalString(reasoningId);
if (normalizedReasoningId is null || content is null)
{
return;
}
_reasoningById.AddOrUpdate(
normalizedReasoningId,
id => new ProviderReasoningSnapshot
{
ReasoningId = id,
Content = content,
IsComplete = isComplete,
},
(_, existing) => existing with
{
Content = isComplete ? content : string.Concat(existing.Content, content),
IsComplete = isComplete || existing.IsComplete,
});
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
@@ -759,4 +948,9 @@ internal class TurnExecutionState
AgentName = agent.AgentName,
};
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}