mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(core): Accept approval commentary when resuming suspended agent runs
A suspended tool call's resume payload is validated against the tool's resume schema serialized to JSON Schema, where additionalProperties: false rejects the envelope-level fields an approval confirmation may attach (userInput free-text commentary, scope). A live resume validates against the zod schema, which strips unknown keys — so the same approval resumed a live run but left a suspended run hanging with no user-visible error. On validation failure, retry without the envelope fields to restore parity.
This commit is contained in:
parent
693fc9616f
commit
ec2da58371
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Approval confirmations attach envelope-level fields (`userInput` free-text
|
||||
* commentary, `scope`) to the resume payload alongside the tool's own resume
|
||||
* fields. Tool resume schemas declare only their own fields, and a SUSPENDED
|
||||
* resume validates against the schema serialized to JSON Schema — where
|
||||
* `additionalProperties: false` rejects the envelope fields outright, leaving
|
||||
* the run suspended forever. (A live resume validates against the zod object,
|
||||
* which strips unknown keys, so the same payload passes there.)
|
||||
*
|
||||
* These tests pin the fallback that restores parity: on validation failure the
|
||||
* runtime retries without the envelope fields.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { parseWithSchema } from '../../utils/parse';
|
||||
import { zodToJsonSchema } from '../../utils/zod';
|
||||
import { withoutApprovalEnvelopeFields } from '../loop/agent-runtime';
|
||||
|
||||
describe('withoutApprovalEnvelopeFields', () => {
|
||||
it('strips userInput and scope, preserving the tool fields', () => {
|
||||
expect(
|
||||
withoutApprovalEnvelopeFields({
|
||||
approved: true,
|
||||
userInput: 'Yes, publish it with the credential attached as is.',
|
||||
scope: 'always',
|
||||
}),
|
||||
).toEqual({ approved: true });
|
||||
});
|
||||
|
||||
it('strips one envelope field without touching other keys', () => {
|
||||
expect(
|
||||
withoutApprovalEnvelopeFields({ approved: true, action: 'apply', userInput: 'go ahead' }),
|
||||
).toEqual({ approved: true, action: 'apply' });
|
||||
});
|
||||
|
||||
it('returns undefined when no envelope field is present (nothing to retry)', () => {
|
||||
expect(withoutApprovalEnvelopeFields({ approved: false })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-object payloads', () => {
|
||||
expect(withoutApprovalEnvelopeFields(undefined)).toBeUndefined();
|
||||
expect(withoutApprovalEnvelopeFields('approve')).toBeUndefined();
|
||||
expect(withoutApprovalEnvelopeFields([{ approved: true }])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mutate the original payload', () => {
|
||||
const original = { approved: true, userInput: 'ok' };
|
||||
withoutApprovalEnvelopeFields(original);
|
||||
expect(original).toEqual({ approved: true, userInput: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('suspended-resume validation parity', () => {
|
||||
// The shape every tool resume schema shares, serialized exactly the way a
|
||||
// suspended tool call persists it (zod object → JSON Schema with
|
||||
// additionalProperties: false).
|
||||
const approvalResumeJsonSchema = zodToJsonSchema(z.object({ approved: z.boolean() }));
|
||||
if (!approvalResumeJsonSchema) throw new Error('failed to serialize the resume schema');
|
||||
|
||||
it('rejects an approval carrying commentary against the serialized schema', async () => {
|
||||
const result = await parseWithSchema(approvalResumeJsonSchema, {
|
||||
approved: true,
|
||||
userInput: 'Yes, publish it.',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the same approval once the envelope fields are stripped', async () => {
|
||||
const stripped = withoutApprovalEnvelopeFields({
|
||||
approved: true,
|
||||
userInput: 'Yes, publish it.',
|
||||
scope: 'always',
|
||||
});
|
||||
expect(stripped).toBeDefined();
|
||||
const result = await parseWithSchema(approvalResumeJsonSchema, stripped);
|
||||
expect(result).toEqual({ success: true, data: { approved: true } });
|
||||
});
|
||||
});
|
||||
|
|
@ -153,6 +153,30 @@ interface LoopContext {
|
|||
pendingResume?: PendingResume;
|
||||
}
|
||||
|
||||
/** Envelope-level fields an approval confirmation may attach to a resume
|
||||
* payload alongside the tool's own resume fields. */
|
||||
const APPROVAL_ENVELOPE_FIELDS = ['userInput', 'scope'] as const;
|
||||
|
||||
/**
|
||||
* A copy of the resume payload without the approval-envelope fields, or
|
||||
* undefined when there is nothing to strip (not an object, or none present).
|
||||
* Used as a fallback when a resume payload fails schema validation: tool
|
||||
* resume schemas declare only their own fields, so envelope fields must not
|
||||
* make an otherwise-valid approval unresumable.
|
||||
*/
|
||||
export function withoutApprovalEnvelopeFields(data: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined;
|
||||
const stripped: Record<string, unknown> = { ...data };
|
||||
let removed = false;
|
||||
for (const field of APPROVAL_ENVELOPE_FIELDS) {
|
||||
if (field in stripped) {
|
||||
delete stripped[field];
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
return removed ? stripped : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core agent execution engine using the Vercel AI SDK directly.
|
||||
*
|
||||
|
|
@ -372,7 +396,18 @@ export class AgentRuntime {
|
|||
|
||||
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;
|
||||
if (!isCancellation(resumeData) && resumeSchema) {
|
||||
const parseResult = await parseWithSchema(resumeSchema, data);
|
||||
let parseResult = await parseWithSchema(resumeSchema, data);
|
||||
if (!parseResult.success) {
|
||||
// Approval confirmations carry envelope-level fields (`userInput`
|
||||
// free-text commentary, `scope`) that tool resume schemas do not
|
||||
// declare. A live resume validates against the zod schema, which
|
||||
// strips unknown keys; a suspended resume validates against the
|
||||
// serialized JSON schema (additionalProperties: false), which rejects
|
||||
// them — leaving the run suspended forever. Retry without the
|
||||
// envelope fields to restore parity.
|
||||
const stripped = withoutApprovalEnvelopeFields(data);
|
||||
if (stripped) parseResult = await parseWithSchema(resumeSchema, stripped);
|
||||
}
|
||||
if (!parseResult.success) {
|
||||
throw new Error(`Invalid resume payload: ${parseResult.error}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user