fix: enforce handoff delegation roles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 21:13:16 +01:00
co-authored by Copilot
parent 8f668da7f8
commit f940b9d153
5 changed files with 132 additions and 5 deletions
@@ -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}";
}
}
@@ -438,7 +438,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
List<AIAgent> 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,
@@ -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",
};
}
}
+6 -3
View File
@@ -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',
},
+9
View File
@@ -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');
});
});