feat: Computer use HITL confirmations in Instance AI (#27910)

This commit is contained in:
Dimitri Lavrenük
2026-04-02 18:49:01 +02:00
committed by GitHub
parent 00014420b1
commit 74e6df3b2e
29 changed files with 975 additions and 48 deletions
@@ -24,4 +24,5 @@ export class InstanceAiConfirmRequestDto extends Z.class({
}),
)
.optional(),
resourceDecision: z.string().optional(),
}) {}
+3
View File
@@ -280,6 +280,8 @@ export {
domainAccessActionSchema,
domainAccessMetaSchema,
credentialFlowSchema,
gatewayConfirmationRequiredPayloadSchema,
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
InstanceAiSendMessageRequest,
instanceAiGatewayKeySchema,
InstanceAiGatewayEventsQuery,
@@ -350,6 +352,7 @@ export type {
DomainAccessAction,
DomainAccessMeta,
InstanceAiCredentialFlow,
GatewayConfirmationRequiredPayload,
ToolCategory,
InstanceAiWorkflowSetupNode,
} from './schemas/instance-ai.schema';
@@ -312,6 +312,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
questions: event.payload.questions,
introMessage: event.payload.introMessage,
tasks: event.payload.tasks,
resourceDecision: event.payload.resourceDecision,
};
}
break;
@@ -258,6 +258,25 @@ export const taskListSchema = z.object({
export type TaskList = z.infer<typeof taskListSchema>;
// ── Gateway resource confirmation (instance permission mode) ─────────────────
/** Protocol prefix used by the daemon to signal a resource-access confirmation is required. */
export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::';
export const gatewayConfirmationRequiredPayloadSchema = z.object({
toolGroup: z.string(),
resource: z.string(),
description: z.string(),
/** Available decision options. */
options: z.array(z.string()),
});
export type GatewayConfirmationRequiredPayload = z.infer<
typeof gatewayConfirmationRequiredPayloadSchema
>;
// ---------------------------------------------------------------------------
export const confirmationRequestPayloadSchema = z.object({
requestId: z.string(),
toolCallId: z.string().describe('Correlates to the tool-call that needs approval'),
@@ -273,11 +292,12 @@ export const confirmationRequestPayloadSchema = z.object({
'Target project ID — used to scope actions (e.g. credential creation) to the correct project',
),
inputType: z
.enum(['approval', 'text', 'questions', 'plan-review'])
.enum(['approval', 'text', 'questions', 'plan-review', 'resource-decision'])
.optional()
.describe(
'UI mode: approval (default) shows approve/deny, text shows a text input, ' +
'questions shows structured Q&A wizard, plan-review shows plan approval with feedback',
'questions shows structured Q&A wizard, plan-review shows plan approval with feedback, ' +
'resource-decision shows 5-option gateway permission dialog',
),
questions: z
.array(
@@ -307,6 +327,9 @@ export const confirmationRequestPayloadSchema = z.object({
.optional()
.describe('Per-node setup cards for workflow credential/parameter configuration'),
workflowId: z.string().optional().describe('Workflow ID for setup-workflow tool'),
resourceDecision: gatewayConfirmationRequiredPayloadSchema
.optional()
.describe('Gateway resource-access decision data (inputType=resource-decision)'),
});
export const statusPayloadSchema = z.object({
@@ -557,6 +580,7 @@ export interface InstanceAiConfirmResponse {
autoSetup?: { credentialType: string };
userInput?: string;
domainAccessAction?: DomainAccessAction;
resourceDecision?: string;
action?: 'apply' | 'test-trigger';
nodeParameters?: Record<string, Record<string, unknown>>;
testTriggerNode?: string;
@@ -586,7 +610,7 @@ export interface InstanceAiToolCallState {
message: string;
credentialRequests?: InstanceAiCredentialRequest[];
projectId?: string;
inputType?: 'approval' | 'text' | 'questions' | 'plan-review';
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision';
domainAccess?: DomainAccessMeta;
credentialFlow?: InstanceAiCredentialFlow;
setupRequests?: InstanceAiWorkflowSetupNode[];
@@ -599,6 +623,7 @@ export interface InstanceAiToolCallState {
}>;
introMessage?: string;
tasks?: TaskList;
resourceDecision?: GatewayConfirmationRequiredPayload;
};
confirmationStatus?: 'pending' | 'approved' | 'denied';
startedAt?: string;
+3
View File
@@ -0,0 +1,3 @@
Why don't programmers like nature?
It has too many bugs.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@n8n/fs-proxy",
"version": "0.1.0-rc2",
"version": "0.1.0-rc3",
"description": "Local AI gateway for n8n Instance AI — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
"bin": {
"n8n-fs-proxy": "dist/cli.js"
+52 -1
View File
@@ -269,12 +269,14 @@ interface UserGatewayState {
## 6. Tool Call Dispatch
### 6.1 Normal tool call (no confirmation required)
When the AI agent needs to invoke a local tool the call flows through
`LocalGateway`:
```mermaid
sequenceDiagram
participant A as AI Agent
participant A as AI Agent (Mastra tool)
participant GW as LocalGateway
participant SRV as Controller (SSE)
participant D as fs-proxy Daemon
@@ -297,6 +299,55 @@ the agent receives a tool-error event.
If the gateway disconnects while requests are pending, `LocalGateway.disconnect()`
rejects all outstanding promises immediately with `"Local gateway disconnected"`.
### 6.2 Tool call with resource-access confirmation
When a tool group operates in `Ask` mode and no stored rule matches the
resource, the daemon returns a `GATEWAY_CONFIRMATION_REQUIRED` error instead
of a result. The Mastra tool layer handles this by suspending the agent —
persisting its state to the database — and resuming it after the user
responds. This means the confirmation survives page reloads and server
restarts.
```mermaid
sequenceDiagram
participant FE as Browser (Frontend)
participant SRV as n8n Server
participant DB as Database
participant D as fs-proxy Daemon
Note over SRV: First invocation — tool execute() called by Mastra
SRV->>D: callTool({ name, args }) via LocalGateway
D-->>SRV: { isError: true, content: ["GATEWAY_CONFIRMATION_REQUIRED::..."] }
SRV->>SRV: parse GatewayConfirmationRequiredPayload
SRV->>DB: suspend() — persist agent snapshot + confirmation payload
SRV-->>FE: SSE confirmation-request event<br/>{ inputType: "resource-decision", resourceDecision: { resource, description, options: [...] } }
FE->>FE: show GatewayResourceDecision panel
Note over FE: User clicks a decision button (e.g. Allow for session)
FE->>SRV: POST /confirm/:requestId { approved: true, resourceDecision: "allowForSession" }
SRV->>DB: load agent snapshot, resume with resumeData
Note over SRV: Second invocation — tool execute() called with resumeData
SRV->>D: callTool({ name, args, _confirmation: "allowForSession" }) via LocalGateway
D->>D: apply decision, execute tool
D-->>SRV: { content: [...], isError: false }
SRV-->>FE: SSE tool-result / text-delta events
```
**Key properties of this design:**
- Agent state is persisted to the database on suspension — the confirmation
dialog survives page reloads and server restarts.
- The daemon returns `options` as a plain list of decision names (e.g.
`["allowOnce", "allowForSession", "alwaysAllow", "denyOnce", "alwaysDeny"]`).
The user's choice is sent back as the decision string directly — no token
indirection.
- `_confirmation` is always stripped from LLM-provided args on the first-call
path, so the agent cannot bypass the HITL flow by injecting a decision.
- If the user denies without providing a decision, `resumeData.resourceDecision`
is absent and the tool returns an access-denied error to the agent
without re-calling the daemon.
---
## 7. Disconnect & Reconnect
+17 -1
View File
@@ -63,6 +63,8 @@ export interface GatewayConfig {
};
/** Startup permission overrides (ENV/CLI). Merged with persistent settings in SettingsStore. */
permissions: Partial<Record<ToolGroup, PermissionMode>>;
/** Where resource access confirmation prompts are displayed. */
permissionConfirmation: 'client' | 'instance';
}
// ---------------------------------------------------------------------------
@@ -111,6 +113,7 @@ const structuralConfigSchema = z.object({
defaultBrowser: z.string().default('chrome'),
})
.default({}),
permissionConfirmation: z.enum(['client', 'instance']).default('instance'),
});
// ---------------------------------------------------------------------------
@@ -177,6 +180,9 @@ function buildEnvConfig(): PartialStructural {
const defaultBrowser = envString('BROWSER_DEFAULT');
if (defaultBrowser) config.browser = { defaultBrowser };
const permissionConfirmation = envString('PERMISSION_CONFIRMATION');
if (permissionConfirmation) config.permissionConfirmation = permissionConfirmation;
return config as PartialStructural;
}
@@ -199,6 +205,9 @@ function buildCliConfig(args: yargsParser.Arguments): PartialStructural {
if (args['browser-default'])
config.browser = { defaultBrowser: args['browser-default'] as string };
if (args['permission-confirmation'])
config.permissionConfirmation = args['permission-confirmation'];
return config as PartialStructural;
}
@@ -273,7 +282,14 @@ export function parseConfig(argv = process.argv.slice(2)): ParsedArgs {
const permissionFlags = Object.values(TOOL_GROUP_DEFINITIONS).map((o) => o.cliFlag);
const args = yargsParser(rawArgs, {
string: ['log-level', 'filesystem-dir', 'browser-default', 'allow-origin', ...permissionFlags],
string: [
'log-level',
'filesystem-dir',
'browser-default',
'allow-origin',
'permission-confirmation',
...permissionFlags,
],
boolean: ['auto-confirm', 'non-interactive', 'help'],
number: ['port', 'computer-shell-timeout'],
alias: { h: 'help', p: 'port' },
+43 -13
View File
@@ -17,12 +17,15 @@ import type { SettingsStore } from './settings-store';
import type { BrowserModule } from './tools/browser';
import { filesystemReadTools, filesystemWriteTools } from './tools/filesystem';
import { ShellModule } from './tools/shell';
import type {
AffectedResource,
CallToolResult,
ConfirmResourceAccess,
McpTool,
ToolDefinition,
import {
type AffectedResource,
type CallToolResult,
type ConfirmResourceAccess,
type McpTool,
type ResourceDecision,
type ToolDefinition,
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
RESOURCE_DECISION_KEYS,
} from './tools/types';
import { formatErrorResult } from './tools/utils';
@@ -395,30 +398,57 @@ export class GatewayClient {
await this.getAllDefinitions();
const def = this.definitionMap.get(name);
if (!def) throw new Error(`Unknown tool: ${name}`);
const typedArgs: unknown = def.inputSchema.parse(args);
// Strip _confirmation from args before schema validation — the agent must
// never be able to inject a decision directly into tool arguments.
const { _confirmation, ...cleanArgs } = args;
const decision =
typeof _confirmation === 'string' ? (_confirmation as ResourceDecision) : undefined;
const typedArgs: unknown = def.inputSchema.parse(cleanArgs);
const context = { dir: this.dir };
const resources = await def.getAffectedResources(typedArgs, context);
await this.checkPermissions(resources);
await this.checkPermissions(resources, decision);
return await def.execute(typedArgs, context);
}
private async checkPermissions(resources: AffectedResource[]): Promise<void> {
const { settingsStore, confirmResourceAccess } = this.options;
private async checkPermissions(
resources: AffectedResource[],
decision?: ResourceDecision,
): Promise<void> {
const { settingsStore, confirmResourceAccess, config } = this.options;
for (const resource of resources) {
const rule = settingsStore.check(resource.toolGroup, resource.resource);
if (rule === 'deny') {
throw new Error(`User denied access to ${resource.toolGroup}: ${resource.resource}`);
throw new Error(
`User permanently denied access to ${resource.toolGroup}: ${resource.resource}`,
);
}
if (rule === 'allow') continue;
const decision = await confirmResourceAccess(resource);
let resolvedDecision: ResourceDecision;
switch (decision) {
if (decision) {
resolvedDecision = decision;
} else if (config.permissionConfirmation === 'instance') {
throw new Error(
`${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify({
toolGroup: resource.toolGroup,
resource: resource.resource,
description: resource.description,
options: RESOURCE_DECISION_KEYS,
})}`,
);
} else {
resolvedDecision = await confirmResourceAccess(resource);
}
switch (resolvedDecision) {
case 'allowOnce':
break;
case 'allowForSession':
@@ -11,6 +11,7 @@ const BASE_CONFIG: GatewayConfig = {
defaultBrowser: 'chrome',
},
permissions: {},
permissionConfirmation: 'instance',
};
/** Find the message logged for a specific module by inspecting the meta argument. */
@@ -11,6 +11,7 @@ const BASE_CONFIG: GatewayConfig = {
defaultBrowser: 'chrome',
},
permissions: {},
permissionConfirmation: 'instance',
};
describe('resolveTemplateName', () => {
+12
View File
@@ -47,6 +47,18 @@ export type ResourceDecision =
| 'denyOnce'
| 'alwaysDeny';
/** Ordered list of all ResourceDecision values — used for iteration (e.g. token generation). */
export const RESOURCE_DECISION_KEYS: ResourceDecision[] = [
'allowOnce',
'allowForSession',
'alwaysAllow',
'denyOnce',
'alwaysDeny',
];
/** Prefix used to signal a gateway confirmation is required (instance mode). */
export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::';
export type ConfirmResourceAccess = (
resource: AffectedResource,
) => ResourceDecision | Promise<ResourceDecision>;
@@ -37,6 +37,8 @@ export interface ConfirmationData {
customText?: string;
skipped?: boolean;
}>;
/** User's resource-access decision (e.g. 'allowForSession'). */
resourceDecision?: string;
}
export interface PendingConfirmation {
@@ -1,9 +1,15 @@
import { credentialRequestSchema, workflowSetupNodeSchema, taskListSchema } from '@n8n/api-types';
import {
credentialRequestSchema,
workflowSetupNodeSchema,
taskListSchema,
gatewayConfirmationRequiredPayloadSchema,
} from '@n8n/api-types';
import type {
InstanceAiCredentialRequest,
InstanceAiEvent,
InstanceAiWorkflowSetupNode,
TaskList,
GatewayConfirmationRequiredPayload,
} from '@n8n/api-types';
import { z } from 'zod';
@@ -183,10 +189,16 @@ export function mapMastraChunkToEvent(
const projectId =
typeof suspendPayload.projectId === 'string' ? suspendPayload.projectId : undefined;
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review')
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision')
const rawInputType =
typeof suspendPayload.inputType === 'string' ? suspendPayload.inputType : undefined;
const validInputTypes = ['approval', 'text', 'questions', 'plan-review'] as const;
const validInputTypes = [
'approval',
'text',
'questions',
'plan-review',
'resource-decision',
] as const;
const inputType = (validInputTypes as readonly string[]).includes(rawInputType ?? '')
? (rawInputType as (typeof validInputTypes)[number])
: undefined;
@@ -257,6 +269,17 @@ export function mapMastraChunkToEvent(
const workflowId =
typeof suspendPayload.workflowId === 'string' ? suspendPayload.workflowId : undefined;
// Extract optional resourceDecision for gateway permission gating (inputType=resource-decision)
let resourceDecision: GatewayConfirmationRequiredPayload | undefined;
if (isRecord(suspendPayload.resourceDecision)) {
const parsed = gatewayConfirmationRequiredPayloadSchema.safeParse(
suspendPayload.resourceDecision,
);
if (parsed.success) {
resourceDecision = parsed.data;
}
}
return {
type: 'confirmation-request',
runId,
@@ -281,6 +304,7 @@ export function mapMastraChunkToEvent(
...(questions ? { questions } : {}),
...(introMessage ? { introMessage } : {}),
...(tasks ? { tasks } : {}),
...(resourceDecision ? { resourceDecision } : {}),
},
};
}
@@ -0,0 +1,230 @@
import { GATEWAY_CONFIRMATION_REQUIRED_PREFIX } from '@n8n/api-types';
import type { McpTool, McpToolCallResult } from '@n8n/api-types';
import type { LocalMcpServer } from '../../../types';
import { createToolsFromLocalMcpServer } from '../create-tools-from-mcp-server';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SAMPLE_TOOL: McpTool = {
name: 'write_file',
description: 'Write a file',
inputSchema: { type: 'object', properties: { filePath: { type: 'string' } } },
};
const CONFIRMATION_PAYLOAD = {
toolGroup: 'filesystemWrite',
resource: 'write_file',
description: 'Write to file: test.ts',
options: ['allowOnce', 'allowForSession', 'alwaysAllow', 'denyOnce', 'alwaysDeny'],
};
const PLAIN_CONFIRMATION_ERROR: McpToolCallResult = {
content: [
{
type: 'text',
text: `${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify(CONFIRMATION_PAYLOAD)}`,
},
],
isError: true,
};
const JSON_ENVELOPE_CONFIRMATION_ERROR: McpToolCallResult = {
content: [
{
type: 'text',
text: JSON.stringify({
error: `${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify(CONFIRMATION_PAYLOAD)}`,
}),
},
],
isError: true,
};
const SUCCESS_RESULT: McpToolCallResult = {
content: [{ type: 'text', text: 'file written' }],
};
const GENERIC_ERROR_RESULT: McpToolCallResult = {
content: [{ type: 'text', text: 'Permission denied' }],
isError: true,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): jest.Mocked<LocalMcpServer> {
return {
getAvailableTools: jest.fn().mockReturnValue(tools),
getToolsByCategory: jest.fn().mockReturnValue([]),
callTool: jest.fn(),
};
}
/** Build the tool and return its execute function. */
function getExecute(server: LocalMcpServer, toolName = 'write_file') {
const tools = createToolsFromLocalMcpServer(server);
const tool = tools[toolName];
if (!tool?.execute) throw new Error(`Tool '${toolName}' has no execute function`);
return tool.execute.bind(tool) as (
args: Record<string, unknown>,
ctx: unknown,
) => Promise<McpToolCallResult>;
}
/** Build a ctx object with suspend/resumeData for use in execute calls. */
function makeCtx(opts: {
suspend?: jest.Mock;
resumeData?: Record<string, unknown> | null;
}): unknown {
return { agent: { suspend: opts.suspend ?? jest.fn(), resumeData: opts.resumeData ?? null } };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('createToolsFromLocalMcpServer', () => {
describe('tool creation', () => {
it('creates a tool for each advertised tool', () => {
const server = makeMockServer([SAMPLE_TOOL, { ...SAMPLE_TOOL, name: 'read_file' }]);
const tools = createToolsFromLocalMcpServer(server);
expect(Object.keys(tools)).toContain('write_file');
expect(Object.keys(tools)).toContain('read_file');
});
it('falls back to record schema when inputSchema conversion fails', () => {
const server = makeMockServer([
{
name: 'bad_tool',
description: 'Bad schema',
inputSchema: { $ref: '#/broken' } as unknown as McpTool['inputSchema'],
},
]);
// Should not throw — the tool must be created even with a bad schema
expect(() => createToolsFromLocalMcpServer(server)).not.toThrow();
expect(createToolsFromLocalMcpServer(server)['bad_tool']).toBeDefined();
});
});
describe('execute — first-call path', () => {
it('passes through a successful result', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(SUCCESS_RESULT);
const execute = getExecute(server);
const result = await execute({ filePath: 'test.ts' }, makeCtx({}));
expect(result).toEqual(SUCCESS_RESULT);
expect(server.callTool).toHaveBeenCalledWith({
name: 'write_file',
arguments: { filePath: 'test.ts' },
});
});
it('strips _confirmation from LLM-provided args on the first-call path', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(SUCCESS_RESULT);
const execute = getExecute(server);
await execute({ filePath: 'test.ts', _confirmation: 'injected-token' }, makeCtx({}));
expect(server.callTool).toHaveBeenCalledWith({
name: 'write_file',
arguments: { filePath: 'test.ts' },
});
});
it('passes through a generic error result unchanged', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(GENERIC_ERROR_RESULT);
const suspend = jest.fn();
const execute = getExecute(server);
const result = await execute({}, makeCtx({ suspend }));
expect(result).toEqual(GENERIC_ERROR_RESULT);
expect(suspend).not.toHaveBeenCalled();
});
it('calls suspend() for a plain-text GATEWAY_CONFIRMATION_REQUIRED error', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR);
const suspend = jest.fn().mockResolvedValue(undefined);
const execute = getExecute(server);
await execute({ filePath: 'test.ts' }, makeCtx({ suspend }));
expect(suspend).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(suspend.mock.calls[0][0]).toMatchObject({
inputType: 'resource-decision',
severity: 'warning',
resourceDecision: CONFIRMATION_PAYLOAD,
message: expect.stringContaining('write_file') as string,
requestId: expect.any(String) as string,
});
});
it('calls suspend() for a JSON-envelope GATEWAY_CONFIRMATION_REQUIRED error', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(JSON_ENVELOPE_CONFIRMATION_ERROR);
const suspend = jest.fn().mockResolvedValue(undefined);
const execute = getExecute(server);
await execute({}, makeCtx({ suspend }));
expect(suspend).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(suspend.mock.calls[0][0]).toMatchObject({
inputType: 'resource-decision',
resourceDecision: CONFIRMATION_PAYLOAD,
});
});
it('does NOT call suspend() when ctx.agent is absent', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR);
const execute = getExecute(server);
// ctx without agent — suspend is unavailable
const result = await execute({}, {});
// Returns the raw error result unchanged
expect(result).toEqual(PLAIN_CONFIRMATION_ERROR);
});
});
describe('execute — resume path', () => {
it('re-calls the daemon with _confirmation decision when decision is present', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(SUCCESS_RESULT);
const execute = getExecute(server);
const result = await execute(
{ filePath: 'test.ts' },
makeCtx({ resumeData: { approved: true, resourceDecision: 'allowForSession' } }),
);
expect(result).toEqual(SUCCESS_RESULT);
expect(server.callTool).toHaveBeenCalledWith({
name: 'write_file',
arguments: { filePath: 'test.ts', _confirmation: 'allowForSession' },
});
});
it('returns access-denied error when resumeData has no token (user denied)', async () => {
const server = makeMockServer();
const execute = getExecute(server);
const result = await execute({}, makeCtx({ resumeData: { approved: false } }));
expect(result.isError).toBe(true);
expect((result.content[0] as { type: string; text: string }).text).toContain('denied');
expect(server.callTool).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,13 @@
import type { ToolsInput } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import {
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
gatewayConfirmationRequiredPayloadSchema,
instanceAiConfirmationSeveritySchema,
type GatewayConfirmationRequiredPayload,
type McpToolCallResult,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { convertJsonSchemaToZod } from 'zod-from-json-schema-v3';
import type { JSONSchema } from 'zod-from-json-schema-v3';
@@ -7,6 +15,68 @@ import type { JSONSchema } from 'zod-from-json-schema-v3';
import { sanitizeMcpToolSchemas } from '../../agent/sanitize-mcp-schemas';
import type { LocalMcpServer } from '../../types';
// ---------------------------------------------------------------------------
// Schemas shared across all gateway-gated tools
// ---------------------------------------------------------------------------
const gatewayConfirmationSuspendSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
inputType: z.literal('resource-decision'),
resourceDecision: gatewayConfirmationRequiredPayloadSchema,
});
const gatewayConfirmationResumeSchema = z.object({
approved: z.boolean(),
resourceDecision: z.string().optional(),
});
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function tryParseGatewayConfirmationRequired(
result: McpToolCallResult,
): GatewayConfirmationRequiredPayload | null {
if (!result.isError) return null;
const raw = result.content
.filter((c) => c.type === 'text')
.map((c) => c.text ?? '')
.join('');
// Unwrap JSON envelope `{"error":"GATEWAY_CONFIRMATION_REQUIRED::..."}` if present
let candidate = raw;
try {
const parsed = JSON.parse(raw) as unknown;
if (
typeof parsed === 'object' &&
parsed !== null &&
'error' in parsed &&
typeof (parsed as Record<string, unknown>).error === 'string'
) {
candidate = (parsed as Record<string, unknown>).error as string;
}
} catch {
// Not JSON — use raw text as-is
}
if (!candidate.startsWith(GATEWAY_CONFIRMATION_REQUIRED_PREFIX)) return null;
try {
const json = JSON.parse(
candidate.slice(GATEWAY_CONFIRMATION_REQUIRED_PREFIX.length),
) as unknown;
const parsed = gatewayConfirmationRequiredPayloadSchema.safeParse(json);
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Build Mastra tools dynamically from the MCP tools advertised by a connected
* local MCP server (e.g. the fs-proxy daemon).
@@ -15,9 +85,14 @@ import type { LocalMcpServer } from '../../types';
* to a Zod schema so the LLM receives accurate parameter information. Falls back
* to `z.record(z.unknown())` if conversion fails for a particular tool.
*
* The execute function forwards the call via `server.callTool()` and returns
* the full MCP result. A `toModelOutput` callback converts MCP content blocks
* (text and image) into the AI SDK's multimodal format so the LLM receives images.
* When the daemon responds with `GATEWAY_CONFIRMATION_REQUIRED`, the tool
* suspends the agent via Mastra's native `suspend()` mechanism. This persists
* the confirmation request to the database, so it survives page reloads and
* server restarts. On resume, the tool re-calls the daemon with the selected
* decision token.
*
* The `toModelOutput` callback converts MCP content blocks (text and image)
* into the AI SDK's multimodal format so the LLM receives images.
*/
export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInput {
const tools: ToolsInput = {};
@@ -38,15 +113,52 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInpu
inputSchema = z.record(z.unknown());
}
tools[toolName] = createTool({
const tool = createTool({
id: toolName,
description,
inputSchema,
execute: async (args: Record<string, unknown>) => {
const result = await server.callTool({
name: toolName,
arguments: args,
});
suspendSchema: gatewayConfirmationSuspendSchema,
resumeSchema: gatewayConfirmationResumeSchema,
execute: async (args: Record<string, unknown>, ctx) => {
const { resumeData, suspend } = ctx?.agent ?? {};
// Resume path: user has made a resource-access decision
if (resumeData !== undefined && resumeData !== null) {
if (!resumeData.resourceDecision) {
// User denied — no decision provided
return {
content: [{ type: 'text', text: JSON.stringify({ error: 'Access denied by user' }) }],
isError: true,
};
}
// Re-call the daemon with the user's decision
return await server.callTool({
name: toolName,
arguments: { ...args, _confirmation: resumeData.resourceDecision },
});
}
// First-call path: strip any LLM-provided _confirmation key so the agent
// cannot bypass the human confirmation flow by supplying its own token.
const { _confirmation: _stripped, ...safeArgs } = args;
const result = await server.callTool({ name: toolName, arguments: safeArgs });
// If the daemon requires a resource-access confirmation, suspend the agent
if (result.isError && suspend) {
const payload = tryParseGatewayConfirmationRequired(result);
if (payload) {
await suspend({
requestId: nanoid(),
message: `${toolName}: ${payload.description}`,
severity: 'warning',
inputType: 'resource-decision',
resourceDecision: payload,
});
// suspend() never resolves — this line is unreachable but satisfies the type checker
return result;
}
}
return result;
},
toModelOutput: (result: unknown) => {
@@ -89,6 +201,8 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInpu
return { type: 'content', value };
},
});
tools[toolName] = tool;
}
return sanitizeMcpToolSchemas(tools);
+1
View File
@@ -834,6 +834,7 @@ export interface OrchestrationContext {
autoSetup?: { credentialType: string };
userInput?: string;
domainAccessAction?: string;
resourceDecision?: string;
answers?: Array<{
questionId: string;
selectedOptions: string[];
@@ -86,6 +86,7 @@ export class SettingsStore {
computer: s.screenshotEnabled || s.mouseKeyboardEnabled ? 'ask' : 'deny',
browser: s.browserEnabled ? 'ask' : 'deny',
},
permissionConfirmation: 'instance',
};
}
@@ -345,6 +345,22 @@ describe('InstanceAiController', () => {
);
});
it('should pass resourceDecision through to resolveConfirmation', async () => {
instanceAiService.resolveConfirmation.mockResolvedValue(true);
const body = mock<InstanceAiConfirmRequestDto>({
approved: true,
resourceDecision: 'allowOnce',
});
await controller.confirm(req, res, 'req-1', body);
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(
USER_ID,
'req-1',
expect.objectContaining({ resourceDecision: 'allowOnce' }),
);
});
it('should throw NotFoundError when confirmation not found', async () => {
instanceAiService.resolveConfirmation.mockResolvedValue(false);
const body = mock<InstanceAiConfirmRequestDto>({ approved: false });
@@ -123,7 +123,10 @@ describe('LocalGateway', () => {
isError: true,
});
await expect(callPromise).rejects.toThrow('File too large');
// isError results are passed through so the tool layer can inspect them
const result = await callPromise;
expect(result.isError).toBe(true);
expect((result.content[0] as { type: 'text'; text: string }).text).toBe('File too large');
});
it('should throw when gateway is not connected', async () => {
@@ -176,6 +179,38 @@ describe('LocalGateway', () => {
});
});
describe('resolveRequest with isError results', () => {
const CONFIRMATION_PAYLOAD = {
toolGroup: 'filesystemWrite',
resource: 'write_file',
description: 'Write to file: test.ts',
options: { allowOnce: 'token-allow-once', denyOnce: 'token-deny-once' },
};
const rawErrorText = `GATEWAY_CONFIRMATION_REQUIRED::${JSON.stringify(CONFIRMATION_PAYLOAD)}`;
it('should resolve with isError result so the tool layer can inspect it', async () => {
gateway.init(EMPTY_CAPABILITIES);
const requestEvents: LocalGatewayEvent[] = [];
gateway.onRequest((e) => requestEvents.push(e));
const callPromise = gateway.callTool({
name: 'write_file',
arguments: { filePath: 'test.ts' },
});
const errorResult = {
content: [{ type: 'text' as const, text: rawErrorText }],
isError: true as const,
};
gateway.resolveRequest(requestEvents[0].payload.requestId, errorResult);
const result = await callPromise;
expect(result.isError).toBe(true);
expect((result.content[0] as { type: 'text'; text: string }).text).toBe(rawErrorText);
});
});
describe('getStatus', () => {
it('should return disconnected status by default', () => {
const status = gateway.getStatus();
@@ -16,6 +16,7 @@ interface PendingRequest {
resolve: (result: McpToolCallResult) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
toolCall: McpToolCallRequest;
}
export interface LocalGatewayEvent {
@@ -39,6 +40,9 @@ export interface LocalGatewayEvent {
* 3. callTool() → emits filesystem-request via SSE
* 4. Client executes locally, POSTs MCP result to /instance-ai/gateway/response/:requestId
* 5. resolveRequest() resolves the pending promise → caller gets McpToolCallResult
*
* Resource-access confirmations (GATEWAY_CONFIRMATION_REQUIRED) are handled at the
* tool layer via Mastra's suspend()/resumeData mechanism — not here.
*/
export class LocalGateway {
private readonly pendingRequests = new Map<string, PendingRequest>();
@@ -105,18 +109,13 @@ export class LocalGateway {
if (error) {
pending.reject(new Error(error));
} else if (result?.isError === true) {
pending.reject(
new Error(
result.content
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map((c) => c.text)
.join('\n'),
),
);
} else {
pending.resolve(result ?? { content: [] });
return true;
}
// Resolve with the result as-is (including isError responses) so the tool
// layer (create-tools-from-mcp-server.ts) can inspect GATEWAY_CONFIRMATION_REQUIRED
// errors and handle them via Mastra suspend().
pending.resolve(result ?? { content: [] });
return true;
}
@@ -170,7 +169,7 @@ export class LocalGateway {
reject(new Error(`Local gateway request timed out after ${REQUEST_TIMEOUT_MS}ms`));
}, REQUEST_TIMEOUT_MS);
this.pendingRequests.set(requestId, { resolve, reject, timer });
this.pendingRequests.set(requestId, { resolve, reject, timer, toolCall });
this.emitter.emit('filesystem-request', {
type: 'filesystem-request',
@@ -270,6 +270,7 @@ export class InstanceAiController {
nodeParameters: body.nodeParameters,
testTriggerNode: body.testTriggerNode,
answers: body.answers,
resourceDecision: body.resourceDecision,
});
if (!resolved) {
throw new NotFoundError('Confirmation request not found or not authorized');
@@ -119,7 +119,6 @@ export class InstanceAiService {
string,
{ threadId: string; messageGroupId?: string; tracing: InstanceAiTraceContext }
>();
/** Active sandboxes keyed by thread ID — persisted across messages within a conversation. */
private readonly sandboxes = new Map<
string,
@@ -1816,6 +1815,7 @@ export class InstanceAiService {
...(data.nodeParameters ? { nodeParameters: data.nodeParameters } : {}),
...(data.testTriggerNode ? { testTriggerNode: data.testTriggerNode } : {}),
...(data.answers ? { answers: data.answers } : {}),
...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}),
};
void this.processResumedStream(agent, resumeData, {
@@ -5032,6 +5032,12 @@
"instanceAi.confirmation.pendingInline": "Waiting for approval",
"instanceAi.confirmation.agentContext": "{agent} needs approval",
"instanceAi.confirmation.approveAll": "Approve all",
"instanceAi.gatewayConfirmation.allowForSession": "Allow for session",
"instanceAi.gatewayConfirmation.allowOnce": "Allow once",
"instanceAi.gatewayConfirmation.alwaysAllow": "Always allow",
"instanceAi.gatewayConfirmation.alwaysDeny": "Always deny",
"instanceAi.gatewayConfirmation.denyOnce": "Deny once",
"instanceAi.gatewayConfirmation.prompt": "n8n AI wants to access '{resources}' on Computer Use",
"instanceAi.askUser.placeholder": "Type your answer...",
"instanceAi.askUser.submit": "Submit",
"instanceAi.askUser.skip": "Skip",
@@ -1,7 +1,7 @@
import { setActivePinia, createPinia } from 'pinia';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, test, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchThreadMessages, fetchThreadStatus } from '../instanceAi.memory.api';
import { ensureThread, postMessage } from '../instanceAi.api';
import { ensureThread, postMessage, postConfirmation } from '../instanceAi.api';
import { useInstanceAiStore } from '../instanceAi.store';
// ---------------------------------------------------------------------------
@@ -134,6 +134,7 @@ const mockFetchThreadMessages = vi.mocked(fetchThreadMessages);
const mockFetchThreadStatus = vi.mocked(fetchThreadStatus);
const mockEnsureThread = vi.mocked(ensureThread);
const mockPostMessage = vi.mocked(postMessage);
const mockPostConfirmation = vi.mocked(postConfirmation);
describe('useInstanceAiStore - onSSEMessage', () => {
let store: ReturnType<typeof useInstanceAiStore>;
@@ -597,3 +598,85 @@ describe('useInstanceAiStore - feedback integration', () => {
});
});
});
// ---------------------------------------------------------------------------
// confirmResourceDecision / confirmAction (resource-decision token)
// ---------------------------------------------------------------------------
describe('useInstanceAiStore - gateway resource-decision confirmation', () => {
let store: ReturnType<typeof useInstanceAiStore>;
beforeEach(async () => {
setActivePinia(createPinia());
capturedOnMessage = null;
store = useInstanceAiStore();
store.newThread();
await vi.waitFor(() => {
expect(capturedOnMessage).not.toBeNull();
});
mockPostConfirmation.mockResolvedValue(undefined);
});
afterEach(() => {
store.closeSSE();
vi.clearAllMocks();
});
it('confirmAction passes resourceDecision to postConfirmation', async () => {
await store.confirmAction(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'allowOnce',
);
expect(mockPostConfirmation).toHaveBeenCalledOnce();
expect(mockPostConfirmation).toHaveBeenCalledWith(
expect.anything(),
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'allowOnce',
);
});
it('confirmResourceDecision calls postConfirmation with approved=true and the decision', async () => {
await store.confirmResourceDecision('req-2', 'allowForSession');
expect(mockPostConfirmation).toHaveBeenCalledOnce();
expect(mockPostConfirmation).toHaveBeenCalledWith(
expect.anything(),
'req-2',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'allowForSession',
);
});
it('confirmResourceDecision does not call postConfirmation when confirmAction throws', async () => {
mockPostConfirmation.mockRejectedValueOnce(new Error('network error'));
await store.confirmResourceDecision('req-3', 'denyOnce');
// postConfirmation was called once (inside confirmAction) but threw
expect(mockPostConfirmation).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,235 @@
<script lang="ts" setup>
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
const props = defineProps<{
requestId: string;
resource: string;
description: string;
options: string[];
}>();
const i18n = useI18n();
const store = useInstanceAiStore();
interface OptionEntry {
decision: string;
label: string;
}
const DECISION_LABELS: Record<string, string> = {
allowOnce: i18n.baseText('instanceAi.gatewayConfirmation.allowOnce'),
allowForSession: i18n.baseText('instanceAi.gatewayConfirmation.allowForSession'),
alwaysAllow: i18n.baseText('instanceAi.gatewayConfirmation.alwaysAllow'),
denyOnce: i18n.baseText('instanceAi.gatewayConfirmation.denyOnce'),
alwaysDeny: i18n.baseText('instanceAi.gatewayConfirmation.alwaysDeny'),
};
const KNOWN_DECISIONS = new Set(Object.keys(DECISION_LABELS));
function getDecisionLabel(decision: string): string {
return DECISION_LABELS[decision] ?? decision;
}
function optionEntry(decision: string): OptionEntry {
return { decision, label: getDecisionLabel(decision) };
}
const denyPrimary = computed(() =>
props.options.includes('denyOnce') ? optionEntry('denyOnce') : undefined,
);
const denyDropdownItems = computed(() => {
const items: Array<ActionDropdownItem<string>> = [];
if (props.options.includes('alwaysDeny'))
items.push({ id: 'alwaysDeny', label: getDecisionLabel('alwaysDeny') });
return items;
});
const approvePrimary = computed(() =>
props.options.includes('allowForSession') ? optionEntry('allowForSession') : undefined,
);
const approveDropdownItems = computed(() => {
const items: Array<ActionDropdownItem<string>> = [];
if (props.options.includes('allowOnce'))
items.push({ id: 'allowOnce', label: getDecisionLabel('allowOnce') });
if (props.options.includes('alwaysAllow'))
items.push({ id: 'alwaysAllow', label: getDecisionLabel('alwaysAllow') });
return items;
});
const otherOptions = computed<OptionEntry[]>(() =>
props.options.filter((d) => !KNOWN_DECISIONS.has(d)).map(optionEntry),
);
async function confirm(decision: string) {
await store.confirmResourceDecision(props.requestId, decision);
}
</script>
<template>
<div :class="$style.root">
<div :class="$style.body">
<div :class="$style.message">
{{
i18n.baseText('instanceAi.gatewayConfirmation.prompt', {
interpolate: { resources: props.resource },
})
}}
</div>
<div :class="$style.preview">{{ props.description }}</div>
</div>
<div :class="$style.actions">
<!-- Unknown options not in the standard set -->
<N8nButton
v-for="opt in otherOptions"
:key="opt.decision"
variant="outline"
size="small"
:label="opt.label"
@click="confirm(opt.decision)"
/>
<!-- Deny side -->
<template v-if="denyPrimary">
<div v-if="denyDropdownItems.length" :class="$style.splitButton">
<N8nButton
variant="outline"
size="small"
:label="denyPrimary.label"
:class="$style.splitButtonMain"
data-test-id="gateway-decision-deny"
@click="confirm(denyPrimary.decision)"
/>
<N8nActionDropdown
:items="denyDropdownItems"
:class="$style.splitButtonDropdown"
placement="bottom-start"
@select="confirm"
>
<template #activator>
<N8nIconButton
variant="outline"
icon="chevron-down"
:class="$style.splitButtonCaret"
aria-label="More deny options"
size="small"
/>
</template>
</N8nActionDropdown>
</div>
<N8nButton
v-else
variant="outline"
size="small"
:label="denyPrimary.label"
data-test-id="gateway-decision-deny"
@click="confirm(denyPrimary.decision)"
/>
</template>
<!-- Approve side -->
<template v-if="approvePrimary">
<div v-if="approveDropdownItems.length" :class="$style.splitButton">
<N8nButton
variant="solid"
size="small"
:label="approvePrimary.label"
:class="$style.splitButtonMain"
data-test-id="gateway-decision-approve"
@click="confirm(approvePrimary.decision)"
/>
<N8nActionDropdown
:items="approveDropdownItems"
:class="$style.splitButtonDropdown"
placement="bottom-start"
@select="confirm"
>
<template #activator>
<N8nIconButton
variant="solid"
icon="chevron-down"
:class="$style.splitButtonCaret"
aria-label="More approve options"
size="small"
/>
</template>
</N8nActionDropdown>
</div>
<N8nButton
v-else
variant="solid"
size="small"
:label="approvePrimary.label"
data-test-id="gateway-decision-approve"
@click="confirm(approvePrimary.decision)"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.root {
border: var(--border);
border-radius: var(--radius--lg);
}
.body {
padding: var(--spacing--sm);
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.message {
font-size: var(--font-size--2xs);
color: var(--color--text);
font-weight: var(--font-weight--medium);
}
.preview {
font-family: monospace;
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
word-break: break-all;
padding: var(--spacing--2xs);
background: var(--color--background);
border-radius: var(--radius);
border: var(--border);
}
.actions {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.splitButton {
display: flex;
position: relative;
}
.splitButtonMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.splitButtonDropdown {
display: flex;
}
.splitButtonCaret {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid var(--color--foreground--tint-2);
}
</style>
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue';
import { useInstanceAiStore, type PendingConfirmationItem } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels';
import DomainAccessApproval from './DomainAccessApproval.vue';
import GatewayResourceDecision from './GatewayResourceDecision.vue';
import InstanceAiCredentialSetup from './InstanceAiCredentialSetup.vue';
import type { QuestionAnswer } from './InstanceAiQuestions.vue';
import InstanceAiQuestions from './InstanceAiQuestions.vue';
@@ -137,7 +138,7 @@ function handlePlanRequestChanges(requestId: string, feedback: string) {
void store.confirmAction(requestId, false, undefined, undefined, undefined, feedback);
}
/** True when every item in the approval-wrapped group is a generic approval (not domain access). */
/** True when every item in the group is a generic approval (not domain/cred/text). */
function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
return items.every((item) => !item.toolCall.confirmation!.domainAccess);
}
@@ -248,6 +249,20 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
</div>
</div>
</div>
<!-- Resource-access decision (gateway permission mode) -->
<GatewayResourceDecision
v-else-if="
chunk.item.toolCall.confirmation!.inputType === 'resource-decision' &&
chunk.item.toolCall.confirmation!.resourceDecision
"
:key="'rd-' + chunk.item.toolCall.confirmation!.requestId"
:class="$style.confirmation"
data-test-id="instance-ai-gateway-confirmation-panel"
:request-id="chunk.item.toolCall.confirmation!.requestId"
:resource="chunk.item.toolCall.confirmation!.resourceDecision.resource"
:description="chunk.item.toolCall.confirmation!.resourceDecision.description"
:options="chunk.item.toolCall.confirmation!.resourceDecision.options"
/>
</template>
<!-- ============ Approval-wrapped group ============ -->
@@ -88,6 +88,7 @@ export async function postConfirmation(
testTriggerNode?: string;
},
answers?: InstanceAiConfirmResponse['answers'],
resourceDecision?: string,
): Promise<void> {
const payload: InstanceAiConfirmResponse = {
approved,
@@ -111,6 +112,7 @@ export async function postConfirmation(
? { testTriggerNode: setupWorkflowData.testTriggerNode }
: {}),
...(answers ? { answers } : {}),
...(resourceDecision ? { resourceDecision } : {}),
};
await makeRestApiRequest(context, 'POST', `/instance-ai/confirm/${requestId}`, payload);
}
@@ -786,6 +786,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
testTriggerNode?: string;
},
answers?: InstanceAiConfirmResponse['answers'],
resourceDecision?: string,
): Promise<boolean> {
try {
await postConfirmation(
@@ -799,6 +800,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
domainAccessAction,
setupWorkflowData,
answers,
resourceDecision,
);
return true;
} catch {
@@ -807,6 +809,22 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
}
}
async function confirmResourceDecision(requestId: string, decision: string): Promise<void> {
resolveConfirmation(requestId, 'approved');
await confirmAction(
requestId,
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
decision,
);
}
function toggleResearchMode(): void {
researchMode.value = !researchMode.value;
localStorage.setItem('instanceAi.researchMode', String(researchMode.value));
@@ -956,6 +974,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
amendAgent,
toggleResearchMode,
confirmAction,
confirmResourceDecision,
resolveConfirmation,
findToolCallByRequestId,
copyFullTrace,