mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 07:58:47 +02:00
fix: use runtime tools for approval auto-approval
- load runtime tools dynamically from Copilot CLI capabilities via tools.list - merge runtime tools with configured MCP and LSP tools in the approval catalog - keep a fallback builtin runtime tool list when capabilities are unavailable - move approval-tool pruning to the app service so dynamic tools are not dropped on load - update approval UI and docs to use the corrected runtime-tool model Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+44
-13
@@ -103,6 +103,16 @@ function isBuiltinPattern(patternId: string): boolean {
|
||||
return patternId.startsWith('pattern-');
|
||||
}
|
||||
|
||||
function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean {
|
||||
const normalizedLeft = left ?? [];
|
||||
const normalizedRight = right ?? [];
|
||||
if (normalizedLeft.length !== normalizedRight.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
||||
}
|
||||
|
||||
export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
private readonly sidecar = new SidecarClient();
|
||||
@@ -124,7 +134,8 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async loadWorkspace(): Promise<WorkspaceState> {
|
||||
if (!this.workspace) {
|
||||
this.workspace = await this.workspaceRepository.load();
|
||||
if (this.failInterruptedPendingApprovals(this.workspace)) {
|
||||
const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace);
|
||||
if (didPruneApprovalTools || this.failInterruptedPendingApprovals(this.workspace)) {
|
||||
await this.workspaceRepository.save(this.workspace);
|
||||
}
|
||||
}
|
||||
@@ -200,9 +211,10 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||
const issues = validatePatternDefinition(
|
||||
pattern,
|
||||
this.listKnownApprovalToolNames(workspace),
|
||||
knownApprovalToolNames,
|
||||
).filter((issue) => issue.level === 'error');
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0].message);
|
||||
@@ -281,7 +293,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspace.settings.tooling.mcpServers.push(candidate);
|
||||
}
|
||||
|
||||
this.pruneUnavailableApprovalTools(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -299,7 +311,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
this.pruneUnavailableApprovalTools(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -328,7 +340,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspace.settings.tooling.lspProfiles.push(candidate);
|
||||
}
|
||||
|
||||
this.pruneUnavailableApprovalTools(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -346,7 +358,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
this.pruneUnavailableApprovalTools(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -703,7 +715,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
throw new Error('Scratchpad sessions do not support tool auto-approval settings.');
|
||||
}
|
||||
|
||||
const knownToolNames = new Set(this.listKnownApprovalToolNames(workspace));
|
||||
const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace));
|
||||
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
|
||||
if (unknownToolName) {
|
||||
throw new Error(`Unknown approval tool "${unknownToolName}".`);
|
||||
@@ -1172,23 +1184,42 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
|
||||
}
|
||||
|
||||
private listKnownApprovalToolNames(workspace: WorkspaceState): string[] {
|
||||
return listApprovalToolNames(workspace.settings.tooling);
|
||||
private async listKnownApprovalToolNames(workspace: WorkspaceState): Promise<string[]> {
|
||||
const capabilities = await this.loadSidecarCapabilities();
|
||||
const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined;
|
||||
return listApprovalToolNames(workspace.settings.tooling, runtimeTools);
|
||||
}
|
||||
|
||||
private pruneUnavailableApprovalTools(workspace: WorkspaceState): void {
|
||||
const knownToolNames = this.listKnownApprovalToolNames(workspace);
|
||||
private async pruneUnavailableApprovalTools(workspace: WorkspaceState): Promise<boolean> {
|
||||
const knownToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||
let changed = false;
|
||||
|
||||
for (const pattern of workspace.patterns) {
|
||||
pattern.approvalPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames);
|
||||
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames);
|
||||
if (!equalStringArrays(
|
||||
pattern.approvalPolicy?.autoApprovedToolNames,
|
||||
nextPolicy?.autoApprovedToolNames,
|
||||
)) {
|
||||
pattern.approvalPolicy = nextPolicy;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of workspace.sessions) {
|
||||
session.approvalSettings = pruneSessionApprovalSettings(
|
||||
const nextSettings = pruneSessionApprovalSettings(
|
||||
session.approvalSettings,
|
||||
knownToolNames,
|
||||
);
|
||||
if (!equalStringArrays(
|
||||
session.approvalSettings?.autoApprovedToolNames,
|
||||
nextSettings?.autoApprovedToolNames,
|
||||
)) {
|
||||
session.approvalSettings = nextSettings;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private buildRunTurnToolingConfig(
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import {
|
||||
listApprovalToolNames,
|
||||
normalizeSessionToolingSelection,
|
||||
normalizeWorkspaceSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
@@ -13,8 +12,6 @@ import {
|
||||
normalizeApprovalPolicy,
|
||||
normalizePendingApprovalState,
|
||||
normalizeSessionApprovalSettings,
|
||||
pruneApprovalPolicyTools,
|
||||
pruneSessionApprovalSettings,
|
||||
} from '@shared/domain/approval';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
@@ -68,26 +65,19 @@ export class WorkspaceRepository {
|
||||
|
||||
const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath);
|
||||
const settings = normalizeWorkspaceSettings(stored.settings);
|
||||
const knownToolNames = listApprovalToolNames(settings.tooling);
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
...stored,
|
||||
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
|
||||
...pattern,
|
||||
approvalPolicy: pruneApprovalPolicyTools(
|
||||
normalizeApprovalPolicy(pattern.approvalPolicy),
|
||||
knownToolNames,
|
||||
),
|
||||
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
|
||||
})),
|
||||
projects,
|
||||
sessions: (stored.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: pruneSessionApprovalSettings(
|
||||
normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
knownToolNames,
|
||||
),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
...normalizePendingApprovalState({
|
||||
pendingApproval: session.pendingApproval,
|
||||
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||
|
||||
@@ -275,6 +275,7 @@ export default function App() {
|
||||
lspProfiles={workspace.settings.tooling.lspProfiles}
|
||||
mcpServers={workspace.settings.tooling.mcpServers}
|
||||
toolingSettings={workspace.settings.tooling}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onUpdateSessionTooling={(selection) => {
|
||||
void api.updateSessionTooling({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type {
|
||||
LspProfileDefinition,
|
||||
McpServerDefinition,
|
||||
RuntimeToolDefinition,
|
||||
SessionToolingSelection,
|
||||
WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
@@ -163,6 +164,7 @@ interface ActivityPanelProps {
|
||||
lspProfiles: LspProfileDefinition[];
|
||||
mcpServers: McpServerDefinition[];
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onUpdateSessionTooling: (selection: SessionToolingSelection) => void;
|
||||
onUpdateSessionApprovalSettings: (settings: { autoApprovedToolNames?: string[] }) => void;
|
||||
@@ -176,6 +178,7 @@ export function ActivityPanel({
|
||||
lspProfiles,
|
||||
mcpServers,
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
onJumpToMessage,
|
||||
onUpdateSessionTooling,
|
||||
onUpdateSessionApprovalSettings,
|
||||
@@ -188,7 +191,10 @@ export function ActivityPanel({
|
||||
[activity, pattern.agents],
|
||||
);
|
||||
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||
const approvalTools = useMemo(() => listApprovalToolDefinitions(toolingSettings), [toolingSettings]);
|
||||
const approvalTools = useMemo(
|
||||
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
|
||||
[runtimeTools, toolingSettings],
|
||||
);
|
||||
|
||||
const isOverridden = session.approvalSettings !== undefined;
|
||||
const effectiveAutoApproved = new Set(
|
||||
@@ -353,7 +359,7 @@ export function ActivityPanel({
|
||||
</p>
|
||||
) : approvalTools.length === 0 ? (
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Add MCP servers or LSP profiles in Settings to configure tool auto-approvals.
|
||||
No approval-capable runtime tools are currently available.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -478,7 +484,13 @@ function ApprovalOverrideRow({
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const kindBadge = tool.kind === 'lsp' ? 'LSP' : tool.kind === 'mcp' ? 'MCP' : 'Mixed';
|
||||
const kindBadge = tool.kind === 'builtin'
|
||||
? 'Built-in'
|
||||
: tool.kind === 'lsp'
|
||||
? 'LSP'
|
||||
: tool.kind === 'mcp'
|
||||
? 'MCP'
|
||||
: 'Mixed';
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition ${
|
||||
@@ -496,6 +508,11 @@ function ApprovalOverrideRow({
|
||||
{kindBadge}
|
||||
</span>
|
||||
</div>
|
||||
{(tool.description || tool.providerNames.length > 0) && (
|
||||
<div className="truncate text-[10px] text-zinc-600">
|
||||
{tool.description ?? tool.providerNames.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch enabled={enabled} />
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import {
|
||||
listApprovalToolDefinitions,
|
||||
type ApprovalToolDefinition,
|
||||
type RuntimeToolDefinition,
|
||||
type WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
@@ -42,6 +43,7 @@ interface PatternEditorProps {
|
||||
pattern: PatternDefinition;
|
||||
isBuiltin: boolean;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
onChange: (pattern: PatternDefinition) => void;
|
||||
onDelete?: () => void;
|
||||
onSave: () => void;
|
||||
@@ -209,6 +211,7 @@ export function PatternEditor({
|
||||
pattern,
|
||||
isBuiltin,
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
onChange,
|
||||
onDelete,
|
||||
onSave,
|
||||
@@ -262,7 +265,7 @@ export function PatternEditor({
|
||||
});
|
||||
}
|
||||
|
||||
const approvalTools = listApprovalToolDefinitions(toolingSettings);
|
||||
const approvalTools = listApprovalToolDefinitions(toolingSettings, runtimeTools);
|
||||
const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
|
||||
|
||||
function toggleToolAutoApproval(toolId: string) {
|
||||
@@ -568,7 +571,7 @@ export function PatternEditor({
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3">
|
||||
{approvalTools.length === 0 ? (
|
||||
<p className="py-2 text-center text-[11px] text-zinc-600">
|
||||
No tools available. Add MCP servers or LSP profiles in Settings to configure auto-approvals.
|
||||
No approval-capable runtime tools are currently available.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
@@ -719,7 +722,13 @@ function ToolApprovalToggleRow({
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const kindBadge = tool.kind === 'lsp' ? 'LSP' : tool.kind === 'mcp' ? 'MCP' : 'Mixed';
|
||||
const kindBadge = tool.kind === 'builtin'
|
||||
? 'Built-in'
|
||||
: tool.kind === 'lsp'
|
||||
? 'LSP'
|
||||
: tool.kind === 'mcp'
|
||||
? 'MCP'
|
||||
: 'Mixed';
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition hover:bg-zinc-800/60"
|
||||
@@ -733,11 +742,13 @@ function ToolApprovalToggleRow({
|
||||
{kindBadge}
|
||||
</span>
|
||||
</div>
|
||||
{tool.providerNames.length > 0 && (
|
||||
<div className="truncate text-[10px] text-zinc-600">{tool.providerNames.join(', ')}</div>
|
||||
{(tool.description || tool.providerNames.length > 0) && (
|
||||
<div className="truncate text-[10px] text-zinc-600">
|
||||
{tool.description ?? tool.providerNames.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch enabled={enabled} onToggle={onToggle} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export function SettingsPanel({
|
||||
setEditingPattern(null);
|
||||
}}
|
||||
pattern={editingPattern}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
toolingSettings={toolingSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
|
||||
|
||||
export interface SidecarModeCapability {
|
||||
available: boolean;
|
||||
@@ -52,6 +53,7 @@ export interface SidecarCapabilities {
|
||||
runtime: 'dotnet-maf';
|
||||
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
|
||||
models: SidecarModelCapability[];
|
||||
runtimeTools: RuntimeToolDefinition[];
|
||||
connection: SidecarConnectionDiagnostics;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,13 @@ export interface SessionToolingSelection {
|
||||
enabledLspProfileIds: string[];
|
||||
}
|
||||
|
||||
export type ApprovalToolKind = 'mcp' | 'lsp' | 'mixed';
|
||||
export type ApprovalToolKind = 'builtin' | 'mcp' | 'lsp' | 'mixed';
|
||||
|
||||
export interface RuntimeToolDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalToolDefinition {
|
||||
id: string;
|
||||
@@ -60,6 +66,7 @@ export interface ApprovalToolDefinition {
|
||||
kind: ApprovalToolKind;
|
||||
providerIds: string[];
|
||||
providerNames: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const lspApprovalOperations = [
|
||||
@@ -70,6 +77,17 @@ const lspApprovalOperations = [
|
||||
{ suffix: 'references', label: 'References' },
|
||||
] as const;
|
||||
|
||||
// Fallback runtime tools used before sidecar capabilities are loaded or when the
|
||||
// CLI cannot report its built-in tool catalog dynamically.
|
||||
const fallbackRuntimeApprovalTools: ReadonlyArray<RuntimeToolDefinition> = [
|
||||
{ id: 'glob', label: 'glob', description: 'Match files by glob pattern.' },
|
||||
{ id: 'lsp', label: 'lsp', description: 'Query configured language servers.' },
|
||||
{ id: 'rg', label: 'rg', description: 'Search file contents with ripgrep.' },
|
||||
{ id: 'view', label: 'view', description: 'Read files and list directories.' },
|
||||
{ id: 'web_fetch', label: 'web_fetch', description: 'Fetch content from a URL.' },
|
||||
{ id: 'web_search', label: 'web_search', description: 'Search the web for current information.' },
|
||||
];
|
||||
|
||||
export function createWorkspaceSettings(): WorkspaceSettings {
|
||||
return {
|
||||
theme: 'dark',
|
||||
@@ -114,8 +132,21 @@ export function normalizeSessionToolingSelection(
|
||||
|
||||
export function listApprovalToolDefinitions(
|
||||
tooling: WorkspaceToolingSettings,
|
||||
runtimeTools: ReadonlyArray<RuntimeToolDefinition> = fallbackRuntimeApprovalTools,
|
||||
): ApprovalToolDefinition[] {
|
||||
const toolsById = new Map<string, ApprovalToolDefinition>();
|
||||
const runtimeApprovalTools = runtimeTools.length > 0 ? runtimeTools : fallbackRuntimeApprovalTools;
|
||||
|
||||
for (const tool of runtimeApprovalTools) {
|
||||
registerApprovalTool(toolsById, {
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
kind: 'builtin',
|
||||
providerId: `builtin:${tool.id}`,
|
||||
providerName: 'Built-in',
|
||||
});
|
||||
}
|
||||
|
||||
for (const server of tooling.mcpServers) {
|
||||
for (const toolName of normalizeStringArray(server.tools)) {
|
||||
@@ -146,8 +177,11 @@ export function listApprovalToolDefinitions(
|
||||
left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function listApprovalToolNames(tooling: WorkspaceToolingSettings): string[] {
|
||||
return listApprovalToolDefinitions(tooling).map((tool) => tool.id);
|
||||
export function listApprovalToolNames(
|
||||
tooling: WorkspaceToolingSettings,
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>,
|
||||
): string[] {
|
||||
return listApprovalToolDefinitions(tooling, runtimeTools).map((tool) => tool.id);
|
||||
}
|
||||
|
||||
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
||||
@@ -276,6 +310,7 @@ function registerApprovalTool(
|
||||
tool: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
kind: Exclude<ApprovalToolKind, 'mixed'>;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -286,6 +321,7 @@ function registerApprovalTool(
|
||||
toolsById.set(tool.id, {
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
kind: tool.kind,
|
||||
providerIds: [tool.providerId],
|
||||
providerNames: [tool.providerName],
|
||||
@@ -299,6 +335,9 @@ function registerApprovalTool(
|
||||
if (!existing.providerNames.includes(tool.providerName)) {
|
||||
existing.providerNames.push(tool.providerName);
|
||||
}
|
||||
if (!existing.description && tool.description) {
|
||||
existing.description = tool.description;
|
||||
}
|
||||
if (existing.kind !== tool.kind) {
|
||||
existing.kind = 'mixed';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user