Summary
Custom output guardrails fail (workflow → FAILED) even when the guardrail should pass or fix, on turns where the LLM produced no text (tool-call turns) or the checked content is otherwise empty. Two intertwined root causes in the guardrail content-resolution + normalize path. Surfaced by the AgentSpan SDK e2e suites against orkes-embedded — Suite17 custom OUTPUT cells (aout_custom_fix, tout_custom_fix, aout_custom_retry) end FAILED instead of COMPLETED.
These are pre-existing and independent of the guardrail retry→raise escalation fix in #316 (that fix is correct; this issue is what remains once escalation is enabled).
Bug 1 — output guardrail receives content=[] on tool-call / no-text turns
The output guardrail's content reference is ${<agent>_llm.output.result} (AgentCompiler.java:269/:527). On a turn where the LLM returns finishReason=TOOL_CALLS (or otherwise emits no text), output.result is []. So the guardrail worker is invoked with content=[] and never sees the real content it's meant to check.
Observed (orkes-embedded, aout_custom_fix workflow): the guardrail worker task's inputData.content = []; the customAoutFix func (redact MARKER42) therefore sees no MARKER42, returns passed:true with no fixedOutput.
Bug 2 — normalize treats a passing guardrail as failed
The custom-guardrail worker always returns on_fail = <configured policy> (e.g. "fix"), even when it passes (runtime.ts:933/:942 in the TS SDK; python is equivalent):
return { passed: result.passed ?? true, on_fail: gDef.onFail ?? "raise", fixed_output: result.fixedOutput, ... };
But customGuardrailNormalizeScript computes passed by also requiring on_fail to be null/'pass' (JavaScriptBuilder.java:458):
var passed = raw.passed !== false && (existingOnFail == null || existingOnFail === 'pass');
So a genuinely passing guardrail (raw.passed = true, on_fail = 'fix') is computed as passed = false, then routed by its on_fail policy. With retry→raise escalation now enabled (#316), that becomes raise → TERMINATE → FAILED. (Previously this was masked: on_fail='fix' with a null fixed_output was a no-op fix and the loop eventually completed.)
Observed (aout_custom_fix): normalize input worker_output = {passed:true, on_fail:"fix", should_continue:true} (no fixed_output) → normalize computes passed=false → on_fail=raise → guardrail_terminate → workflow FAILED.
Impact
Suite17 custom OUTPUT cells #09 aout_custom_fix, #27 tout_custom_fix (expect COMPLETED with [REDACTED]) and #07 aout_custom_retry (expect COMPLETED) → FAILED.
- More broadly, any custom OUTPUT guardrail configured with
onFail != 'pass' that legitimately passes can be misrouted, and any output guardrail is blind to tool-call turns (content=[]).
- Invisible in AgentSpan's own CI (standalone server), same cross-engine pattern as the escalation bug.
Proposed fixes
-
Bug 2 (normalize passed) — determine passed from raw.passed and only fall back to on_fail when raw.passed is absent; and set on_fail='pass' when passed so the route continues:
var passed = raw.passed === true
|| (raw.passed !== false && (existingOnFail == null || existingOnFail === 'pass'));
var actualOnFail = passed ? 'pass' : escalate(existingOnFail, fixedOutput);
(This is a straightforward SDK-side fix and could be a quick PR.)
-
Bug 1 (content=[]) — decide what an OUTPUT guardrail should check on a tool-call turn: skip the guardrail when there is no text output, or resolve content across the turn's text parts / accumulated output rather than only output.result. Needs a small design decision.
Repro
AgentSpan SDK e2e (java/ts/python) Suite17 (aout_custom_fix, tout_custom_fix, aout_custom_retry) or Suite8 custom output guardrails, run against a server that exposes tasks as {input, output} docs (orkes-embedded); the agent turn that triggers the guardrail is a tool-call turn.
Relation to #316
#316 fixes the retry→raise escalation (and correctly makes fix→raise conditional on fixedOutput). Once escalation is live, Bug 2 turns a previously-masked misroute into a hard FAILED, which is how this surfaced. These two bugs are the remaining, separate work.
Summary
Custom output guardrails fail (workflow →
FAILED) even when the guardrail should pass or fix, on turns where the LLM produced no text (tool-call turns) or the checked content is otherwise empty. Two intertwined root causes in the guardrail content-resolution + normalize path. Surfaced by the AgentSpan SDK e2e suites against orkes-embedded —Suite17custom OUTPUT cells (aout_custom_fix,tout_custom_fix,aout_custom_retry) endFAILEDinstead ofCOMPLETED.These are pre-existing and independent of the guardrail retry→raise escalation fix in #316 (that fix is correct; this issue is what remains once escalation is enabled).
Bug 1 — output guardrail receives
content=[]on tool-call / no-text turnsThe output guardrail's content reference is
${<agent>_llm.output.result}(AgentCompiler.java:269/:527). On a turn where the LLM returnsfinishReason=TOOL_CALLS(or otherwise emits no text),output.resultis[]. So the guardrail worker is invoked withcontent=[]and never sees the real content it's meant to check.Observed (orkes-embedded,
aout_custom_fixworkflow): the guardrail worker task'sinputData.content=[]; thecustomAoutFixfunc (redactMARKER42) therefore sees noMARKER42, returnspassed:truewith nofixedOutput.Bug 2 — normalize treats a passing guardrail as failed
The custom-guardrail worker always returns
on_fail = <configured policy>(e.g."fix"), even when it passes (runtime.ts:933/:942in the TS SDK; python is equivalent):But
customGuardrailNormalizeScriptcomputespassedby also requiringon_failto benull/'pass'(JavaScriptBuilder.java:458):So a genuinely passing guardrail (
raw.passed = true,on_fail = 'fix') is computed aspassed = false, then routed by itson_failpolicy. With retry→raise escalation now enabled (#316), that becomesraise→TERMINATE→FAILED. (Previously this was masked:on_fail='fix'with a nullfixed_outputwas a no-op fix and the loop eventually completed.)Observed (
aout_custom_fix): normalize inputworker_output = {passed:true, on_fail:"fix", should_continue:true}(nofixed_output) → normalize computespassed=false→on_fail=raise→guardrail_terminate→ workflowFAILED.Impact
Suite17custom OUTPUT cells#09 aout_custom_fix,#27 tout_custom_fix(expectCOMPLETEDwith[REDACTED]) and#07 aout_custom_retry(expectCOMPLETED) →FAILED.onFail != 'pass'that legitimately passes can be misrouted, and any output guardrail is blind to tool-call turns (content=[]).Proposed fixes
Bug 2 (normalize
passed) — determinepassedfromraw.passedand only fall back toon_failwhenraw.passedis absent; and seton_fail='pass'when passed so the route continues:(This is a straightforward SDK-side fix and could be a quick PR.)
Bug 1 (
content=[]) — decide what an OUTPUT guardrail should check on a tool-call turn: skip the guardrail when there is no text output, or resolve content across the turn's text parts / accumulated output rather than onlyoutput.result. Needs a small design decision.Repro
AgentSpan SDK e2e (java/ts/python)
Suite17(aout_custom_fix,tout_custom_fix,aout_custom_retry) orSuite8custom output guardrails, run against a server that exposes tasks as{input, output}docs (orkes-embedded); the agent turn that triggers the guardrail is a tool-call turn.Relation to #316
#316 fixes the retry→raise escalation (and correctly makes
fix→raiseconditional onfixedOutput). Once escalation is live, Bug 2 turns a previously-masked misroute into a hardFAILED, which is how this surfaced. These two bugs are the remaining, separate work.