fix: align orchestration behavior with agent framework docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 22:09:52 +01:00
co-authored by Copilot
parent cf6a25a9da
commit 5ab0b22d15
7 changed files with 235 additions and 17 deletions
+4 -2
View File
@@ -92,10 +92,10 @@ sequenceDiagram
M->>M: Create run record and mark session running
M->>S: run-turn command
S->>C: Execute workflow
C-->>S: Partial output / tool activity / handoffs
C-->>S: Partial output / tool activity / handoffs / input requests
S-->>M: Stream deltas and activity events
M-->>R: Push session events and workspace updates
C-->>S: Final messages
C-->>S: Final messages or turn boundary
S-->>M: Completion or error
M->>M: Finalize run and persist state
M-->>R: Final workspace snapshot
@@ -134,6 +134,8 @@ Patterns describe how agents collaborate. The architecture supports:
- handoff flows
- group chat style collaboration
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
### Sessions
+4 -4
View File
@@ -36,10 +36,10 @@ Add a local folder when you want help that is grounded in your work. Eryx is des
Eryx supports several ways of working:
- **Single** for direct one-agent help
- **Sequential** for step-by-step specialist workflows
- **Concurrent** for parallel exploration
- **Handoff** for agent-to-agent delegation
- **Group chat** for collaborative multi-agent discussion
- **Sequential** for pipeline-style work where each agent sees the full conversation and appends its contribution
- **Concurrent** for parallel exploration where the final turn aggregates multiple independent responses
- **Handoff** for agent-to-agent delegation, with the next user turn continuing when a specialist needs more input
- **Group chat** for round-robin collaborative refinement across multiple agent turns
### Add global MCPs and LSPs
@@ -47,8 +47,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
{
await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
.ConfigureAwait(false);
if (shouldEndTurn)
{
break;
}
}
return state.FinalizeCompletedMessages();
@@ -61,7 +65,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
}
private static async Task HandleWorkflowEventAsync(
private static async Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
@@ -76,7 +80,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity invokedAgent))
{
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
return;
return false;
}
if (evt is RequestInfoEvent requestInfo)
@@ -89,18 +93,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (activity is null)
{
return;
return WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
}
state.ApplyActivity(activity);
await onActivity(activity).ConfigureAwait(false);
return;
return false;
}
if (evt is AgentResponseUpdateEvent update)
{
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
return;
return false;
}
if (evt is ExecutorCompletedEvent completed
@@ -111,7 +115,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity completedAgent))
{
state.ClearActiveAgentIfMatching(completedAgent);
return;
return false;
}
if (evt is WorkflowOutputEvent outputEvent)
@@ -119,6 +123,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
state.UpdateCompletedMessages(allMessages, inputMessages);
}
return false;
}
private static async Task HandleAgentResponseUpdateAsync(
@@ -52,6 +52,23 @@ internal static class WorkflowRequestInfoInterpreter
};
}
public static bool RequiresUserInputTurnBoundary(
RunTurnCommandDto command,
RequestInfoEvent requestInfo)
{
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (TryGetHandoffTarget(command.Pattern, requestInfo, out _))
{
return false;
}
return !TryGetToolRequestInfo(requestInfo, out _, out _);
}
private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern,
RequestInfoEvent requestInfo,
@@ -156,6 +156,143 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Hello", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-sequential",
Name = "Sequential Trio Review",
Mode = "sequential",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-sequential-analyst", name: "Analyst"),
CreateAgent(id: "agent-sequential-builder", name: "Builder"),
CreateAgent(id: "agent-sequential-reviewer", name: "Reviewer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Plan the approach.") { AuthorName = "Analyst" },
new ChatMessage(ChatRole.Assistant, "Implement the change.") { AuthorName = "Builder" },
new ChatMessage(ChatRole.Assistant, "Review the implementation.") { AuthorName = "Reviewer" },
],
[]);
Assert.Collection(
messages,
analyst =>
{
Assert.Equal("Analyst", analyst.AuthorName);
Assert.Equal("Plan the approach.", analyst.Content);
},
builder =>
{
Assert.Equal("Builder", builder.AuthorName);
Assert.Equal("Implement the change.", builder.Content);
},
reviewer =>
{
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Review the implementation.", reviewer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_PreservesConcurrentAggregatedResponses()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-concurrent",
Name = "Concurrent Brainstorm",
Mode = "concurrent",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-product", name: "Product"),
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Architecture concerns.") { AuthorName = "Architect" },
new ChatMessage(ChatRole.Assistant, "Product trade-offs.") { AuthorName = "Product" },
new ChatMessage(ChatRole.Assistant, "Implementation details.") { AuthorName = "Implementer" },
],
[]);
Assert.Collection(
messages,
architect => Assert.Equal("Architect", architect.AuthorName),
product => Assert.Equal("Product", product.AuthorName),
implementer => Assert.Equal("Implementer", implementer.AuthorName));
}
[Fact]
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-group-chat",
Name = "Collaborative Group Chat",
Mode = "group-chat",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-group-writer", name: "Writer"),
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Initial draft.") { AuthorName = "Writer" },
new ChatMessage(ChatRole.Assistant, "Needs clearer examples.") { AuthorName = "Reviewer" },
new ChatMessage(ChatRole.Assistant, "Revised draft with examples.") { AuthorName = "Writer" },
],
[]);
Assert.Collection(
messages,
writerDraft =>
{
Assert.Equal("Writer", writerDraft.AuthorName);
Assert.Equal("Initial draft.", writerDraft.Content);
},
reviewer =>
{
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Needs clearer examples.", reviewer.Content);
},
writerRevision =>
{
Assert.Equal("Writer", writerRevision.AuthorName);
Assert.Equal("Revised draft with examples.", writerRevision.Content);
});
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
{
@@ -109,6 +109,62 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Empty(toolNamesByCallId);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(new
{
Prompt = "Please provide more detail.",
});
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateHandoffCommand(),
requestInfo);
Assert.True(requiresBoundary);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsFalseForExplicitHandoffs()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateHandoffCommand(),
requestInfo);
Assert.False(requiresBoundary);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsFalseForToolRequests()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateHandoffCommand(),
requestInfo);
Assert.False(requiresBoundary);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsFalseOutsideHandoffMode()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(new
{
Prompt = "Please provide more detail.",
});
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateSingleAgentCommand(),
requestInfo);
Assert.False(requiresBoundary);
}
private static RunTurnCommandDto CreateSingleAgentCommand()
{
return new RunTurnCommandDto
+4 -4
View File
@@ -90,7 +90,7 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
{
id: 'pattern-sequential-review',
name: 'Sequential Trio Review',
description: 'Three Copilot-backed agents execute in order, refining the answer each step.',
description: 'Agents execute in order, each seeing the full conversation and appending to a shared transcript.',
mode: 'sequential',
availability: 'available',
maxIterations: 1,
@@ -126,7 +126,7 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
{
id: 'pattern-concurrent-brainstorm',
name: 'Concurrent Brainstorm',
description: 'Multiple agents respond in parallel for comparison or voting.',
description: 'Agents work independently in parallel and the final conversation aggregates every response.',
mode: 'concurrent',
availability: 'available',
maxIterations: 1,
@@ -162,7 +162,7 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
{
id: 'pattern-handoff-support',
name: 'Handoff Support Flow',
description: 'A triage agent routes the task to specialists and can reclaim control.',
description: 'A triage agent routes work to specialists, and the next user turn continues when more input is needed.',
mode: 'handoff',
availability: 'available',
maxIterations: 4,
@@ -201,7 +201,7 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
{
id: 'pattern-group-chat',
name: 'Collaborative Group Chat',
description: 'Two or more agents iterate together under a round-robin manager.',
description: 'Agents take turns under a round-robin manager, iteratively refining a shared conversation.',
mode: 'group-chat',
availability: 'available',
maxIterations: 5,