refactor(core): Keep foreground cancellation independent of guardrails (no-changelog)

This commit is contained in:
Eugene Molodkin 2026-10-16 18:51:04 +02:00
parent e8fd60c10d
commit 7e8ecff54d
No known key found for this signature in database
4 changed files with 4 additions and 74 deletions

View File

@ -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' }))

View File

@ -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;
}

View File

@ -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}`),

View File

@ -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 {