Skip to content

Custom output guardrails fail on tool-call/clean turns (content=[] + normalize passed-computation) #317

Description

@ling-senpeng13

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 raiseTERMINATEFAILED. (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=falseon_fail=raiseguardrail_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

  1. 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.)

  2. 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.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions