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:
David Kaya
2026-04-02 11:41:18 +02:00
co-authored by Copilot
parent aa7830f01a
commit 5d69d9d855
21 changed files with 900 additions and 47 deletions
@@ -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",
});
}