From 500a5e66a8bbe9c8c3597299cbabb1ba7761cb63 Mon Sep 17 00:00:00 2001 From: Kowser Date: Tue, 28 Jul 2026 19:49:51 -0700 Subject: [PATCH] fix(agents): custom guardrail worker misreports pass as fail on retry/fix _registerGuardrailWorker always echoed the guardrail's configured onFail value in on_fail, even when the check passed. The server-side guardrail router only treats on_fail as "pass" when it's null/"pass", so any custom guardrail(onFail: 'retry'|'fix') that genuinely passed was still routed into the retry loop, permanently, until it hit the workflow's retry ceiling and failed the whole run. Also fixes examples/agents/04-guardrails.ts's factChecker, which used onFail:'human' with the one-shot runtime.run() API -- a real hang, since nothing ever completes the pending human task in that script. Matches the onFail:'retry' pattern already used by the equivalent guardrail-type combo demo (10-guardrails.ts); onFail:'human' has its own dedicated demo in 32-human-guardrail.ts using the correct start/stream/respond pattern. Co-Authored-By: Claude Sonnet 5 --- examples/agents/04-guardrails.ts | 2 +- src/agents/__tests__/runtime.test.ts | 65 ++++++++++++++++++++++++++++ src/agents/runtime.ts | 7 +-- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/examples/agents/04-guardrails.ts b/examples/agents/04-guardrails.ts index cafbb7b2..41e41eb0 100644 --- a/examples/agents/04-guardrails.ts +++ b/examples/agents/04-guardrails.ts @@ -56,7 +56,7 @@ const factChecker = guardrail( { name: 'fact_checker', position: 'output', - onFail: 'human', + onFail: 'retry', }, ); diff --git a/src/agents/__tests__/runtime.test.ts b/src/agents/__tests__/runtime.test.ts index 5efeb16e..85dfa4d8 100644 --- a/src/agents/__tests__/runtime.test.ts +++ b/src/agents/__tests__/runtime.test.ts @@ -92,6 +92,7 @@ import { } from "../runtime.js"; import { AgentConfig } from "../config.js"; import { LivenessMonitor } from "../liveness.js"; +import type { GuardrailDef } from "../types.js"; import { TokenResource } from "../../open-api/generated"; const mockedGenerateToken = TokenResource.generateToken as jest.MockedFunction< @@ -884,6 +885,70 @@ describe("AgentRuntime", () => { } }); }); + + describe("_registerGuardrailWorker (custom guardrail on_fail contract)", () => { + const baseGDef: GuardrailDef = { + name: "fact_checker", + position: "output", + onFail: "retry", + guardrailType: "custom", + taskName: "fact_checker", + func: undefined, + }; + + async function registerAndInvoke( + gDef: GuardrailDef, + inputData: Record, + ): Promise> { + const runtime = new AgentRuntime(); + let handler: ((inputData: unknown) => Promise>) | undefined; + jest + .spyOn((runtime as any).workerManager, "addWorker") + .mockImplementation((_taskName: unknown, fn: unknown) => { + handler = fn as typeof handler; + }); + + await (runtime as any)._registerGuardrailWorker(gDef); + return handler!(inputData); + } + + it("reports on_fail as 'pass' when the guardrail passes, even though onFail is 'retry'", async () => { + const result = await registerAndInvoke( + { ...baseGDef, func: () => ({ passed: true }) }, + { content: "clean content" }, + ); + + expect(result).toMatchObject({ passed: true, on_fail: "pass", should_continue: true }); + }); + + it("reports on_fail as the configured value when the guardrail fails", async () => { + const result = await registerAndInvoke( + { ...baseGDef, func: () => ({ passed: false, message: "Unverifiable claims: always" }) }, + { content: "This always works." }, + ); + + expect(result).toMatchObject({ + passed: false, + on_fail: "retry", + should_continue: false, + message: "Unverifiable claims: always", + }); + }); + + it("reports on_fail as the configured value when the guardrail function throws", async () => { + const result = await registerAndInvoke( + { + ...baseGDef, + func: () => { + throw new Error("boom"); + }, + }, + { content: "anything" }, + ); + + expect(result).toMatchObject({ passed: false, on_fail: "retry", should_continue: false }); + }); + }); }); // ── Singleton functions ───────────────────────────────── diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index 7137abf9..9aaf5bac 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -1092,13 +1092,14 @@ export class AgentRuntime { const content = typeof raw === "object" ? JSON.stringify(raw) : String(raw); try { const result = await fn(content); + const passed = result.passed ?? true; return { - passed: result.passed ?? true, + passed, message: result.message ?? "", - on_fail: gDef.onFail ?? "raise", + on_fail: passed ? "pass" : (gDef.onFail ?? "raise"), fixed_output: result.fixedOutput, guardrail_name: gDef.name, - should_continue: result.passed ?? true, + should_continue: passed, }; } catch (err) { return {