mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(core): Keep foreground cancellation independent of guardrails (no-changelog)
This commit is contained in:
parent
e8fd60c10d
commit
7e8ecff54d
|
|
@ -1815,62 +1815,6 @@ describe('AgentRuntime — guardrails', () => {
|
|||
expect(call.state === 'rejected' && call.error).toContain('tool.stop');
|
||||
});
|
||||
|
||||
it.each(['generate', 'stream'] as const)(
|
||||
'records canceled calls without errors and stops the next %s model turn',
|
||||
async (mode) => {
|
||||
const canceled: GuardrailDecision = { action: 'stop', code: 'user.cancel', canceled: true };
|
||||
const calls = ['tc-1', 'tc-2'].map((toolCallId) => ({
|
||||
toolCallId,
|
||||
toolName: 'echo',
|
||||
args: { v: 'x' },
|
||||
}));
|
||||
generateText.mockResolvedValueOnce(makeGenerateWithToolCalls(calls));
|
||||
streamText.mockReturnValueOnce(makeStreamWithToolCalls(calls));
|
||||
const handler = vi.fn(async () => 'Must not run');
|
||||
const hook = makeGuardrail({
|
||||
before: vi.fn().mockResolvedValueOnce({ action: 'allow' }).mockResolvedValue(canceled),
|
||||
beforeTool: vi.fn().mockResolvedValue(canceled),
|
||||
});
|
||||
const runtime = createRuntimeWithEchoTool(handler);
|
||||
const options = { guardrails: guardrailsOption(hook) };
|
||||
if (mode === 'generate') {
|
||||
const result = await runtime.generate('go', options);
|
||||
const blocks = result.messages.flatMap((message) =>
|
||||
isLlmMessage(message)
|
||||
? message.content.filter((block) => block.type === 'tool-call')
|
||||
: [],
|
||||
);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks).toEqual([
|
||||
expect.objectContaining({ state: 'resolved', canceled: true }),
|
||||
expect.objectContaining({ state: 'resolved', canceled: true }),
|
||||
]);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.guardrail).toEqual({ code: 'user.cancel', canceled: true });
|
||||
expect(generateText).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
const { stream } = await runtime.stream('go', options);
|
||||
const chunks = await collectChunks(stream);
|
||||
const results = chunks.filter((chunk) => chunk.type === 'tool-result');
|
||||
expect(results).toHaveLength(2);
|
||||
for (const result of results) {
|
||||
expect(result.canceled).toBe(true);
|
||||
expect(result.output).toContain('user.cancel');
|
||||
expect(result.output).not.toContain('User said');
|
||||
expect(result.isError).not.toBe(true);
|
||||
}
|
||||
expect(chunks.some((chunk) => chunk.type === 'error')).toBe(false);
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: 'finish',
|
||||
guardrail: { code: 'user.cancel', canceled: true },
|
||||
});
|
||||
expect(streamText).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(hook.afterTool).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('passes the handler result to afterTool() when the tool call is allowed', async () => {
|
||||
generateText
|
||||
.mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'echo', { v: 'x' }))
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ export class GuardrailRunner {
|
|||
async before(ctx: GuardrailModelCallContext): Promise<GuardrailStop | undefined> {
|
||||
for (const hook of this.hooks) {
|
||||
const decision = await hook.before?.(ctx);
|
||||
if (decision?.action === 'stop') {
|
||||
return { code: decision.code, ...(decision.canceled ? { canceled: true } : {}) };
|
||||
}
|
||||
if (decision?.action === 'stop') return { code: decision.code };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -62,9 +60,7 @@ export class GuardrailRunner {
|
|||
async beforeTool(ctx: GuardrailToolCallContext): Promise<GuardrailStop | undefined> {
|
||||
for (const hook of this.hooks) {
|
||||
const decision = await hook.beforeTool?.(ctx);
|
||||
if (decision?.action === 'stop') {
|
||||
return { code: decision.code, ...(decision.canceled ? { canceled: true } : {}) };
|
||||
}
|
||||
if (decision?.action === 'stop') return { code: decision.code };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,12 +137,6 @@ export class ToolCallRunner {
|
|||
if (guardrails && guardCtx && !params.previouslySuspended) {
|
||||
const stop = await guardrails.beforeTool(guardCtx);
|
||||
if (stop) {
|
||||
if (stop.canceled)
|
||||
return this.buildCancelledOutcome(
|
||||
params,
|
||||
'Tool call canceled',
|
||||
`[Tool call stopped by guardrail: ${stop.code}]`,
|
||||
);
|
||||
return await this.toolError(
|
||||
params,
|
||||
new Error(`Tool call stopped by guardrail: ${stop.code}`),
|
||||
|
|
|
|||
|
|
@ -12,13 +12,9 @@ export interface BuiltGuardrail {
|
|||
/** @internal */ readonly _config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type GuardrailDecision = { action: 'allow' } | ({ action: 'stop' } & GuardrailStop);
|
||||
export type GuardrailDecision = { action: 'allow' } | { action: 'stop'; code: string };
|
||||
|
||||
export type GuardrailStop = {
|
||||
code: string;
|
||||
/** Record a stopped model or tool call as canceled. */
|
||||
canceled?: true;
|
||||
};
|
||||
export type GuardrailStop = { code: string };
|
||||
export type GuardrailModelCallSource = 'turn' | 'title' | 'memory';
|
||||
|
||||
export interface GuardrailModelCallContext {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user