From f940b9d153e94675044e8c74ba4b5eb9c5ea9fe3 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 21 Mar 2026 21:13:16 +0100 Subject: [PATCH] fix: enforce handoff delegation roles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/AgentInstructionComposer.cs | 35 ++++++++ .../Services/CopilotWorkflowRunner.cs | 4 +- .../AgentInstructionComposerTests.cs | 80 +++++++++++++++++++ src/shared/domain/pattern.ts | 9 ++- tests/shared/pattern.test.ts | 9 +++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs create mode 100644 sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs diff --git a/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs b/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs new file mode 100644 index 0000000..cade8ce --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs @@ -0,0 +1,35 @@ +using Kopaya.AgentHost.Contracts; + +namespace Kopaya.AgentHost.Services; + +internal static class AgentInstructionComposer +{ + public static string Compose( + PatternDefinitionDto pattern, + PatternAgentDefinitionDto agent, + int agentIndex) + { + string baseInstructions = agent.Instructions.Trim(); + if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) + { + return baseInstructions; + } + + string runtimeGuidance = agentIndex == 0 + ? """ + You are the routing gate for this handoff workflow. + Your job is to classify the request and hand it off to the most appropriate specialist as soon as you know who should own the substantive work. + Do not perform the specialist's implementation, design, or execution work yourself once a specialist is appropriate. + Only answer directly if the user is asking for pure triage or a minimal clarification that must happen before delegation. + """ + : """ + You are a specialist participating in a handoff workflow. + Once the triage agent hands work to you, you own the substantive answer within your specialty and should carry it through. + Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty. + """; + + return string.IsNullOrWhiteSpace(baseInstructions) + ? runtimeGuidance + : $"{baseInstructions}\n\n{runtimeGuidance}"; + } +} diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index 95b0820..5e94c60 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -438,7 +438,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner List agents = []; CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); - foreach (PatternAgentDefinitionDto definition in pattern.Agents) + foreach ((PatternAgentDefinitionDto definition, int agentIndex) in pattern.Agents.Select((definition, index) => (definition, index))) { CopilotClient client = new(clientOptions); await client.StartAsync(cancellationToken).ConfigureAwait(false); @@ -449,7 +449,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner ReasoningEffort = definition.ReasoningEffort, SystemMessage = new SystemMessageConfig { - Content = definition.Instructions, + Content = AgentInstructionComposer.Compose(pattern, definition, agentIndex), }, WorkingDirectory = projectPath, OnPermissionRequest = ApprovePermissionAsync, diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs new file mode 100644 index 0000000..6c721fc --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs @@ -0,0 +1,80 @@ +using Kopaya.AgentHost.Contracts; +using Kopaya.AgentHost.Services; + +namespace Kopaya.AgentHost.Tests; + +public sealed class AgentInstructionComposerTests +{ + [Fact] + public void Compose_LeavesNonHandoffInstructionsUnchanged() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-sequential", + Name = "Sequential", + Mode = "sequential", + Availability = "available", + }; + PatternAgentDefinitionDto agent = CreateAgent( + id: "agent-reviewer", + name: "Reviewer", + instructions: "Review the proposal."); + + string instructions = AgentInstructionComposer.Compose(pattern, agent, agentIndex: 0); + + Assert.Equal("Review the proposal.", instructions); + } + + [Fact] + public void Compose_StrengthensHandoffTriageInstructions() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-handoff", + Name = "Handoff", + Mode = "handoff", + Availability = "available", + }; + PatternAgentDefinitionDto triage = CreateAgent( + id: "agent-handoff-triage", + name: "Triage", + instructions: "You triage requests and must hand them off to the most appropriate specialist."); + + string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0); + + Assert.Contains("routing gate", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not perform the specialist", instructions, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Compose_StrengthensHandoffSpecialistInstructions() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-handoff", + Name = "Handoff", + Mode = "handoff", + Availability = "available", + }; + PatternAgentDefinitionDto specialist = CreateAgent( + id: "agent-handoff-ux", + name: "UX Specialist", + instructions: "You focus on navigation, UX, and interaction details."); + + string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1); + + Assert.Contains("Once the triage agent hands work to you", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase); + } + + private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions) + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Instructions = instructions, + Model = "gpt-5.4", + }; + } +} diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 5302b4e..bc0a2ae 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -151,7 +151,8 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] { id: 'agent-handoff-triage', name: 'Triage', description: 'Routes the request to the right specialist.', - instructions: 'You triage requests and must hand them off to the most appropriate specialist.', + instructions: + 'You triage requests and must hand them off to the most appropriate specialist. Do not do the specialist work yourself once the right owner is clear.', model: defaultModels.gpt54, reasoningEffort: 'medium', }, @@ -159,7 +160,8 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] { id: 'agent-handoff-ux', name: 'UX Specialist', description: 'Handles user experience questions.', - instructions: 'You focus on navigation, UX, and interaction details.', + instructions: + 'You focus on navigation, UX, and interaction details. Once triage hands work to you, you own the substantive answer.', model: defaultModels.claude, reasoningEffort: 'medium', }, @@ -167,7 +169,8 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] { id: 'agent-handoff-runtime', name: 'Runtime Specialist', description: 'Handles backend and execution details.', - instructions: 'You focus on runtime, orchestration, and backend integration details.', + instructions: + 'You focus on runtime, orchestration, and backend integration details. Once triage hands work to you, you own the substantive answer.', model: defaultModels.gpt53, reasoningEffort: 'medium', }, diff --git a/tests/shared/pattern.test.ts b/tests/shared/pattern.test.ts index 6eaf1f2..df65101 100644 --- a/tests/shared/pattern.test.ts +++ b/tests/shared/pattern.test.ts @@ -81,4 +81,13 @@ describe('pattern validation', () => { }).find((issue) => issue.field === 'agents')?.message, ).toBe('Group chat requires at least two agents.'); }); + + test('handoff builtin instructions clearly separate triage and specialist ownership', () => { + const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find((pattern) => pattern.mode === 'handoff'); + + expect(handoff).toBeDefined(); + expect(handoff?.agents[0].instructions).toContain('Do not do the specialist work yourself'); + expect(handoff?.agents[1].instructions).toContain('own the substantive answer'); + expect(handoff?.agents[2].instructions).toContain('own the substantive answer'); + }); });