mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 05:13:58 +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 = [];
|
List<AIAgent> agents = [];
|
||||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
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);
|
CopilotClient client = new(clientOptions);
|
||||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -449,7 +449,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
ReasoningEffort = definition.ReasoningEffort,
|
ReasoningEffort = definition.ReasoningEffort,
|
||||||
SystemMessage = new SystemMessageConfig
|
SystemMessage = new SystemMessageConfig
|
||||||
{
|
{
|
||||||
Content = definition.Instructions,
|
Content = AgentInstructionComposer.Compose(pattern, definition, agentIndex),
|
||||||
},
|
},
|
||||||
WorkingDirectory = projectPath,
|
WorkingDirectory = projectPath,
|
||||||
OnPermissionRequest = ApprovePermissionAsync,
|
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',
|
id: 'agent-handoff-triage',
|
||||||
name: 'Triage',
|
name: 'Triage',
|
||||||
description: 'Routes the request to the right specialist.',
|
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,
|
model: defaultModels.gpt54,
|
||||||
reasoningEffort: 'medium',
|
reasoningEffort: 'medium',
|
||||||
},
|
},
|
||||||
@@ -159,7 +160,8 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
|
|||||||
id: 'agent-handoff-ux',
|
id: 'agent-handoff-ux',
|
||||||
name: 'UX Specialist',
|
name: 'UX Specialist',
|
||||||
description: 'Handles user experience questions.',
|
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,
|
model: defaultModels.claude,
|
||||||
reasoningEffort: 'medium',
|
reasoningEffort: 'medium',
|
||||||
},
|
},
|
||||||
@@ -167,7 +169,8 @@ export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
|
|||||||
id: 'agent-handoff-runtime',
|
id: 'agent-handoff-runtime',
|
||||||
name: 'Runtime Specialist',
|
name: 'Runtime Specialist',
|
||||||
description: 'Handles backend and execution details.',
|
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,
|
model: defaultModels.gpt53,
|
||||||
reasoningEffort: 'medium',
|
reasoningEffort: 'medium',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,4 +81,13 @@ describe('pattern validation', () => {
|
|||||||
}).find((issue) => issue.field === 'agents')?.message,
|
}).find((issue) => issue.field === 'agents')?.message,
|
||||||
).toBe('Group chat requires at least two agents.');
|
).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