feat: add agent activity events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 12:48:27 +01:00
co-authored by Copilot
parent b9e9662f6e
commit c5979d07bd
14 changed files with 754 additions and 33 deletions
@@ -105,6 +105,14 @@ public sealed class TurnCompleteEventDto : SidecarEventDto
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? AgentName { get; init; }
public string? ToolName { get; init; }
}
public sealed class CommandErrorEventDto : SidecarEventDto
{
public string Message { get; init; } = string.Empty;
@@ -9,8 +9,18 @@ using Microsoft.Extensions.AI;
namespace Kopaya.AgentHost.Services;
public sealed class CopilotWorkflowRunner
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)
@@ -21,6 +31,7 @@ public sealed class CopilotWorkflowRunner
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();
@@ -36,18 +47,50 @@ public sealed class CopilotWorkflowRunner
List<StreamingSegment> segments = [];
int fallbackMessageIndex = 0;
List<ChatMessageDto> completedMessages = [];
string? activeAgentId = null;
string? activeAgentName = null;
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))
if (evt is ExecutorInvokedEvent invoked
&& TryResolveKnownAgentName(command.Pattern, invoked.ExecutorId, out string invokedAgentName)
&& !string.Equals(activeAgentId, invoked.ExecutorId, StringComparison.Ordinal))
{
activeAgentId = invoked.ExecutorId;
activeAgentName = invokedAgentName;
await onActivity(CreateActivityEvent(
command,
activityType: "thinking",
agentName: invokedAgentName)).ConfigureAwait(false);
}
else if (evt is RequestInfoEvent requestInfo)
{
AgentActivityEventDto? activity = TryCreateActivityFromRequest(
command,
requestInfo,
activeAgentName);
if (activity is not null)
{
await onActivity(activity).ConfigureAwait(false);
}
}
else 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);
if (TryResolveKnownAgentName(command.Pattern, update.ExecutorId, out string updateAgentName))
{
activeAgentId = update.ExecutorId;
activeAgentName = updateAgentName;
}
await onDelta(new TurnDeltaEventDto
{
Type = "turn-delta",
@@ -58,6 +101,18 @@ public sealed class CopilotWorkflowRunner
ContentDelta = update.Update.Text,
}).ConfigureAwait(false);
}
else if (evt is ExecutorCompletedEvent completed
&& TryResolveKnownAgentName(command.Pattern, completed.ExecutorId, out string completedAgentName)
&& string.Equals(activeAgentId, completed.ExecutorId, StringComparison.Ordinal))
{
await onActivity(CreateActivityEvent(
command,
activityType: "completed",
agentName: completedAgentName)).ConfigureAwait(false);
activeAgentId = null;
activeAgentName = null;
}
else if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
@@ -69,6 +124,172 @@ public sealed class CopilotWorkflowRunner
return completedMessages;
}
private static AgentActivityEventDto CreateActivityEvent(
RunTurnCommandDto command,
string activityType,
string agentName,
string? toolName = null)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = activityType,
AgentName = agentName,
ToolName = toolName,
};
}
private static AgentActivityEventDto? TryCreateActivityFromRequest(
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
string? activeAgentName)
{
if (TryGetHandoffTargetName(command.Pattern, requestInfo, out string handoffAgentName))
{
return CreateActivityEvent(
command,
activityType: "handoff",
agentName: handoffAgentName);
}
if (string.IsNullOrWhiteSpace(activeAgentName) || !TryGetToolName(requestInfo, out string toolName))
{
return null;
}
return CreateActivityEvent(
command,
activityType: "tool-calling",
agentName: activeAgentName,
toolName: toolName);
}
private static bool TryGetHandoffTargetName(
PatternDefinitionDto pattern,
RequestInfoEvent requestInfo,
out string agentName)
{
agentName = string.Empty;
if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget))
{
return false;
}
object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget);
agentName = ResolveAgentName(
pattern,
GetStringProperty(target, "Id"),
GetStringProperty(target, "Name"));
return !string.IsNullOrWhiteSpace(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 bool TryResolveKnownAgentName(
PatternDefinitionDto pattern,
string? agentIdentifier,
out string agentName)
{
agentName = string.Empty;
if (string.IsNullOrWhiteSpace(agentIdentifier))
{
return false;
}
PatternAgentDefinitionDto? match = pattern.Agents.FirstOrDefault(agent =>
MatchesAgent(agent, agentIdentifier));
if (match is null)
{
return false;
}
agentName = string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name;
return true;
}
private static string ResolveAgentName(
PatternDefinitionDto pattern,
string? agentId,
string? agentName)
{
PatternAgentDefinitionDto? match = pattern.Agents.FirstOrDefault(agent =>
MatchesAgent(agent, agentId) || MatchesAgent(agent, agentName));
if (match is not null)
{
return string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name;
}
if (!string.IsNullOrWhiteSpace(agentName))
{
return agentName;
}
return agentId ?? string.Empty;
}
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
{
return !string.IsNullOrWhiteSpace(candidate)
&& (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase)
|| string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase));
}
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);
@@ -0,0 +1,12 @@
using Kopaya.AgentHost.Contracts;
namespace Kopaya.AgentHost.Services;
public interface ITurnWorkflowRunner
{
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
CancellationToken cancellationToken);
}
@@ -8,15 +8,20 @@ namespace Kopaya.AgentHost.Services;
public sealed class SidecarProtocolHost
{
private readonly PatternValidator _patternValidator;
private readonly CopilotWorkflowRunner _workflowRunner;
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())
{
_patternValidator = new PatternValidator();
_workflowRunner = new CopilotWorkflowRunner(_patternValidator);
}
public SidecarProtocolHost(PatternValidator patternValidator, ITurnWorkflowRunner? workflowRunner = null)
{
_patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -102,6 +107,7 @@ public sealed class SidecarProtocolHost
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