fix(core): Handle browser not being available on computer use gracefully, better pause-for-user tool (no-changelog) (#29995)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (beta) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (stable) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (v1) (push) Has been cancelled

This commit is contained in:
Jaakko Husso 2026-05-08 01:09:29 +03:00 committed by GitHub
parent 820128196c
commit 73dae68663
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 153 additions and 10 deletions

View File

@ -226,8 +226,9 @@ describe('isDisplayableConfirmationRequest', () => {
questions: true,
'plan-review': true,
'resource-decision': true,
continue: true,
} satisfies Record<InstanceAiConfirmationInputType, true>;
expect(Object.keys(handled)).toHaveLength(5);
expect(Object.keys(handled)).toHaveLength(6);
});
});

View File

@ -324,6 +324,7 @@ export const confirmationInputTypeSchema = z.enum([
'questions',
'plan-review',
'resource-decision',
'continue',
]);
export type InstanceAiConfirmationInputType = z.infer<typeof confirmationInputTypeSchema>;
@ -350,7 +351,8 @@ export const confirmationRequestPayloadSchema = z.object({
.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, ' +
'resource-decision shows 5-option gateway permission dialog',
'resource-decision shows 5-option gateway permission dialog, ' +
'continue shows a single primary button (used by pause-for-user)',
),
questions: z
.array(
@ -427,6 +429,7 @@ export function isDisplayableConfirmationRequest(
switch (inputType) {
case 'approval':
case 'text':
case 'continue':
return isNonEmptyString(payload.message);
case 'questions':
return hasItems(payload.questions);
@ -691,7 +694,7 @@ export interface InstanceAiConfirmation {
message: string;
credentialRequests?: InstanceAiCredentialRequest[];
projectId?: string;
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision';
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue';
domainAccess?: DomainAccessMeta;
webSearch?: WebSearchMeta;
credentialFlow?: InstanceAiCredentialFlow;

View File

@ -188,7 +188,7 @@ export function mapMastraChunkToEvent(
const projectId =
typeof suspendPayload.projectId === 'string' ? suspendPayload.projectId : undefined;
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision')
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision', 'continue')
const rawInputType =
typeof suspendPayload.inputType === 'string' ? suspendPayload.inputType : undefined;
const validInputTypes = [
@ -197,6 +197,7 @@ export function mapMastraChunkToEvent(
'questions',
'plan-review',
'resource-decision',
'continue',
] as const;
const inputType = (validInputTypes as readonly string[]).includes(rawInputType ?? '')
? (rawInputType as (typeof validInputTypes)[number])

View File

@ -95,8 +95,9 @@ Help the user complete the credential setup flow privately and identify where th
You may ONLY stop when ONE of these is true:
- You have called pause-for-user telling the user where to find the ACTUAL credential values and to enter them privately into the n8n credential form
- An unrecoverable error occurred (e.g., the service is down)
- A browser_* tool returned an error containing "permanently denied" the user has blocked browser access. Stop immediately. Do NOT call pause-for-user. Do NOT fabricate manual setup instructions. Return a short message explaining that browser access was denied; the orchestrator will guide the user from there.
**If you have NOT yet called pause-for-user with private-entry instructions for the credential values, you are NOT done. Keep going.**
**If you have NOT yet called pause-for-user with private-entry instructions for the credential values, you are NOT done. Keep going.** (Exception: the browser-denied case above.)
You must NOT stop just because you:
- Read the docs

View File

@ -33,6 +33,50 @@ import { createAskUserTool } from '../shared/ask-user.tool';
export { buildBrowserAgentPrompt, type BrowserToolSource } from './browser-credential-setup.prompt';
const PERMANENT_DENIAL_MARKER = 'User permanently denied access to';
const BROWSER_DENIED_RESULT =
'Browser access was denied by the user. Provide manual setup guidance in chat — do not try the browser flow again in this turn.';
const browserToolErrorResultSchema = z.object({
isError: z.literal(true),
structuredContent: z.object({ error: z.string().optional() }).optional(),
content: z.array(z.object({ text: z.string() }).passthrough()).optional(),
});
function isPermanentDenialResult(result: unknown): boolean {
const parsed = browserToolErrorResultSchema.safeParse(result);
if (!parsed.success) return false;
const messages = [
parsed.data.structuredContent?.error ?? '',
...(parsed.data.content?.map((c) => c.text) ?? []),
];
return messages.some((m) => m.includes(PERMANENT_DENIAL_MARKER));
}
type ToolExecuteFn = (input: unknown, ctx: unknown) => Promise<unknown>;
function wrapToolForDenialDetection<T extends ToolsInput[string]>(
tool: T,
onDenied: () => void,
): T {
const originalExecute = tool.execute as ToolExecuteFn | undefined;
if (!originalExecute) return tool;
const observingExecute: ToolExecuteFn = async (input, ctx) => {
const result = await originalExecute(input, ctx);
if (isPermanentDenialResult(result)) onDenied();
return result;
};
return { ...tool, execute: observingExecute as T['execute'] };
}
function wrapBrowserToolsForDenialDetection(tools: ToolsInput, onDenied: () => void): ToolsInput {
const wrapped: ToolsInput = {};
for (const [name, tool] of Object.entries(tools)) {
wrapped[name] = name.startsWith('browser_') ? wrapToolForDenialDetection(tool, onDenied) : tool;
}
return wrapped;
}
function createPauseForUserTool() {
return createTool({
id: 'pause-for-user',
@ -47,6 +91,7 @@ function createPauseForUserTool() {
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
inputType: z.literal('continue'),
}),
resumeSchema: browserCredentialSetupResumeSchema,
execute: async (input: z.infer<typeof browserCredentialSetupInputSchema>, ctx) => {
@ -60,6 +105,7 @@ function createPauseForUserTool() {
requestId: nanoid(),
message: input.message,
severity: 'info' as const,
inputType: 'continue' as const,
});
return { continued: false };
}
@ -148,13 +194,21 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
};
}
let browserPermanentlyDenied = false;
const browserToolsWithDenialDetection = wrapBrowserToolsForDenialDetection(
browserTools,
() => {
browserPermanentlyDenied = true;
},
);
// Add interaction tools
browserTools['pause-for-user'] = createPauseForUserTool();
browserTools['ask-user'] = createAskUserTool();
browserToolsWithDenialDetection['pause-for-user'] = createPauseForUserTool();
browserToolsWithDenialDetection['ask-user'] = createAskUserTool();
// Add consolidated research tool (web-search + fetch-url) from the domain context
if (context.domainContext) {
browserTools.research = createResearchTool(context.domainContext);
browserToolsWithDenialDetection.research = createResearchTool(context.domainContext);
}
const subAgentId = `agent-browser-${nanoid(6)}`;
@ -167,7 +221,7 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
payload: {
parentId: context.orchestratorAgentId,
role: 'credential-setup-browser-agent',
tools: Object.keys(browserTools),
tools: Object.keys(browserToolsWithDenialDetection),
},
});
let traceRun: Awaited<ReturnType<typeof startSubAgentTrace>>;
@ -196,7 +250,7 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
});
const tracedBrowserTools = traceSubAgentTools(
context,
browserTools,
browserToolsWithDenialDetection,
'credential-setup-browser-agent',
);
const browserPrompt = buildBrowserAgentPrompt(toolSource);
@ -321,6 +375,13 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
throw new Error('Run cancelled while waiting for confirmation');
}
// If the user has permanently denied browser access, stop here.
// Don't nudge into pause-for-user — return a structured result so
// the orchestrator can offer manual guidance via plain chat text.
if (browserPermanentlyDenied) {
return BROWSER_DENIED_RESULT;
}
if (lastSuspendedToolName !== 'pause-for-user' && nudgeCount < MAX_NUDGES) {
// Agent ended without a final pause-for-user confirmation.
// Replay the prior conversation + a nudge so the sub-agent

View File

@ -5344,6 +5344,7 @@
"instanceAi.confirmation.pendingInline": "Waiting for approval",
"instanceAi.confirmation.agentContext": "{agent} needs approval",
"instanceAi.confirmation.approveAll": "Approve all",
"instanceAi.confirmation.continue": "Continue",
"instanceAi.gatewayConfirmation.allowForSession": "Allow for session",
"instanceAi.gatewayConfirmation.allowOnce": "Allow once",
"instanceAi.gatewayConfirmation.denyOnce": "Deny once",

View File

@ -300,6 +300,44 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
});
describe('continue confirmation', () => {
it('renders a single primary button and resolves as approved', async () => {
injectPendingConfirmation(store, {
requestId: 'req-continue',
severity: 'info',
message: 'Enter the values privately into n8n.',
inputType: 'continue',
});
const confirmSpy = vi.spyOn(store, 'confirmAction').mockResolvedValue(true);
const { getByTestId, queryByTestId } = renderComponent();
expect(queryByTestId('instance-ai-panel-confirm-approve')).toBeNull();
expect(queryByTestId('instance-ai-panel-confirm-deny')).toBeNull();
await userEvent.click(getByTestId('instance-ai-panel-continue'));
expect(confirmSpy).toHaveBeenCalledWith('req-continue', {
kind: 'approval',
approved: true,
});
expect(mockTelemetryTrack).toHaveBeenCalledWith(
'User finished providing input',
expect.objectContaining({
type: 'continue',
provided_inputs: [
{
label: 'Enter the values privately into n8n.',
options: ['continue'],
option_chosen: 'continue',
},
],
skipped_inputs: [],
}),
);
});
});
describe('questions confirmation', () => {
const questionsConfirmation: InstanceAiConfirmation = {
requestId: 'req-q',

View File

@ -194,6 +194,17 @@ function handleTextSkip(conf: InstanceAiConfirmation) {
void store.confirmAction(conf.requestId, { kind: 'approval', approved: false });
}
function handleContinue(conf: InstanceAiConfirmation) {
if (store.resolvedConfirmationIds.has(conf.requestId)) return;
trackInputCompleted(
conf,
[{ label: conf.message, options: ['continue'], option_chosen: 'continue' }],
[],
);
store.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
}
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
const questionsById = new Map((conf.questions ?? []).map((q) => [q.id, q]));
const provided: Array<{
@ -388,6 +399,26 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
</div>
</N8nCard>
</div>
<!-- Continue (pause-for-user) single-button acknowledgement -->
<div
v-else-if="chunk.item.toolCall.confirmation.inputType === 'continue'"
:key="'continue-' + chunk.item.toolCall.confirmation.requestId"
:class="$style.confirmation"
>
<N8nCard :class="$style.textCard">
<N8nText tag="div">{{ chunk.item.toolCall.confirmation!.message }}</N8nText>
<div :class="$style.continueRow">
<N8nButton
data-test-id="instance-ai-panel-continue"
size="medium"
variant="solid"
@click="handleContinue(chunk.item.toolCall.confirmation)"
>
{{ i18n.baseText('instanceAi.confirmation.continue') }}
</N8nButton>
</div>
</N8nCard>
</div>
<!-- Resource-access decision (gateway permission mode) -->
<GatewayResourceDecision
v-else-if="
@ -547,6 +578,12 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
margin-top: var(--spacing--2xs);
}
.continueRow {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing--2xs);
}
.generic {
padding: var(--spacing--sm);
border-bottom: var(--border);