mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
fix: enforce handoff delegation roles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user