diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 05b1201..bfe307b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/README.md b/README.md index 1936807..43b0af3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs index 27041db..7130e04 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -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 HandleWorkflowEventAsync( RunTurnCommandDto command, WorkflowEvent evt, IReadOnlyList 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 allMessages = outputEvent.As>() ?? []; state.UpdateCompletedMessages(allMessages, inputMessages); } + + return false; } private static async Task HandleAgentResponseUpdateAsync( diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index 707a91f..a7804e1 100644 --- a/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -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, diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index c8838fb..673ac99 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -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 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 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 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() { diff --git a/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs index 60e4885..6c586ba 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -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())); + + 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 diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 4bf52e1..0045627 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -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,