mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 05:43:57 +02:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
381 lines
14 KiB
C#
381 lines
14 KiB
C#
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,
|
|
};
|
|
}
|
|
|
|
CopilotApprovalCoordinator.ResolvedApprovalContext approval = CopilotApprovalCoordinator.ResolveHookApprovalContext(
|
|
toolName,
|
|
command.Tooling?.McpServers);
|
|
|
|
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
|
command.Workflow.Settings.ApprovalPolicy,
|
|
agentDefinition.GetAgentId(),
|
|
approval.ToolName,
|
|
approval.ApprovalToolKey,
|
|
approval.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; }
|
|
}
|
|
}
|