diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/GuardrailCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/GuardrailCompiler.java index 1f0b6060..300b7621 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/GuardrailCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/GuardrailCompiler.java @@ -83,7 +83,11 @@ public List compileGuardrailTasks( return new ArrayList<>(); } - String iterationRef = "${" + agentName + "_loop.iteration}"; + // NOTE: the live loop counter lives under the DO_WHILE task's OUTPUT + // (Conductor exposes {input, output} per task ref), so it must be read as + // ${.output.iteration}. The bare ${.iteration} resolves to null + // mid-loop, which silently disabled every iteration-based escalation below. + String iterationRef = "${" + agentName + "_loop.output.iteration}"; return compileGuardrailTasksInternal(outputGuardrails, agentName, contentRef, iterationRef); } @@ -262,6 +266,10 @@ private GuardrailTaskResult compileCustomGuardrail( normalizeInputs.put("worker_output", "${" + workerRef + ".output}"); normalizeInputs.put("guardrail_name", guard.getName()); normalizeInputs.put("default_on_fail", guard.getOnFail()); + // Wire the live loop counter + retry budget so the normalize script can escalate + // retry -> raise once maxRetries is exhausted (parity with regex/llm guardrails). + normalizeInputs.put("iteration", iterationRef); + normalizeInputs.put("max_retries", guard.getMaxRetries()); normalizeTask.setInputParameters(normalizeInputs); return new GuardrailTaskResult(List.of(task, normalizeTask), refName, true); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java index 87a5020d..1af1b1b3 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java @@ -1050,7 +1050,7 @@ private WorkflowDef compileRotation(AgentConfig config, boolean random) { Map selectInputs = new LinkedHashMap<>(); selectInputs.put("evaluatorType", "graaljs"); selectInputs.put("expression", selectScript); - selectInputs.put("iteration", ref(loopRef + ".iteration")); + selectInputs.put("iteration", ref(loopRef + ".output.iteration")); if (config.getAllowedTransitions() != null) { selectInputs.put("last_agent", "${workflow.variables.last_agent}"); } diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/TerminationCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/TerminationCompiler.java index 79515e81..9f7dfa44 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/TerminationCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/TerminationCompiler.java @@ -38,7 +38,7 @@ public static WorkflowTask compileTermination(TerminationConfig config, String a String taskName = agentName + "_termination"; String refName = agentName + "_termination"; String resultRef = "${" + llmRef + ".output.result}"; - String iterationRef = "${" + agentName + "_loop.iteration}"; + String iterationRef = "${" + agentName + "_loop.output.iteration}"; // Match local compiler: emit a SIMPLE worker task. // The Python runtime registers a worker that evaluates the termination condition. @@ -69,7 +69,7 @@ public static WorkflowTask compileStopWhen(String taskName, String agentName, St agentName = AgentCompiler.toRef(agentName); String refName = agentName + "_stop_when"; String resultRef = "${" + llmRef + ".output.result}"; - String iterationRef = "${" + agentName + "_loop.iteration}"; + String iterationRef = "${" + agentName + "_loop.output.iteration}"; WorkflowTask task = new WorkflowTask(); task.setName(taskName); @@ -106,7 +106,7 @@ public static WorkflowTask compileStopWhenForConversation(String taskName, Strin Map inputs = new LinkedHashMap<>(); inputs.put("result", "${workflow.variables.conversation}"); - inputs.put("iteration", "${" + loopRef + ".iteration}"); + inputs.put("iteration", "${" + loopRef + ".output.iteration}"); task.setInputParameters(inputs); return task; @@ -134,7 +134,7 @@ public static WorkflowTask compileTerminationForConversation( Map inputs = new LinkedHashMap<>(); inputs.put("result", "${workflow.variables.conversation}"); - inputs.put("iteration", "${" + loopRef + ".iteration}"); + inputs.put("iteration", "${" + loopRef + ".output.iteration}"); task.setInputParameters(inputs); return task; diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java index 9883d4ba..42524a74 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java @@ -431,6 +431,13 @@ public static String guardrailFixScript() { public static String customGuardrailNormalizeScript() { return iife(" var raw = $.worker_output;" + " var guardrailName = $.guardrail_name || 'guardrail';" + " var defaultOnFail = $.default_on_fail || 'retry';" + + " var iteration = $.iteration || 0;" + + " var max_retries = $.max_retries || 0;" + + " function escalate(of, fixedOutput) {" + + " if (of === 'retry' && iteration >= max_retries) return 'raise';" + + " if (of === 'fix' && (fixedOutput === null || fixedOutput === undefined)) return 'raise';" + + " return of;" + + " }" + " if (raw == null) {" + " return {passed: true, message: '', on_fail: null," + " fixed_output: null, guardrail_name: guardrailName," @@ -449,9 +456,10 @@ public static String customGuardrailNormalizeScript() { + " var existingOnFail = raw.on_fail !== undefined ? raw.on_fail : raw.onFail;" + " var fixedOutput = raw.fixed_output !== undefined ? raw.fixed_output : raw.fixedOutput;" + " var passed = raw.passed !== false && (existingOnFail == null || existingOnFail === 'pass');" - + " return {passed: passed, message: raw.message || '', on_fail: existingOnFail," + + " var actualOnFail = passed ? existingOnFail : escalate(existingOnFail, fixedOutput);" + + " return {passed: passed, message: raw.message || '', on_fail: actualOnFail," + " fixed_output: fixedOutput, guardrail_name: raw.guardrail_name || raw.guardrailName || guardrailName," - + " should_continue: existingOnFail === 'retry'};" + + " should_continue: actualOnFail === 'retry'};" + " }" + " if (raw != null && typeof raw === 'object'" + " && (raw.tripwire_triggered !== undefined || raw.tripwireTriggered !== undefined" @@ -469,9 +477,10 @@ public static String customGuardrailNormalizeScript() { + " fixed_output: null, guardrail_name: guardrailName," + " should_continue: false};" + " }" + + " var tripwireOnFail = escalate(defaultOnFail, null);" + " return {passed: false, message: reason || (guardrailName + ' triggered')," - + " on_fail: defaultOnFail, fixed_output: null," - + " guardrail_name: guardrailName, should_continue: defaultOnFail === 'retry'};" + + " on_fail: tripwireOnFail, fixed_output: null," + + " guardrail_name: guardrailName, should_continue: tripwireOnFail === 'retry'};" + " }" + " return {passed: true, message: '', on_fail: null," + " fixed_output: null, guardrail_name: guardrailName," diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/GuardrailCompilerTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/GuardrailCompilerTest.java index 0342ea0f..253e62d0 100644 --- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/GuardrailCompilerTest.java +++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/GuardrailCompilerTest.java @@ -80,6 +80,39 @@ void testCustomGuardrail() { assertThat(results.get(0).getTasks().get(1).getType()).isEqualTo("INLINE"); } + @Test + void testCustomGuardrailNormalizeWiresLiveIterationAndMaxRetriesForEscalation() { + GuardrailConfig g = GuardrailConfig.builder() + .name("custom_check") + .guardrailType("custom") + .position("output") + .taskName("my_guardrail_worker") + .onFail("retry") + .maxRetries(3) + .build(); + + GuardrailCompiler gc = new GuardrailCompiler(); + var results = gc.compileGuardrailTasks(List.of(g), "agent", "${ref}"); + + WorkflowTask normalize = results.get(0).getTasks().get(1); + assertThat(normalize.getType()).isEqualTo("INLINE"); + // The live loop counter must be read from the DO_WHILE task's OUTPUT + // (${.output.iteration}); the bare ${.iteration} resolves to null + // mid-loop and silently disables retry->raise escalation. + assertThat((String) normalize.getInputParameters().get("iteration")) + .isEqualTo("${agent_loop.output.iteration}"); + assertThat(normalize.getInputParameters().get("max_retries")).isEqualTo(3); + // The normalize script must apply the same retry->raise coercion the regex/llm + // scripts use once the retry budget is exhausted. + assertThat((String) normalize.getInputParameters().get("expression")) + .contains("iteration >= max_retries") + .contains("'raise'") + // fix -> raise must be conditional on there being NO fixed output, otherwise + // a custom `fix` guardrail with a fixedOutput would be wrongly terminated + // instead of applying the fix (regression guard). + .contains("fixedOutput === null"); + } + @Test void testCustomGuardrailIncludesOpenAICompatibleInputAliases() { GuardrailConfig g = GuardrailConfig.builder() diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/TerminationCompilerTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/TerminationCompilerTest.java index 5a50da56..fe4b8f1a 100644 --- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/TerminationCompilerTest.java +++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/TerminationCompilerTest.java @@ -89,7 +89,7 @@ void testStopWhen() { assertThat(task.getTaskReferenceName()).isEqualTo("agent_stop_when"); // Inputs bind to LLM result, loop iteration, and messages (stop_when needs conversation history) assertThat((String) task.getInputParameters().get("result")).contains("agent_llm.output.result"); - assertThat((String) task.getInputParameters().get("iteration")).contains("agent_loop.iteration"); + assertThat((String) task.getInputParameters().get("iteration")).contains("agent_loop.output.iteration"); assertThat((String) task.getInputParameters().get("messages")).contains("agent_llm.input.messages"); }