mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 05:43:57 +02:00
refactor: extract copilot provider module
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotSessionHooks
|
||||
{
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private const string AllowDecision = "allow";
|
||||
private const string AskDecision = "ask";
|
||||
private const string DenyDecision = "deny";
|
||||
private const string ExitPlanModeToolName = "exit_plan_mode";
|
||||
private const string FetchCopilotCliDocumentationToolName = "fetch_copilot_cli_documentation";
|
||||
private const string HandoffToolPrefix = "handoff_to_";
|
||||
private const string ListAgentsToolName = "list_agents";
|
||||
private const string ReadAgentToolName = "read_agent";
|
||||
private const string ReportIntentToolName = "report_intent";
|
||||
private const string SkillToolName = "skill";
|
||||
private const string SqlToolName = "sql";
|
||||
private const string TaskToolName = "task";
|
||||
private const string TaskCompleteToolName = "task_complete";
|
||||
private const string UpdateTodoToolName = "update_todo";
|
||||
private static readonly HashSet<string> AlwaysAllowedToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AskUserToolName,
|
||||
ExitPlanModeToolName,
|
||||
FetchCopilotCliDocumentationToolName,
|
||||
ListAgentsToolName,
|
||||
ReadAgentToolName,
|
||||
ReportIntentToolName,
|
||||
SkillToolName,
|
||||
SqlToolName,
|
||||
TaskToolName,
|
||||
TaskCompleteToolName,
|
||||
UpdateTodoToolName,
|
||||
};
|
||||
private static readonly JsonSerializerOptions HookJsonOptions = CreateHookJsonOptions();
|
||||
|
||||
public static SessionHooks Create(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
ResolvedHookSet hooks = configuredHooks ?? ResolvedHookSet.Empty;
|
||||
IHookCommandRunner runner = hookCommandRunner ?? HookCommandRunner.Instance;
|
||||
|
||||
return new SessionHooks
|
||||
{
|
||||
OnPreToolUse = (input, _) => CreatePreToolUseOutputAsync(command, agentDefinition, hooks, runner, input),
|
||||
OnPostToolUse = (input, _) => RunPostToolUseHooksAsync(command, hooks, runner, input),
|
||||
OnUserPromptSubmitted = (input, _) => RunUserPromptSubmittedHooksAsync(command, hooks, runner, input),
|
||||
OnSessionStart = (input, _) => RunSessionStartHooksAsync(command, hooks, runner, input),
|
||||
OnSessionEnd = (input, _) => RunSessionEndHooksAsync(command, hooks, runner, input),
|
||||
OnErrorOccurred = (input, _) => RunErrorOccurredHooksAsync(command, hooks, runner, input),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
if (configuredHooks.PreToolUse.Count > 0)
|
||||
{
|
||||
string payload = SerializeHookInput(new FilePreToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
});
|
||||
|
||||
foreach (HookCommandDefinition hook in configuredHooks.PreToolUse)
|
||||
{
|
||||
string? hookOutput = await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
command.ProjectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PreToolUseHookOutput? decision = ParsePreToolUseDecision(hookOutput);
|
||||
if (string.Equals(decision?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CreateApprovalPolicyOutput(command, agentDefinition, input);
|
||||
}
|
||||
|
||||
private static async Task<PostToolUseHookOutput?> RunPostToolUseHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PostToolUseHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.PostToolUse,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FilePostToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
ToolResult = input.ToolResult,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<UserPromptSubmittedHookOutput?> RunUserPromptSubmittedHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
UserPromptSubmittedHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.UserPromptSubmitted,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileUserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Prompt = input.Prompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionStartHookOutput?> RunSessionStartHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionStartHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionStart,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionStartHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Source = input.Source,
|
||||
InitialPrompt = input.InitialPrompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionEndHookOutput?> RunSessionEndHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionEndHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionEnd,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionEndHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Reason = input.Reason,
|
||||
FinalMessage = input.FinalMessage,
|
||||
Error = input.Error,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<ErrorOccurredHookOutput?> RunErrorOccurredHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
ErrorOccurredHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.ErrorOccurred,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Error = new FileHookError
|
||||
{
|
||||
Message = input.Error,
|
||||
Context = input.ErrorContext,
|
||||
Recoverable = input.Recoverable,
|
||||
},
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task RunConfiguredHooksAsync(
|
||||
IReadOnlyList<HookCommandDefinition> hooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
string projectPath,
|
||||
string payload)
|
||||
{
|
||||
if (hooks.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition hook in hooks)
|
||||
{
|
||||
await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
projectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
string? toolName = Normalize(input.ToolName);
|
||||
if (IsAlwaysAllowedTool(toolName))
|
||||
{
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = AllowDecision,
|
||||
};
|
||||
}
|
||||
|
||||
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||
toolName,
|
||||
command.Tooling?.McpServers);
|
||||
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Workflow.Settings.ApprovalPolicy,
|
||||
agentDefinition.GetAgentId(),
|
||||
toolName,
|
||||
autoApprovedToolName,
|
||||
mcpServerApprovalKey);
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = requiresApproval ? AskDecision : AllowDecision,
|
||||
};
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput? ParsePreToolUseDecision(string? hookOutput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hookOutput))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FilePreToolUseHookOutput? parsed = JsonSerializer.Deserialize<FilePreToolUseHookOutput>(hookOutput, HookJsonOptions);
|
||||
if (!string.Equals(parsed?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = DenyDecision,
|
||||
PermissionDecisionReason = Normalize(parsed?.PermissionDecisionReason),
|
||||
};
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Ignoring invalid preToolUse hook output: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeHookInput<T>(T input)
|
||||
=> JsonSerializer.Serialize(input, HookJsonOptions);
|
||||
|
||||
private static string SerializeHookValue(object? value)
|
||||
=> JsonSerializer.Serialize(value, HookJsonOptions);
|
||||
|
||||
private static JsonSerializerOptions CreateHookJsonOptions()
|
||||
{
|
||||
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
|
||||
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
return options;
|
||||
}
|
||||
|
||||
private static bool IsAlwaysAllowedTool(string? toolName)
|
||||
{
|
||||
string? normalizedToolName = Normalize(toolName);
|
||||
return normalizedToolName is not null
|
||||
&& (AlwaysAllowedToolNames.Contains(normalizedToolName)
|
||||
|| normalizedToolName.StartsWith(HandoffToolPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class FileSessionStartHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? InitialPrompt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileSessionEndHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public string? FinalMessage { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileUserPromptSubmittedHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
}
|
||||
|
||||
private sealed class FilePostToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
public object? ToolResult { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileErrorOccurredHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public FileHookError Error { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed class FileHookError
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string Context { get; init; } = string.Empty;
|
||||
public bool Recoverable { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookOutput
|
||||
{
|
||||
public string? PermissionDecision { get; init; }
|
||||
public string? PermissionDecisionReason { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user