diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index 5e94c60..eacee7d 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -500,11 +500,30 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern) { AIAgent firstAgent = Agents[0]; - IReadOnlyList specialists = Agents.Skip(1).ToList(); + PatternAgentDefinitionDto triageDefinition = pattern.Agents[0]; + IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists = + Agents.Skip(1) + .Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition)) + .ToList(); HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent) - .WithHandoffs(firstAgent, specialists) - .WithHandoffs(specialists, firstAgent); + .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); + + foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists) + { + builder = builder.WithHandoff( + firstAgent, + specialist, + HandoffWorkflowGuidance.CreateForwardReason(definition)); + } + + foreach ((AIAgent specialist, _) in specialists) + { + builder = builder.WithHandoff( + specialist, + firstAgent, + HandoffWorkflowGuidance.CreateReturnReason(triageDefinition)); + } return builder.Build(); } diff --git a/sidecar/src/Kopaya.AgentHost/Services/HandoffWorkflowGuidance.cs b/sidecar/src/Kopaya.AgentHost/Services/HandoffWorkflowGuidance.cs new file mode 100644 index 0000000..fb55027 --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/Services/HandoffWorkflowGuidance.cs @@ -0,0 +1,31 @@ +using Kopaya.AgentHost.Contracts; + +namespace Kopaya.AgentHost.Services; + +internal static class HandoffWorkflowGuidance +{ + public static string CreateWorkflowInstructions() + { + return """ + This workflow uses explicit handoffs to transfer ownership between agents. + If another agent should do the substantive work, perform an actual handoff instead of answering as though the handoff already happened. + Do not claim that you delegated unless you actually executed the handoff. + The triage agent should route to the best specialist promptly once ownership is clear. + Specialists should complete the substantive work after handoff and only hand control back when the task needs re-routing, broader coordination, or is outside their specialty. + """; + } + + public static string CreateForwardReason(PatternAgentDefinitionDto target) + { + string specialty = string.IsNullOrWhiteSpace(target.Description) + ? target.Name + : target.Description.TrimEnd('.'); + + return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.Name} own the substantive response."; + } + + public static string CreateReturnReason(PatternAgentDefinitionDto triageAgent) + { + return $"Hand off back to {triageAgent.Name} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty."; + } +} diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/HandoffWorkflowGuidanceTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/HandoffWorkflowGuidanceTests.cs new file mode 100644 index 0000000..ee7f9c2 --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/HandoffWorkflowGuidanceTests.cs @@ -0,0 +1,55 @@ +using Kopaya.AgentHost.Contracts; +using Kopaya.AgentHost.Services; + +namespace Kopaya.AgentHost.Tests; + +public sealed class HandoffWorkflowGuidanceTests +{ + [Fact] + public void CreateWorkflowInstructions_RequiresRealHandoffs() + { + string instructions = HandoffWorkflowGuidance.CreateWorkflowInstructions(); + + Assert.Contains("explicit handoffs", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not claim that you delegated", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Specialists should complete the substantive work", instructions, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateForwardReason_UsesTargetSpecialtyAndOwnership() + { + PatternAgentDefinitionDto specialist = new() + { + Id = "agent-handoff-ux", + Name = "UX Specialist", + Description = "Handles user experience questions.", + Instructions = "Focus on UX.", + Model = "claude-opus-4.5", + }; + + string reason = HandoffWorkflowGuidance.CreateForwardReason(specialist); + + Assert.Contains("Handles user experience questions", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("UX Specialist", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("substantive response", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateReturnReason_RestrictsReturnToReroutingCases() + { + PatternAgentDefinitionDto triage = new() + { + Id = "agent-handoff-triage", + Name = "Triage", + Description = "Routes the request to the right specialist.", + Instructions = "Triages requests.", + Model = "gpt-5.4", + }; + + string reason = HandoffWorkflowGuidance.CreateReturnReason(triage); + + Assert.Contains("Triage", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("re-routing", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("outside your specialty", reason, StringComparison.OrdinalIgnoreCase); + } +}