mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
feat: add structured prompt invocation backend
- parse prompt tools metadata and carry structured promptInvocation payloads - store prompt invocation metadata on trigger messages for replay-safe reruns - route prompt agents through per-turn plan or Copilot agent overrides - restrict prompt-scoped tools in sidecar session configuration - auto-rescan project customization files with debounced watchers - document the new customization watcher and prompt invocation flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -187,10 +187,22 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string? ProjectInstructions { get; init; }
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
public WorkflowCheckpointResumeDto? ResumeFromCheckpoint { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RunTurnPromptInvocationDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string SourcePath { get; init; } = string.Empty;
|
||||
public string ResolvedPrompt { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public string? Agent { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string TargetRequestId { get; init; } = string.Empty;
|
||||
|
||||
@@ -10,10 +10,12 @@ internal static class AgentInstructionComposer
|
||||
int agentIndex,
|
||||
string workspaceKind = "project",
|
||||
string interactionMode = "interactive",
|
||||
string? projectInstructions = null)
|
||||
string? projectInstructions = null,
|
||||
RunTurnPromptInvocationDto? promptInvocation = null)
|
||||
{
|
||||
string baseInstructions = agent.Instructions.Trim();
|
||||
string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty;
|
||||
string promptInvocationInstructions = FormatPromptInvocation(promptInvocation);
|
||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
||||
? """
|
||||
You are operating in scratchpad mode.
|
||||
@@ -48,10 +50,21 @@ internal static class AgentInstructionComposer
|
||||
Focus on refining the answer already in progress.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
|
||||
return JoinInstructionBlocks(
|
||||
baseInstructions,
|
||||
repositoryInstructions,
|
||||
promptInvocationInstructions,
|
||||
workspaceGuidance,
|
||||
planModeGuidance,
|
||||
groupChatGuidance);
|
||||
}
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
|
||||
return JoinInstructionBlocks(
|
||||
baseInstructions,
|
||||
repositoryInstructions,
|
||||
promptInvocationInstructions,
|
||||
workspaceGuidance,
|
||||
planModeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
@@ -60,4 +73,47 @@ internal static class AgentInstructionComposer
|
||||
"\n\n",
|
||||
blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim()));
|
||||
}
|
||||
|
||||
private static string FormatPromptInvocation(RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
string? resolvedPrompt = promptInvocation?.ResolvedPrompt?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(resolvedPrompt))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
List<string> lines =
|
||||
[
|
||||
"The current turn was started from a repository prompt file.",
|
||||
"Treat the prompt body below as the task directive for this turn rather than as prior user chat history.",
|
||||
$"Source: {promptInvocation!.SourcePath.Trim()}",
|
||||
$"Name: {promptInvocation.Name.Trim()}"
|
||||
];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(promptInvocation.Description))
|
||||
{
|
||||
lines.Add($"Description: {promptInvocation.Description.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(promptInvocation.Agent))
|
||||
{
|
||||
lines.Add($"Agent: {promptInvocation.Agent.Trim()}");
|
||||
}
|
||||
|
||||
if (promptInvocation.Tools is not null)
|
||||
{
|
||||
List<string> toolNames = promptInvocation.Tools
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool))
|
||||
.Select(tool => tool.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
lines.Add(toolNames.Count > 0
|
||||
? $"Tools: {string.Join(", ", toolNames)}"
|
||||
: "Tools: none");
|
||||
}
|
||||
|
||||
lines.Add("Prompt instructions:");
|
||||
lines.Add(resolvedPrompt);
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
{
|
||||
private static readonly string[] RequiredPromptTools =
|
||||
[
|
||||
"ask_user",
|
||||
"report_intent",
|
||||
"task_complete"
|
||||
];
|
||||
private const string HandoffToolPrefix = "handoff_to_";
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
internal CopilotAgentBundle(IReadOnlyList<AIAgent> agents, bool hasConfiguredHooks)
|
||||
@@ -62,6 +69,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
hookCommandRunner);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
|
||||
|
||||
AryxCopilotAgent agent = new(
|
||||
client,
|
||||
@@ -104,7 +112,8 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode,
|
||||
command.ProjectInstructions),
|
||||
command.ProjectInstructions,
|
||||
command.PromptInvocation),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
@@ -113,7 +122,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
OnEvent = onSessionEvent,
|
||||
Streaming = true,
|
||||
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
|
||||
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
|
||||
Agent = ResolveEffectiveAgent(definition.Copilot?.Agent, command.PromptInvocation),
|
||||
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
|
||||
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
|
||||
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
|
||||
@@ -136,6 +145,29 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ApplyPromptInvocation(
|
||||
SessionConfig sessionConfig,
|
||||
RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
IReadOnlyList<string>? allowedTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (allowedTools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sessionConfig.AvailableTools = BuildAvailableTools(sessionConfig.AvailableTools, allowedTools);
|
||||
|
||||
if (sessionConfig.Tools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<AIFunction> filteredTools = sessionConfig.Tools
|
||||
.Where(tool => IsAlwaysAllowedTool(tool.Name) || allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
sessionConfig.Tools = filteredTools.Count > 0 ? filteredTools : null;
|
||||
}
|
||||
|
||||
internal static List<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
@@ -180,6 +212,67 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
: null;
|
||||
}
|
||||
|
||||
private static List<string> BuildAvailableTools(
|
||||
ICollection<string>? existingAvailableTools,
|
||||
IReadOnlyList<string> allowedTools)
|
||||
{
|
||||
List<string> availableTools = existingAvailableTools is { Count: > 0 }
|
||||
? existingAvailableTools
|
||||
.Where(tool => allowedTools.Contains(tool, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList()
|
||||
: [.. allowedTools];
|
||||
|
||||
foreach (string requiredTool in RequiredPromptTools)
|
||||
{
|
||||
if (!availableTools.Contains(requiredTool, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
availableTools.Add(requiredTool);
|
||||
}
|
||||
}
|
||||
|
||||
return availableTools;
|
||||
}
|
||||
|
||||
private static bool IsAlwaysAllowedTool(string toolName)
|
||||
{
|
||||
return toolName.StartsWith(HandoffToolPrefix, StringComparison.Ordinal)
|
||||
|| RequiredPromptTools.Contains(toolName, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeToolNames(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? ResolveEffectiveAgent(
|
||||
string? defaultAgent,
|
||||
RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
string? promptAgent = NormalizeOptionalString(promptInvocation?.Agent);
|
||||
if (!string.IsNullOrWhiteSpace(promptAgent)
|
||||
&& !string.Equals(promptAgent, "plan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return promptAgent;
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? promptTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (promptTools is { Count: > 0 })
|
||||
{
|
||||
return "agent";
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(defaultAgent);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
@@ -184,6 +184,48 @@ public sealed class AgentInstructionComposerTests
|
||||
< instructions.IndexOf("scratchpad mode", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_AppendsPromptInvocationAsATaskDirective()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-primary",
|
||||
name: "Primary Agent",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
pattern,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
promptInvocation: new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
Description = "Review docs for missing steps",
|
||||
Agent = "plan",
|
||||
Tools = ["view", "glob"],
|
||||
ResolvedPrompt = "Review the docs for missing steps and propose updates."
|
||||
});
|
||||
|
||||
Assert.Contains("repository prompt file", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(@"Source: .github\prompts\docs\doc-review.prompt.md", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Name: doc-review", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Description: Review docs for missing steps", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Agent: plan", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Tools: view, glob", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Prompt instructions:\nReview the docs for missing steps and propose updates.",
|
||||
instructions,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
|
||||
@@ -54,6 +54,34 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPromptInvocation_RestrictsAvailableToolsAndKeepsHandoffTools()
|
||||
{
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
AvailableTools = ["view", "glob", "edit"],
|
||||
Tools = [CreateTool("view"), CreateTool("edit"), CreateTool("handoff_to_reviewer")],
|
||||
};
|
||||
|
||||
CopilotAgentBundle.ApplyPromptInvocation(
|
||||
sessionConfig,
|
||||
new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
ResolvedPrompt = "Review the docs for missing steps.",
|
||||
Tools = ["view"],
|
||||
});
|
||||
|
||||
Assert.Equal(["view", "ask_user", "report_intent", "task_complete"], sessionConfig.AvailableTools);
|
||||
|
||||
AIFunction[] tools = Assert.IsAssignableFrom<IEnumerable<AIFunction>>(sessionConfig.Tools).ToArray();
|
||||
Assert.Equal(2, tools.Length);
|
||||
Assert.Contains(tools, tool => tool.Name == "view");
|
||||
Assert.Contains(tools, tool => tool.Name == "handoff_to_reviewer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_StoresWhetherHooksAreConfigured()
|
||||
{
|
||||
@@ -426,6 +454,95 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSessionConfig_UsesPromptAgentOverride()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
PromptInvocation = new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
Agent = "designer",
|
||||
ResolvedPrompt = "Review the docs for missing steps.",
|
||||
},
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Equal("designer", sessionConfig.Agent);
|
||||
Assert.Contains("Review the docs for missing steps.", sessionConfig.SystemMessage?.Content, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSessionConfig_DefaultsPromptToolInvocationsToAgentMode()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
PromptInvocation = new RunTurnPromptInvocationDto
|
||||
{
|
||||
Id = "project_customization_prompt_doc_review",
|
||||
Name = "doc-review",
|
||||
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
|
||||
ResolvedPrompt = "Review the docs for missing steps.",
|
||||
Tools = ["view"],
|
||||
},
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Equal("agent", sessionConfig.Agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse()
|
||||
{
|
||||
@@ -484,7 +601,7 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal("agent-ux", agentId);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool()
|
||||
private static AIFunction CreateTool(string name = "echo")
|
||||
{
|
||||
ToolTarget target = new();
|
||||
MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo))
|
||||
@@ -495,7 +612,7 @@ public sealed class CopilotAgentBundleTests
|
||||
target,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "echo",
|
||||
Name = name,
|
||||
Description = "Echo test tool",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user