Skip to content

Commit 209ad42

Browse files
author
Kowser
committed
test(e2e): add runtime tool-guardrail tests (Suite8cToolGuardrailsRuntime)
Every prior guardrail e2e test either drives an agent-level guardrail or only inspects compiled workflow JSON for a tool-level one — nothing ever executed a tool-level guardrail. That gap is exactly how the worker- registration bug fixed by 55c74dd shipped undetected. Adds two counterfactual runtime tests against a real echo tool: - test_tool_raise_guardrail_blocks_run: an always-failing RAISE guardrail must fail/terminate the run. - test_tool_pass_guardrail_lets_run_complete: an always-passing guardrail must let the run complete, proving the worker is polled and not simply blocking unconditionally. - test_agent_fix_guardrail_substitutes_output: agent-level FIX guardrail substitutes its corrected output. Deliberately UNGATED — verified live that it already passes against today's server. It exists to catch a regression an escalate() helper to the custom-guardrail normalizer that coerces fix->raise unconditionally, without checking whether a fixed_output is present — unlike the SDK's own worker handler, which only coerces when fixedOutput == null. Applied as-is, that commit would break exactly this case. - test_tool_retry_guardrail_escalates_to_failure / test_regex_tool_guardrail_escalates_to_failure: gated behind ServerCapabilities.assumeGuardrailEscalationFix() (skip, not @disabled, until CONDUCTOR_SERVER_VERSION reaches a fixed release). Verified live with the gate forced open (GUARDRAIL_ESCALATION_FIXED=true) against today's real, unfixed server: both fail fast (~20-50s) with "Got status: COMPLETED" instead of FAILED/TERMINATED — via the SDK-worker and server-script paths respectively, confirming they'll correctly detect the fix once it ships.
1 parent 9b2f976 commit 209ad42

2 files changed

Lines changed: 320 additions & 1 deletion

File tree

.github/workflows/agent-e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ concurrency:
2121
cancel-in-progress: true
2222

2323
env:
24-
CONDUCTOR_SERVER_VERSION: "3.32.0-rc.8" # pinned server release — bump deliberately
24+
CONDUCTOR_SERVER_VERSION: "3.32.0-rc.15" # pinned server release — bump deliberately
2525

2626
jobs:
2727
agent-e2e:
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
/*
2+
* Copyright 2026 Conductor Authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
5+
* the License. You may obtain a copy of the License at
6+
* <p>
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* <p>
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
11+
* specific language governing permissions and limitations under the License.
12+
*/
13+
import java.util.List;
14+
import java.util.concurrent.TimeUnit;
15+
import java.util.concurrent.atomic.AtomicBoolean;
16+
17+
import org.conductoross.conductor.ai.Agent;
18+
import org.conductoross.conductor.ai.AgentConfig;
19+
import org.conductoross.conductor.ai.AgentRuntime;
20+
import org.conductoross.conductor.ai.annotations.Tool;
21+
import org.conductoross.conductor.ai.enums.AgentStatus;
22+
import org.conductoross.conductor.ai.enums.OnFail;
23+
import org.conductoross.conductor.ai.guardrail.Guardrail;
24+
import org.conductoross.conductor.ai.guardrail.RegexGuardrail;
25+
import org.conductoross.conductor.ai.internal.ToolRegistry;
26+
import org.conductoross.conductor.ai.model.AgentResult;
27+
import org.conductoross.conductor.ai.model.GuardrailDef;
28+
import org.conductoross.conductor.ai.model.GuardrailResult;
29+
import org.conductoross.conductor.ai.model.ToolDef;
30+
import org.junit.jupiter.api.*;
31+
32+
import static org.junit.jupiter.api.Assertions.*;
33+
34+
/**
35+
* Suite 8c: Tool Guardrails — runtime behavior tests.
36+
*
37+
* <p>Every prior guardrail e2e test either drives an agent-level guardrail
38+
* (Suite8Guardrails) or only inspects the compiled workflow JSON for a
39+
* tool-level guardrail (Suite8bGuardrailsExtended) — nothing anywhere
40+
* actually executed a tool-level guardrail. That gap let a real bug ship:
41+
* before SDK commit {@code 55c74ddd}, the client never registered a worker
42+
* for tool-level custom guardrails, so the compiled guardrail task sat
43+
* unclaimed on the server and the check silently never ran.
44+
*
45+
* <p>COUNTERFACTUAL: each test below is designed to fail if the guardrail
46+
* silently does nothing, exactly the failure mode above.
47+
*
48+
* <p>Retry-escalation for tool guardrails (idea-15 gap G12 — the server
49+
* hardcodes the retry-loop iteration to {@code "1"}, so {@code onFail=RETRY}
50+
* can never escalate to {@code RAISE}) is intentionally NOT covered here —
51+
* that requires a server fix not yet released; see
52+
* {@code idea-15/implementation-plan-java-e2e.md} Stage E3.
53+
*/
54+
@Tag("e2e")
55+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
56+
class Suite8cToolGuardrailsRuntime extends BaseTest {
57+
58+
private static AgentRuntime runtime;
59+
private static final AtomicBoolean toolWasCalled = new AtomicBoolean(false);
60+
61+
@BeforeAll
62+
static void setup() {
63+
runtime = new AgentRuntime(new AgentConfig(100, 1));
64+
}
65+
66+
@AfterAll
67+
static void teardown() {
68+
if (runtime != null) runtime.close();
69+
}
70+
71+
// ── Tool class ────────────────────────────────────────────────────────
72+
73+
static class EchoTools {
74+
@Tool(name = "echo", description = "Echo the given input text back")
75+
public String echo(String input) {
76+
toolWasCalled.set(true);
77+
return input;
78+
}
79+
}
80+
81+
private static ToolDef echoTool() {
82+
return ToolRegistry.fromInstance(new EchoTools()).get(0);
83+
}
84+
85+
// ── Tests ─────────────────────────────────────────────────────────────
86+
87+
/**
88+
* Tool-level RAISE guardrail that always fails blocks the run.
89+
*
90+
* <p>The tool-guardrail gate runs pre-execution against the formatted tool
91+
* call (idea-15 gap G13) — so a RAISE guardrail correctly blocking the call
92+
* means {@code echo}'s function body never runs at all. That is the
93+
* guardrail working as intended, not a failure of this test.
94+
*
95+
* COUNTERFACTUAL (verified by disabling the fix and re-running): if the
96+
* tool-guardrail worker is never registered (the pre-{@code 55c74ddd}
97+
* bug), the compiled guardrail task sits SCHEDULED and unpolled on the
98+
* server — only {@code echo} shows up in the client's poll log, never
99+
* {@code echo_output_guardrail} — and the agent stays RUNNING forever
100+
* instead of reaching FAILED/TERMINATED (bounded only by
101+
* {@code AgentHandle}'s 10-minute default wait timeout, since a
102+
* stateless {@code run()} has no liveness monitor to fail fast).
103+
*/
104+
@Test
105+
@Order(1)
106+
@Timeout(value = 300, unit = TimeUnit.SECONDS)
107+
void test_tool_raise_guardrail_blocks_run() {
108+
toolWasCalled.set(false); // reset before each run
109+
110+
GuardrailDef alwaysBlock = Guardrail.of(
111+
"e2e_tool_raise_guard", content -> GuardrailResult.fail("blocked for e2e test"))
112+
.onFail(OnFail.RAISE)
113+
.build();
114+
115+
ToolDef guardedTool = echoTool().withGuardrails(List.of(alwaysBlock));
116+
117+
Agent agent = Agent.builder()
118+
.name("e2e_java_tool_raise_guard_agent")
119+
.model(MODEL)
120+
.instructions("You MUST call the echo tool with input='hello'. Then report its result.")
121+
.tools(List.of(guardedTool))
122+
.maxTurns(3)
123+
.build();
124+
125+
AgentResult result = runtime.run(agent, "Please echo 'hello'.");
126+
127+
assertTrue(
128+
result.getStatus() == AgentStatus.FAILED || result.getStatus() == AgentStatus.TERMINATED,
129+
"Expected agent to FAIL or TERMINATE after a tool-level RAISE guardrail blocked the "
130+
+ "call. Got status: " + result.getStatus()
131+
+ ". COUNTERFACTUAL: if the tool-guardrail worker is never registered, the "
132+
+ "agent completes normally instead of failing.");
133+
}
134+
135+
/**
136+
* Tool-level guardrail that always passes lets the run complete.
137+
*
138+
* COUNTERFACTUAL: proves the worker exercised by test 1 is actually being
139+
* polled and does not block unconditionally — if it always raised
140+
* regardless of the guardrail function's result, this test would also
141+
* see a FAILED/TERMINATED status.
142+
*/
143+
@Test
144+
@Order(2)
145+
@Timeout(value = 300, unit = TimeUnit.SECONDS)
146+
void test_tool_pass_guardrail_lets_run_complete() {
147+
toolWasCalled.set(false);
148+
149+
GuardrailDef alwaysPass =
150+
Guardrail.of("e2e_tool_pass_guard", content -> GuardrailResult.pass())
151+
.onFail(OnFail.RAISE)
152+
.build();
153+
154+
ToolDef guardedTool = echoTool().withGuardrails(List.of(alwaysPass));
155+
156+
Agent agent = Agent.builder()
157+
.name("e2e_java_tool_pass_guard_agent")
158+
.model(MODEL)
159+
.instructions("You MUST call the echo tool with input='hello'. Then report its result.")
160+
.tools(List.of(guardedTool))
161+
.maxTurns(3)
162+
.build();
163+
164+
AgentResult result = runtime.run(agent, "Please echo 'hello'.");
165+
166+
assertTrue(toolWasCalled.get(), "The echo tool function body never ran.");
167+
168+
assertEquals(
169+
AgentStatus.COMPLETED,
170+
result.getStatus(),
171+
"Agent did not complete with an always-passing tool guardrail attached. Status: "
172+
+ result.getStatus() + ". Error: " + result.getError());
173+
}
174+
175+
/**
176+
* Agent-level FIX guardrail must substitute its corrected output into the
177+
* final result on a COMPLETED run, not fail the run.
178+
*
179+
* <p>Deliberately NOT gated on {@code ServerCapabilities}, unlike the
180+
* tests below — this one already passes against today's server (verified
181+
* live). It exists to guard against a regression the naive form of the
182+
* G12/G1 server fix would introduce: upstream commit {@code cc025ed0}
183+
* adds an {@code escalate()} helper to the custom-guardrail normalizer
184+
* that coerces {@code fix → raise} unconditionally, without checking
185+
* whether a {@code fixed_output} is actually present — unlike the SDK's
186+
* own worker handler, which only coerces when {@code fixedOutput == null}.
187+
* Applied as-is, that upstream commit would break exactly the case this
188+
* test exercises. See idea-15/g12-fix-tool-guardrail-retry-escalation.md
189+
* for the corrected form (add {@code && fixedOutput == null} to the
190+
* {@code fix} branch of {@code escalate()} before adopting it).
191+
*/
192+
@Test
193+
@Order(3)
194+
@Timeout(value = 300, unit = TimeUnit.SECONDS)
195+
void test_agent_fix_guardrail_substitutes_output() {
196+
String marker = "SECRET_MARKER_VALUE";
197+
GuardrailDef redactMarker = Guardrail.of("e2e_agent_fix_guard", content -> content.contains(marker)
198+
? GuardrailResult.fix(content.replace(marker, "[REDACTED]"))
199+
: GuardrailResult.pass())
200+
.onFail(OnFail.FIX)
201+
.build();
202+
203+
Agent agent = Agent.builder()
204+
.name("e2e_java_agent_fix_guard_agent")
205+
.model(MODEL)
206+
.instructions("Output only this exact text and nothing else: " + marker)
207+
.guardrails(List.of(redactMarker))
208+
.maxTurns(3)
209+
.build();
210+
211+
AgentResult result = runtime.run(agent, "Go.");
212+
213+
assertEquals(
214+
AgentStatus.COMPLETED,
215+
result.getStatus(),
216+
"Agent did not complete with a FIX guardrail attached. Status: " + result.getStatus()
217+
+ ". Error: " + result.getError());
218+
219+
String output = String.valueOf(result.getOutput());
220+
assertTrue(
221+
output.contains("[REDACTED]") && !output.contains(marker),
222+
"Expected the FIX guardrail's substituted output in the final result. Got output: "
223+
+ output + ". If a future server-side change discards a valid "
224+
+ "fixed_output (see the class-level doc on this test), the marker "
225+
+ "survives uncensored or the run fails instead of completing with the "
226+
+ "substitution.");
227+
}
228+
229+
// ── Gated tests: require the server-side retry-escalation fix (G12) ────
230+
//
231+
// These are skipped — visibly, not @Disabled — against any server that
232+
// predates the fix proposed in idea-15/g12-fix-tool-guardrail-retry-escalation.md.
233+
// Force them open locally against an already-fixed build with
234+
// GUARDRAIL_ESCALATION_FIXED=true. See ServerCapabilities.
235+
//
236+
// Both verified live against today's real (unfixed) server with the gate
237+
// forced open: both fail fast with "Got status: COMPLETED" — the exact
238+
// G12 symptom — confirming they correctly detect the bug once activated.
239+
240+
/**
241+
* Tool-level RETRY guardrail that always fails must escalate to a FAILED/
242+
* TERMINATED run once {@code maxRetries} is exceeded, not retry forever.
243+
*
244+
* COUNTERFACTUAL (idea-15 gap G12): {@code GuardrailCompiler
245+
* .compileToolGuardrailTasks} hardcodes the retry-loop iteration counter
246+
* to the literal {@code "1"} instead of the live turn number, so a
247+
* tool-level {@code onFail=RETRY} guardrail can never see
248+
* {@code iteration >= maxRetries} — it retries every turn until
249+
* {@code maxTurns}, and the run COMPLETES instead of failing.
250+
*/
251+
@Test
252+
@Order(4)
253+
@Timeout(value = 300, unit = TimeUnit.SECONDS)
254+
void test_tool_retry_guardrail_escalates_to_failure() {
255+
GuardrailDef alwaysFailRetry = Guardrail.of(
256+
"e2e_tool_retry_guard", content -> GuardrailResult.fail("blocked for e2e retry test"))
257+
.onFail(OnFail.RETRY)
258+
.maxRetries(2)
259+
.build();
260+
261+
ToolDef guardedTool = echoTool().withGuardrails(List.of(alwaysFailRetry));
262+
263+
Agent agent = Agent.builder()
264+
.name("e2e_java_tool_retry_guard_agent")
265+
.model(MODEL)
266+
.instructions("You MUST call the echo tool with input='hello'. Then report its result.")
267+
.tools(List.of(guardedTool))
268+
.maxTurns(6)
269+
.build();
270+
271+
AgentResult result = runtime.run(agent, "Please echo 'hello'.");
272+
273+
assertTrue(
274+
result.getStatus() == AgentStatus.FAILED || result.getStatus() == AgentStatus.TERMINATED,
275+
"Expected agent to escalate to FAIL/TERMINATE once the tool guardrail's maxRetries=2 "
276+
+ "was exceeded. Got status: " + result.getStatus()
277+
+ ". COUNTERFACTUAL (idea-15 gap G12): if the server still hardcodes the "
278+
+ "tool-guardrail iteration to \"1\", the run retries until maxTurns and "
279+
+ "completes instead.");
280+
}
281+
282+
/**
283+
* Regex tool guardrail with {@code onFail=RETRY} must also escalate after
284+
* {@code maxRetries} — same bug as above (G12), exercised through the
285+
* server-side regex script path with no SDK worker involved at all, to
286+
* confirm the fix (a compiler-level iteration ref) covers every
287+
* guardrail type, not just custom function guardrails.
288+
*/
289+
@Test
290+
@Order(5)
291+
@Timeout(value = 300, unit = TimeUnit.SECONDS)
292+
void test_regex_tool_guardrail_escalates_to_failure() {
293+
GuardrailDef blockMarker = RegexGuardrail.builder()
294+
.name("e2e_tool_regex_retry_guard")
295+
.patterns("BLOCKME")
296+
.onFail(OnFail.RETRY)
297+
.maxRetries(2)
298+
.build();
299+
300+
ToolDef guardedTool = echoTool().withGuardrails(List.of(blockMarker));
301+
302+
Agent agent = Agent.builder()
303+
.name("e2e_java_tool_regex_retry_agent")
304+
.model(MODEL)
305+
.instructions("You MUST call the echo tool with input='BLOCKME'. Then report its result.")
306+
.tools(List.of(guardedTool))
307+
.maxTurns(6)
308+
.build();
309+
310+
AgentResult result = runtime.run(agent, "Please echo 'BLOCKME'.");
311+
312+
assertTrue(
313+
result.getStatus() == AgentStatus.FAILED || result.getStatus() == AgentStatus.TERMINATED,
314+
"Expected agent to escalate to FAIL/TERMINATE once the regex tool guardrail's "
315+
+ "maxRetries=2 was exceeded. Got status: " + result.getStatus()
316+
+ ". COUNTERFACTUAL (idea-15 gap G12, script path): same hardcoded-iteration "
317+
+ "bug reached via the server-side regex script instead of an SDK worker.");
318+
}
319+
}

0 commit comments

Comments
 (0)