Skip to main content

auth_cloudflare/
tool_loop.rs

1//! Tool loop - deterministic multi-turn fake-tool conformance harness
2//! (suite `40-multi-turn-tool-loop`).
3//!
4//! This is the most important Hermes test: a LIVE model must complete a
5//! three-step fixture workflow through OpenAI-format tool calls backed by
6//! deterministic in-memory fake tools - no filesystem, terminal, or network
7//! side effects beyond the one chat-completions POST per assistant turn:
8//!
9//! 1. `read_fixture` - the runner returns the fixture source;
10//! 2. `run_fixture_test` - the runner returns a CONTROLLED failure report;
11//! 3. `write_fixture_patch` - the runner returns a success report;
12//! 4. the model delivers a concise final answer with no tool calls.
13//!
14//! Acceptance: correct ordering (read before run before
15//! write; re-reading after a successful run is a violation), no invalid tool
16//! names, all argument JSON valid and schema-conformant, no duplicate call
17//! of the same tool with identical arguments after a successful result, a
18//! final answer, and at most [`TOOL_LOOP_MAX_TURNS`] turns. Every violation
19//! maps to exactly one [`FailureClass`]: a first turn with no tool calls is
20//! [`FailureClass::NoToolCall`], an unknown tool name is
21//! [`FailureClass::InvalidToolName`], unparseable or schema-violating
22//! arguments are [`FailureClass::InvalidToolArguments`], a repeated
23//! (name + identical arguments) call is [`FailureClass::DuplicateToolCall`],
24//! and an incomplete or mis-ordered workflow (including a final answer that
25//! skips part of the workflow) or a turn-budget breach is
26//! [`FailureClass::ToolLoopDidNotConverge`].
27//!
28//! Cost gate: the live loop is opt-in. [`run_tool_loop`] refuses with
29//! [`CloudflareError::MissingEnv`] naming `AUTH_CLOUDFLARE_LIVE_TESTS`
30//! unless [`live_tests_enabled`] is true, so CI and unit tests can never
31//! trigger a paid call by accident.
32//!
33//! Security contract: [`ToolLoopOutcome`] carries only tool-call
34//! observations (name + arguments + turn) and a truncated final answer -
35//! never tool outputs, never prompts, never the token. The Authorization
36//! header is built exclusively through [`crate::fetch::auth_header`], and
37//! every error string is token-scrubbed before it is returned.
38
39use std::time::Duration;
40
41use serde::{Deserialize, Serialize};
42
43use crate::config::SecretString;
44use crate::error::CloudflareError;
45use crate::fetch::auth_header;
46use crate::health::{FailureClass, MAX_EXCERPT_CHARS};
47
48/// Environment variable gating the LIVE tool-loop harness. Must equal
49/// exactly `"1"` for [`run_tool_loop`] to run; anything else (unset, `"0"`,
50/// `"yes"`, whitespace) refuses with [`CloudflareError::MissingEnv`].
51pub const LIVE_TESTS_ENV: &str = "AUTH_CLOUDFLARE_LIVE_TESTS";
52
53/// Maximum assistant turns allowed for one tool-loop run ("total turns ≤ 8"). A final answer ON the max turn is within budget; tool
54/// calls on the max turn are a breach.
55pub const TOOL_LOOP_MAX_TURNS: u32 = 8;
56
57/// System prompt for the tool-loop harness (fixed fixture - never derived
58/// from user input, so it can never carry secrets).
59pub const TOOL_LOOP_SYSTEM_PROMPT: &str = "You are in a test harness. Complete the fixture workflow: read the fixture, run its test, write the patch, then answer concisely with the final status.";
60
61/// User prompt for the tool-loop harness: names the deterministic target
62/// fixture so the enum-constrained `fixture_id` argument is unambiguous.
63pub const TOOL_LOOP_USER_PROMPT: &str = "Begin the fixture workflow. The target fixture id is 'calc'.";
64
65/// The two deterministic in-memory fixtures. The enum on `fixture_id`
66/// mirrors this list exactly - the wire schema and the validator can never
67/// drift apart.
68const FIXTURE_ID_VALUES: &[&str] = &["calc", "greeter"];
69
70/// `calc` fixture source: `add()` is intentionally off by one (returns 3 for
71/// `add(2, 2)`), so the controlled failure report ("got 3") is coherent with
72/// the source the model just read.
73const FIXTURE_CALC_SOURCE: &str = concat!(
74	"//! calc fixture: add() is intentionally off by one so the test fails until patched.\n",
75	"pub fn add(a: i32, b: i32) -> i32 {\n",
76	"    a + b - 1\n",
77	"}\n",
78	"\n",
79	"#[test]\n",
80	"fn test_add() {\n",
81	"    assert_eq!(add(2, 2), 4);\n",
82	"}\n",
83);
84
85/// `greeter` fixture source: `greet()` is missing the comma, so the
86/// controlled failure report is coherent with the source.
87const FIXTURE_GREETER_SOURCE: &str = concat!(
88	"//! greeter fixture: greet() is missing the comma so the test fails until patched.\n",
89	"pub fn greet(name: &str) -> String {\n",
90	"    format!(\"Hello {name}!\")\n",
91	"}\n",
92	"\n",
93	"#[test]\n",
94	"fn test_greet() {\n",
95	"    assert_eq!(greet(\"World\"), \"Hello, World!\");\n",
96	"}\n",
97);
98
99/// One parameter of a fake tool. Every parameter in this suite is a string;
100/// `enum_values` constrains it when the wire schema carries an `enum`.
101struct ParamSpec {
102	name: &'static str,
103	enum_values: &'static [&'static str],
104}
105
106/// One fake tool: its wire schema (`tool_schemas`) and its hand-rolled
107/// validator (`arguments_valid_for_tool`) are both generated from this spec,
108/// so the two can never disagree.
109struct ToolSpec {
110	name: &'static str,
111	description: &'static str,
112	params: &'static [ParamSpec],
113	required: &'static [&'static str],
114}
115
116/// The three OpenAI-format fake tools. Order matters only for display; the
117/// harness validates names against this list.
118const TOOL_SPECS: &[ToolSpec] = &[
119	ToolSpec {
120		name: "read_fixture",
121		description: "Read the Rust fixture source for the given fixture_id.",
122		params: &[ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES }],
123		required: &["fixture_id"],
124	},
125	ToolSpec {
126		name: "run_fixture_test",
127		description: "Run the fixture's test suite and return the controlled test report for the given fixture_id.",
128		params: &[ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES }],
129		required: &["fixture_id"],
130	},
131	ToolSpec {
132		name: "write_fixture_patch",
133		description: "Apply a patch that fixes the fixture source for the given fixture_id.",
134		params: &[
135			ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES },
136			ParamSpec { name: "patch", enum_values: &[] },
137		],
138		required: &["fixture_id", "patch"],
139	},
140];
141
142/// One observed tool call from one assistant turn (serialized snake_case).
143///
144/// `arguments` is the model's argument JSON as issued. If the arguments did
145/// not parse as JSON, the raw argument string is stored wrapped in a JSON
146/// string so the evidence is still preserved (never fabricated).
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub struct ToolCallObservation {
150	pub name: String,
151	pub arguments: serde_json::Value,
152	/// Assistant turn number the call was issued in (1-based).
153	pub turn: u32,
154}
155
156/// Outcome of one tool-loop run.
157///
158/// Security contract: this struct has NO field for tool outputs or prompts -
159/// only tool-call observations and the (truncated) final answer. The final
160/// answer is capped at [`MAX_EXCERPT_CHARS`] (512) characters, char-safe.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub struct ToolLoopOutcome {
164	pub converged: bool,
165	/// Assistant turns actually issued (each assistant message plus its tool
166	/// executions counts as one turn).
167	pub turns_used: u32,
168	pub max_turns: u32,
169	pub tool_calls: Vec<ToolCallObservation>,
170	pub failure_class: Option<FailureClass>,
171	/// The model's final answer when one was given, truncated to 512 chars.
172	pub final_answer: Option<String>,
173}
174
175impl ToolLoopOutcome {
176	/// Build an outcome, truncating the final answer to
177	/// [`MAX_EXCERPT_CHARS`] characters (char-safe: never splits a UTF-8
178	/// scalar mid-sequence).
179	pub fn new(
180		converged: bool,
181		turns_used: u32,
182		max_turns: u32,
183		tool_calls: Vec<ToolCallObservation>,
184		failure_class: Option<FailureClass>,
185		final_answer: Option<String>,
186	) -> Self {
187		Self {
188			converged,
189			turns_used,
190			max_turns,
191			tool_calls,
192			failure_class,
193			final_answer: final_answer.map(|answer| truncate_final_answer(&answer)),
194		}
195	}
196}
197
198/// Truncate a final answer to [`MAX_EXCERPT_CHARS`] characters, char-safe.
199fn truncate_final_answer(answer: &str) -> String {
200	if answer.chars().count() <= MAX_EXCERPT_CHARS {
201		answer.to_string()
202	} else {
203		answer.chars().take(MAX_EXCERPT_CHARS).collect()
204	}
205}
206
207/// True when the live harness is allowed to run: `AUTH_CLOUDFLARE_LIVE_TESTS`
208/// must equal exactly `"1"` (no trimming, no aliases). Shared with the
209/// sibling `verify.rs` integration runner via the crate root.
210pub fn live_tests_enabled() -> bool {
211	std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1")
212}
213
214/// The three OpenAI-format fake-tool schemas, built from [`TOOL_SPECS`].
215///
216/// Every schema uses `additionalProperties: false` and declares its required
217/// fields; `fixture_id` is enum-constrained to the deterministic fixtures so
218/// the model cannot invent fixture ids.
219pub fn tool_schemas() -> Vec<serde_json::Value> {
220	TOOL_SPECS
221		.iter()
222		.map(|spec| {
223			let mut properties = serde_json::Map::new();
224			for param in spec.params {
225				let mut property = serde_json::json!({ "type": "string" });
226				if !param.enum_values.is_empty() {
227					property["enum"] = serde_json::Value::Array(
228						param
229							.enum_values
230							.iter()
231							.map(|value| serde_json::Value::String((*value).to_string()))
232							.collect(),
233					);
234				}
235				properties.insert(param.name.to_string(), property);
236			}
237			serde_json::json!({
238				"type": "function",
239				"function": {
240					"name": spec.name,
241					"description": spec.description,
242					"parameters": {
243						"type": "object",
244						"properties": serde_json::Value::Object(properties),
245						"required": spec.required,
246						"additionalProperties": false,
247					},
248				},
249			})
250		})
251		.collect()
252}
253
254/// The fixture source for a known fixture id, if any (deterministic,
255/// in-memory only).
256pub fn fixture_source(fixture_id: &str) -> Option<String> {
257	match fixture_id {
258		"calc" => Some(FIXTURE_CALC_SOURCE.to_string()),
259		"greeter" => Some(FIXTURE_GREETER_SOURCE.to_string()),
260		_ => None,
261	}
262}
263
264/// Controlled test-report output per fixture (deterministic, in-memory
265/// only). Every report is a FAILURE: the model must consume it and react.
266fn fixture_test_report(fixture_id: &str) -> Option<String> {
267	match fixture_id {
268		"calc" => Some("assertion failed: add(2, 2) == 4, got 3".to_string()),
269		"greeter" => Some("assertion failed: greet(\"World\") == \"Hello, World!\", got \"Hello World!\"".to_string()),
270		_ => None,
271	}
272}
273
274/// Execute one fake tool in memory. Deterministic per (tool, arguments);
275/// never touches the filesystem, terminal, or network.
276///
277/// - `read_fixture` → `{"status":"success","fixture_id":...,"source":...}`;
278/// - `run_fixture_test` → `{"status":"fail","fixture_id":...,"output":...}`
279///   (a CONTROLLED failure report - the execution succeeds, the report says
280///   the fixture's test failed);
281/// - `write_fixture_patch` → `{"status":"success","applied":true,...}`.
282pub fn execute_tool(name: &str, arguments: &serde_json::Value) -> Result<serde_json::Value, String> {
283	let fixture_id = arguments
284		.get("fixture_id")
285		.and_then(serde_json::Value::as_str)
286		.unwrap_or_default();
287	match name {
288		"read_fixture" => {
289			let source = fixture_source(fixture_id).ok_or_else(|| format!("unknown fixture_id {fixture_id:?}"))?;
290			Ok(serde_json::json!({ "status": "success", "fixture_id": fixture_id, "source": source }))
291		},
292		"run_fixture_test" => {
293			let output = fixture_test_report(fixture_id).ok_or_else(|| format!("unknown fixture_id {fixture_id:?}"))?;
294			Ok(serde_json::json!({ "status": "fail", "fixture_id": fixture_id, "output": output }))
295		},
296		"write_fixture_patch" => {
297			if fixture_source(fixture_id).is_none() {
298				return Err(format!("unknown fixture_id {fixture_id:?}"));
299			}
300			Ok(serde_json::json!({ "status": "success", "applied": true, "fixture_id": fixture_id }))
301		},
302		other => Err(format!("unknown tool {other}")),
303	}
304}
305
306/// Ordering acceptance: the full read → run → write workflow must appear in
307/// that order, with no re-read after a run and no run after a write.
308///
309/// Stage numbering: read=1, run=2, write=3. Valid iff every stage appears at
310/// least once AND the stage sequence is non-decreasing. Unknown tool names
311/// are ignored here (they are a name-check concern, [`FailureClass::InvalidToolName`],
312/// not an ordering concern). A read-only or read→write sequence fails: the
313/// workflow is incomplete.
314pub fn validate_ordering(calls: &[ToolCallObservation]) -> bool {
315	let stages: Vec<u8> = calls.iter().filter_map(|call| stage_of(&call.name)).collect();
316	if stages.len() < 3 {
317		return false;
318	}
319	let has_all_stages = stages.contains(&1) && stages.contains(&2) && stages.contains(&3);
320	has_all_stages && stages.windows(2).all(|pair| pair[0] <= pair[1])
321}
322
323fn stage_of(name: &str) -> Option<u8> {
324	match name {
325		"read_fixture" => Some(1),
326		"run_fixture_test" => Some(2),
327		"write_fixture_patch" => Some(3),
328		_ => None,
329	}
330}
331
332/// Duplicate acceptance: references to every call whose (name, identical
333/// arguments) pair was already issued earlier in the sequence - including a
334/// repeated `run_fixture_test` after a successful run, and a re-read of the
335/// same fixture. Argument equality is content-based (JSON object key order
336/// does not matter).
337pub fn find_duplicates(calls: &[ToolCallObservation]) -> Vec<&ToolCallObservation> {
338	let mut seen: Vec<(&str, &serde_json::Value)> = Vec::new();
339	let mut duplicates: Vec<&ToolCallObservation> = Vec::new();
340	for call in calls {
341		let already_seen = seen.iter().any(|(name, args)| *name == call.name && **args == call.arguments);
342		if already_seen {
343			duplicates.push(call);
344		} else {
345			seen.push((call.name.as_str(), &call.arguments));
346		}
347	}
348	duplicates
349}
350
351/// Argument acceptance: every call's arguments must parse as a JSON object
352/// and satisfy the tool's schema - required fields present, correct types,
353/// enum values respected, and no extra properties (`additionalProperties:
354/// false`). Unknown tool names fail (no schema exists for them). Pure and
355/// hand-rolled: no `jsonschema` dependency.
356pub fn all_arguments_valid(calls: &[ToolCallObservation]) -> bool {
357	calls.iter().all(|call| arguments_valid_for_tool(&call.name, &call.arguments))
358}
359
360fn spec_for(name: &str) -> Option<&'static ToolSpec> {
361	TOOL_SPECS.iter().find(|spec| spec.name == name)
362}
363
364fn arguments_valid_for_tool(name: &str, arguments: &serde_json::Value) -> bool {
365	let Some(spec) = spec_for(name) else {
366		return false;
367	};
368	let serde_json::Value::Object(map) = arguments else {
369		return false;
370	};
371	if spec.required.iter().any(|required| !map.contains_key(*required)) {
372		return false;
373	}
374	for (key, value) in map.iter() {
375		let Some(param) = spec.params.iter().find(|param| param.name == key.as_str()) else {
376			return false;
377		};
378		if !value.is_string() {
379			return false;
380		}
381		if !param.enum_values.is_empty() {
382			let Some(actual) = value.as_str() else {
383				return false;
384			};
385			if !param.enum_values.contains(&actual) {
386				return false;
387			}
388		}
389	}
390	true
391}
392
393/// Pure convergence decision for one assistant turn - factored out of the
394/// live loop so the turn-counting rules are unit-testable without a model.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396enum TurnVerdict {
397	/// First turn carried no tool calls: the model skipped the workflow.
398	NoToolCall,
399	/// A later turn carried no tool calls: the model delivered a final answer.
400	FinalAnswer,
401	/// The turn carried tool calls and the budget allows another turn.
402	Continue,
403	/// The max turn carried tool calls: a further turn would breach the
404	/// budget, so the loop cannot converge.
405	TurnLimitBreached,
406}
407
408fn assess_turn(has_tool_calls: bool, turn: u32, max_turns: u32) -> TurnVerdict {
409	if !has_tool_calls {
410		if turn == 1 { TurnVerdict::NoToolCall } else { TurnVerdict::FinalAnswer }
411	} else if turn >= max_turns {
412		TurnVerdict::TurnLimitBreached
413	} else {
414		TurnVerdict::Continue
415	}
416}
417
418/// Run the LIVE multi-turn tool-loop conformance suite for one model.
419///
420/// Gated: refuses with [`CloudflareError::MissingEnv`] naming
421/// [`LIVE_TESTS_ENV`] unless [`live_tests_enabled`] is true, so this can
422/// never fire a paid call by accident. The loop POSTs OpenAI-format chat
423/// completions to `{base_url}/chat/completions` (Bearer auth via
424/// [`auth_header`]), executes the model's tool calls in memory, appends
425/// `role: tool` results with matching `tool_call_id`, and continues until a
426/// final answer (no tool calls), a violation, or the turn budget is spent.
427/// On violation the run stops immediately with the specific [`FailureClass`].
428pub fn run_tool_loop(
429	account_id: &str,
430	token: &SecretString,
431	base_url: &str,
432	model_id: &str,
433	timeout: Duration,
434) -> Result<ToolLoopOutcome, CloudflareError> {
435	if !live_tests_enabled() {
436		return Err(CloudflareError::MissingEnv {
437			env_var: LIVE_TESTS_ENV,
438			hint: format!(
439				"live multi-turn tool-loop conformance for {model_id} on account {account_id} is opt-in and paid: export {LIVE_TESTS_ENV}=1 to run it (and set AUTH_CLOUDFLARE_MAX_COST_USD to cap spend). Never enable it in CI."
440			),
441		});
442	}
443	if token.as_ref().trim().is_empty() {
444		return Err(CloudflareError::MissingEnv {
445			env_var: crate::auth::TOKEN_ENV,
446			hint: "the API token is empty - export a scoped Workers AI token (Account → Workers AI → Write)"
447				.to_string(),
448		});
449	}
450
451	let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
452	let agent = ureq::AgentBuilder::new().timeout(timeout).build();
453	let mut messages: Vec<serde_json::Value> = vec![
454		serde_json::json!({ "role": "system", "content": TOOL_LOOP_SYSTEM_PROMPT }),
455		serde_json::json!({ "role": "user", "content": TOOL_LOOP_USER_PROMPT }),
456	];
457	let mut calls: Vec<ToolCallObservation> = Vec::new();
458	let mut turn: u32 = 0;
459	loop {
460		turn += 1;
461		let request = serde_json::json!({
462			"model": model_id,
463			"messages": messages,
464			"tools": tool_schemas(),
465			"tool_choice": "auto",
466		});
467		let response = post_chat_completion(&agent, &url, token, &request)?;
468		let message = response
469			.get("choices")
470			.and_then(|choices| choices.as_array())
471			.and_then(|choices| choices.first())
472			.and_then(|choice| choice.get("message"))
473			.cloned()
474			.ok_or_else(|| CloudflareError::Http("tool-loop response missing choices[0].message".to_string()))?;
475		let tool_calls = message.get("tool_calls").and_then(|calls| calls.as_array()).cloned();
476		let has_tool_calls = tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
477		let final_answer = message.get("content").and_then(serde_json::Value::as_str).map(str::to_string);
478		match assess_turn(has_tool_calls, turn, TOOL_LOOP_MAX_TURNS) {
479			TurnVerdict::NoToolCall => {
480				return Ok(ToolLoopOutcome::new(
481					false,
482					turn,
483					TOOL_LOOP_MAX_TURNS,
484					calls,
485					Some(FailureClass::NoToolCall),
486					final_answer,
487				));
488			},
489			TurnVerdict::FinalAnswer => {
490				// The final answer is only a pass when the full workflow ran
491				// in order, with no invalid arguments and no duplicates; an
492				// incomplete or mis-ordered workflow is a non-convergence.
493				let converged =
494					validate_ordering(&calls) && find_duplicates(&calls).is_empty() && all_arguments_valid(&calls);
495				let failure_class = if converged { None } else { Some(FailureClass::ToolLoopDidNotConverge) };
496				return Ok(ToolLoopOutcome::new(
497					converged,
498					turn,
499					TOOL_LOOP_MAX_TURNS,
500					calls,
501					failure_class,
502					final_answer,
503				));
504			},
505			TurnVerdict::TurnLimitBreached => {
506				return Ok(ToolLoopOutcome::new(
507					false,
508					turn,
509					TOOL_LOOP_MAX_TURNS,
510					calls,
511					Some(FailureClass::ToolLoopDidNotConverge),
512					None,
513				));
514			},
515			TurnVerdict::Continue => {
516				let tool_calls = tool_calls.expect("Continue implies non-empty tool calls");
517				let mut turn_calls: Vec<ToolCallObservation> = Vec::new();
518				let mut results: Vec<(String, serde_json::Value)> = Vec::new();
519				for tool_call in &tool_calls {
520					let name = tool_call
521						.pointer("/function/name")
522						.and_then(serde_json::Value::as_str)
523						.unwrap_or("<missing>");
524					let raw_arguments = tool_call
525						.pointer("/function/arguments")
526						.and_then(serde_json::Value::as_str)
527						.unwrap_or_default();
528					let call_id = tool_call.get("id").and_then(serde_json::Value::as_str).unwrap_or_default();
529					// 1. The tool must be one of the three fake tools.
530					if spec_for(name).is_none() {
531						calls.push(ToolCallObservation {
532							name: name.to_string(),
533							arguments: serde_json::Value::String(raw_arguments.to_string()),
534							turn,
535						});
536						return Ok(ToolLoopOutcome::new(
537							false,
538							turn,
539							TOOL_LOOP_MAX_TURNS,
540							calls,
541							Some(FailureClass::InvalidToolName),
542							None,
543						));
544					}
545					// 2. The arguments must parse as JSON.
546					let arguments: serde_json::Value = match serde_json::from_str(raw_arguments) {
547						Ok(value) => value,
548						Err(_) => {
549							calls.push(ToolCallObservation {
550								name: name.to_string(),
551								arguments: serde_json::Value::String(raw_arguments.to_string()),
552								turn,
553							});
554							return Ok(ToolLoopOutcome::new(
555								false,
556								turn,
557								TOOL_LOOP_MAX_TURNS,
558								calls,
559								Some(FailureClass::InvalidToolArguments),
560								None,
561							));
562						},
563					};
564					// 3. The arguments must satisfy the tool schema.
565					if !arguments_valid_for_tool(name, &arguments) {
566						calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
567						return Ok(ToolLoopOutcome::new(
568							false,
569							turn,
570							TOOL_LOOP_MAX_TURNS,
571							calls,
572							Some(FailureClass::InvalidToolArguments),
573							None,
574						));
575					}
576					// 4. The same tool with identical arguments must not be
577					// issued twice (checked against every executed call).
578					let duplicated = calls
579						.iter()
580						.chain(turn_calls.iter())
581						.any(|prior| prior.name == name && prior.arguments == arguments);
582					if duplicated {
583						calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
584						return Ok(ToolLoopOutcome::new(
585							false,
586							turn,
587							TOOL_LOOP_MAX_TURNS,
588							calls,
589							Some(FailureClass::DuplicateToolCall),
590							None,
591						));
592					}
593					// Schema validation guarantees execution succeeds; the
594					// error arm is a defense-in-depth safety net.
595					let result = execute_tool(name, &arguments)
596						.map_err(|message| CloudflareError::Http(redact_token(&message, token.as_ref())))?;
597					turn_calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
598					results.push((call_id.to_string(), result));
599				}
600				// Echo the assistant message with its tool calls, then append
601				// one `role: tool` result per call (OpenAI format).
602				messages.push(serde_json::json!({ "role": "assistant", "content": null, "tool_calls": tool_calls }));
603				for (call_id, result) in results {
604					messages.push(serde_json::json!({
605						"role": "tool",
606						"tool_call_id": call_id,
607						"content": result.to_string(),
608					}));
609				}
610				calls.extend(turn_calls);
611			},
612		}
613	}
614}
615
616/// POST one chat-completions request with Bearer auth and map failures to
617/// the typed error taxonomy. Every text source is token-scrubbed before it
618/// reaches an error string.
619fn post_chat_completion(
620	agent: &ureq::Agent,
621	url: &str,
622	token: &SecretString,
623	body: &serde_json::Value,
624) -> Result<serde_json::Value, CloudflareError> {
625	let request = agent
626		.post(url)
627		.set("Authorization", &auth_header(token))
628		.set("Accept", "application/json")
629		.set("Content-Type", "application/json");
630	let payload = body.to_string();
631	let (status, response) = match request.send_string(&payload) {
632		Ok(response) => (response.status(), response),
633		Err(ureq::Error::Status(status, response)) => (status, response),
634		Err(transport) => {
635			return Err(CloudflareError::Http(redact_token(&transport.to_string(), token.as_ref())));
636		},
637	};
638	let raw = response.into_string().map_err(|error| {
639		CloudflareError::Http(redact_token(&format!("read response body: {error}"), token.as_ref()))
640	})?;
641	let raw = redact_token(&raw, token.as_ref());
642	if status != 200 {
643		return Err(map_http_error(status, &raw));
644	}
645	serde_json::from_str(&raw).map_err(|_| CloudflareError::Http("tool-loop response was not valid JSON".to_string()))
646}
647
648/// Map a non-200 chat-completions status to the typed error taxonomy,
649/// carrying the Cloudflare envelope message when present.
650fn map_http_error(status: u16, body: &str) -> CloudflareError {
651	let labeled = |code: u16, label: &str| -> CloudflareError {
652		CloudflareError::Api {
653			code: u32::from(code),
654			message: format!(
655				"{label} (HTTP {code}){}",
656				envelope_message(body).map(|m| format!(": {m}")).unwrap_or_default()
657			),
658		}
659	};
660	match status {
661		401 => labeled(401, "unauthorized"),
662		403 => labeled(403, "forbidden"),
663		429 => labeled(429, "rate limited"),
664		500..=599 => {
665			CloudflareError::Http(format!("tool-loop endpoint returned HTTP {status} (transient server error)"))
666		},
667		other => CloudflareError::Api { code: u32::from(other), message: format!("HTTP {other}: {body}") },
668	}
669}
670
671/// First Cloudflare envelope `errors[0].message`, if the body parses.
672fn envelope_message(body: &str) -> Option<String> {
673	serde_json::from_str::<serde_json::Value>(body)
674		.ok()?
675		.get("errors")?
676		.as_array()?
677		.first()?
678		.get("message")?
679		.as_str()
680		.map(str::to_string)
681}
682
683/// Replace the token with a redaction marker in any text that could reach an
684/// error string (defense in depth - mirrors `fetch.rs`).
685fn redact_token(text: &str, token: &str) -> String {
686	if token.is_empty() {
687		text.to_string()
688	} else {
689		text.replace(token, "<redacted>")
690	}
691}
692
693#[cfg(test)]
694mod tests {
695	use super::*;
696	use serde_json::json;
697
698	fn obs(name: &str, arguments: serde_json::Value, turn: u32) -> ToolCallObservation {
699		ToolCallObservation { name: name.to_string(), arguments, turn }
700	}
701
702	#[test]
703	fn ordering_accepts_the_full_workflow() {
704		let calls = vec![
705			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
706			obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
707			obs(
708				"write_fixture_patch",
709				json!({"fixture_id": "calc", "patch": "fix the off-by-one"}),
710				3,
711			),
712		];
713		assert!(validate_ordering(&calls));
714	}
715
716	#[test]
717	fn ordering_allows_extra_reads_before_the_run() {
718		// Two different fixture reads, then run, then write: non-decreasing.
719		let calls = vec![
720			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
721			obs("read_fixture", json!({"fixture_id": "greeter"}), 1),
722			obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
723			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
724		];
725		assert!(validate_ordering(&calls));
726	}
727
728	#[test]
729	fn ordering_rejects_reread_after_run() {
730		// Re-reading after a successful run is a violation.
731		let calls = vec![
732			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
733			obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
734			obs("read_fixture", json!({"fixture_id": "calc"}), 3),
735		];
736		assert!(!validate_ordering(&calls));
737	}
738
739	#[test]
740	fn ordering_rejects_run_before_read_and_run_after_write() {
741		let run_first = vec![
742			obs("run_fixture_test", json!({"fixture_id": "calc"}), 1),
743			obs("read_fixture", json!({"fixture_id": "calc"}), 2),
744			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
745		];
746		assert!(!validate_ordering(&run_first));
747		let write_then_run = vec![
748			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
749			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 2),
750			obs("run_fixture_test", json!({"fixture_id": "calc"}), 3),
751		];
752		assert!(!validate_ordering(&write_then_run));
753	}
754
755	#[test]
756	fn ordering_rejects_incomplete_workflows() {
757		// Read-only: the model stopped before running or patching.
758		let read_only = vec![obs("read_fixture", json!({"fixture_id": "calc"}), 1)];
759		assert!(!validate_ordering(&read_only));
760		// Read → write, skipping the run: the workflow is incomplete.
761		let skipped_run = vec![
762			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
763			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 2),
764		];
765		assert!(!validate_ordering(&skipped_run));
766		assert!(!validate_ordering(&[]));
767	}
768
769	#[test]
770	fn ordering_ignores_unknown_tool_names() {
771		// Unknown names are a name-check concern, not an ordering concern.
772		let calls = vec![
773			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
774			obs("some_bogus_tool", json!({}), 1),
775			obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
776			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
777		];
778		assert!(validate_ordering(&calls));
779	}
780
781	#[test]
782	fn duplicates_are_detected_by_name_and_identical_arguments() {
783		let repeated_read = vec![
784			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
785			obs("read_fixture", json!({"fixture_id": "calc"}), 2),
786		];
787		let duplicates = find_duplicates(&repeated_read);
788		assert_eq!(duplicates.len(), 1);
789		assert_eq!(duplicates[0].name, "read_fixture");
790		assert_eq!(duplicates[0].turn, 2);
791
792		// Same tool, different arguments: NOT a duplicate.
793		let different_args = vec![
794			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
795			obs("read_fixture", json!({"fixture_id": "greeter"}), 1),
796		];
797		assert!(find_duplicates(&different_args).is_empty());
798
799		// Repeated run after a successful run is a duplicate too.
800		let repeated_run = vec![
801			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
802			obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
803			obs("run_fixture_test", json!({"fixture_id": "calc"}), 3),
804		];
805		assert_eq!(find_duplicates(&repeated_run).len(), 1);
806
807		// JSON object key order does not matter for identical arguments.
808		let reordered = vec![
809			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 1),
810			obs("write_fixture_patch", json!({"patch": "fix", "fixture_id": "calc"}), 2),
811		];
812		assert_eq!(find_duplicates(&reordered).len(), 1);
813	}
814
815	#[test]
816	fn argument_validation_accepts_exact_calls() {
817		let calls = vec![
818			obs("read_fixture", json!({"fixture_id": "calc"}), 1),
819			obs("run_fixture_test", json!({"fixture_id": "greeter"}), 2),
820			obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix add"}), 3),
821		];
822		assert!(all_arguments_valid(&calls));
823	}
824
825	#[test]
826	fn argument_validation_rejects_missing_required_fields() {
827		assert!(!all_arguments_valid(&[obs("read_fixture", json!({}), 1)]));
828		assert!(!all_arguments_valid(&[obs(
829			"write_fixture_patch",
830			json!({"fixture_id": "calc"}),
831			1
832		)]));
833	}
834
835	#[test]
836	fn argument_validation_rejects_wrong_enum_value() {
837		assert!(!all_arguments_valid(&[obs("read_fixture", json!({"fixture_id": "nope"}), 1)]));
838	}
839
840	#[test]
841	fn argument_validation_rejects_extra_properties() {
842		// additionalProperties: false must be respected.
843		assert!(!all_arguments_valid(&[obs(
844			"read_fixture",
845			json!({"fixture_id": "calc", "extra": 1}),
846			1
847		)]));
848	}
849
850	#[test]
851	fn argument_validation_rejects_wrong_types_and_unknown_tools() {
852		assert!(!all_arguments_valid(&[obs("read_fixture", json!({"fixture_id": 42}), 1)]));
853		assert!(!all_arguments_valid(&[obs("bogus_tool", json!({}), 1)]));
854		assert!(!all_arguments_valid(&[obs(
855			"read_fixture",
856			serde_json::Value::String("not an object".to_string()),
857			1
858		)]));
859	}
860
861	#[test]
862	fn convergence_counting_on_synthetic_turns() {
863		// Turns 1..3 carry tools, turn 4 is the final answer: 4 turns used.
864		assert_eq!(assess_turn(true, 1, 8), TurnVerdict::Continue);
865		assert_eq!(assess_turn(true, 2, 8), TurnVerdict::Continue);
866		assert_eq!(assess_turn(true, 3, 8), TurnVerdict::Continue);
867		assert_eq!(assess_turn(false, 4, 8), TurnVerdict::FinalAnswer);
868		// Turn 1 without tools: NoToolCall, even though prose was produced.
869		assert_eq!(assess_turn(false, 1, 8), TurnVerdict::NoToolCall);
870		// Tools on the max turn: the budget is breached.
871		assert_eq!(assess_turn(true, 8, 8), TurnVerdict::TurnLimitBreached);
872		// A final answer ON the max turn is within budget (turns ≤ 8).
873		assert_eq!(assess_turn(false, 8, 8), TurnVerdict::FinalAnswer);
874	}
875
876	#[test]
877	fn outcome_serde_roundtrip_and_snake_case() {
878		let outcome = ToolLoopOutcome::new(
879			true,
880			4,
881			TOOL_LOOP_MAX_TURNS,
882			vec![
883				obs("read_fixture", json!({"fixture_id": "calc"}), 1),
884				obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
885				obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
886			],
887			None,
888			Some("final status: pass".to_string()),
889		);
890		let json = serde_json::to_string(&outcome).expect("ser");
891		let back: ToolLoopOutcome = serde_json::from_str(&json).expect("de");
892		assert_eq!(back, outcome);
893		let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
894		for key in [
895			"converged",
896			"turns_used",
897			"max_turns",
898			"tool_calls",
899			"failure_class",
900			"final_answer",
901		] {
902			assert!(value.get(key).is_some(), "missing snake_case key {key}");
903		}
904		assert_eq!(value["tool_calls"][0]["arguments"]["fixture_id"], "calc");
905		assert!(value["tool_calls"][0].get("turn").is_some());
906		assert_eq!(value["converged"], true);
907		// The outcome must never carry tool outputs or prompts.
908		let rendered = json.to_lowercase();
909		assert!(
910			!rendered.contains("assertion failed"),
911			"tool outputs must never enter the outcome"
912		);
913		assert!(!rendered.contains("test harness"), "prompts must never enter the outcome");
914		assert!(!rendered.contains("\"source\""), "tool outputs must never enter the outcome");
915	}
916
917	#[test]
918	fn final_answer_is_truncated_char_safe() {
919		let long = "€".repeat(600);
920		let outcome = ToolLoopOutcome::new(true, 1, 8, vec![], None, Some(long.clone()));
921		let answer = outcome.final_answer.expect("answer present");
922		assert_eq!(answer.chars().count(), MAX_EXCERPT_CHARS);
923		assert_eq!(answer, "€".repeat(MAX_EXCERPT_CHARS));
924		let short = ToolLoopOutcome::new(true, 1, 8, vec![], None, Some("ok".to_string()));
925		assert_eq!(short.final_answer.as_deref(), Some("ok"));
926	}
927
928	#[test]
929	fn live_gate_closed_returns_refusal_without_network() {
930		// Ensure the gate is closed regardless of the developer's environment,
931		// then prove the refusal happens BEFORE any network is attempted.
932		with_live_tests_env(None, || {
933			let token = SecretString::new("cfut_test_synthetic_token_0001");
934			let error = run_tool_loop(
935				"0123456789abcdef0123456789abcdef",
936				&token,
937				"https://example.test/ai/v1",
938				"@cf/deepseek-ai/deepseek-v4-flash-0731",
939				Duration::from_secs(1),
940			)
941			.expect_err("gate must refuse without AUTH_CLOUDFLARE_LIVE_TESTS=1");
942			match error {
943				CloudflareError::MissingEnv { env_var, hint } => {
944					assert_eq!(env_var, LIVE_TESTS_ENV);
945					assert!(hint.contains(LIVE_TESTS_ENV), "hint must name the env var: {hint}");
946					assert!(
947						hint.to_lowercase().contains("opt-in"),
948						"hint must explain the opt-in gate: {hint}"
949					);
950				},
951				other => panic!("expected MissingEnv refusal, got {other:?}"),
952			}
953		});
954	}
955
956	#[test]
957	fn live_tests_enabled_matches_exactly_one() {
958		with_live_tests_env(Some("1"), || assert!(live_tests_enabled()));
959		with_live_tests_env(None, || assert!(!live_tests_enabled()));
960		with_live_tests_env(Some("0"), || assert!(!live_tests_enabled()));
961		with_live_tests_env(Some("yes"), || assert!(!live_tests_enabled()));
962		with_live_tests_env(Some("1 "), || {
963			assert!(!live_tests_enabled(), "whitespace is not exactly '1'");
964		});
965	}
966
967	#[test]
968	fn fake_tools_are_deterministic_and_in_memory() {
969		let read = execute_tool("read_fixture", &json!({"fixture_id": "calc"})).expect("read succeeds");
970		assert_eq!(read["status"], "success");
971		let source = read["source"].as_str().expect("source present");
972		assert!(source.contains("fn add"));
973		assert!(source.contains("assert_eq!(add(2, 2), 4)"));
974		assert_eq!(
975			execute_tool("read_fixture", &json!({"fixture_id": "calc"})).expect("deterministic"),
976			read
977		);
978
979		let run = execute_tool("run_fixture_test", &json!({"fixture_id": "calc"})).expect("run succeeds");
980		assert_eq!(run["status"], "fail", "the report is a controlled failure");
981		assert_eq!(run["output"], "assertion failed: add(2, 2) == 4, got 3");
982
983		let write = execute_tool("write_fixture_patch", &json!({"fixture_id": "calc", "patch": "fix"}))
984			.expect("write succeeds");
985		assert_eq!(write["status"], "success");
986		assert_eq!(write["applied"].as_bool(), Some(true));
987
988		assert!(execute_tool("read_fixture", &json!({"fixture_id": "nope"})).is_err());
989		assert!(execute_tool("bogus", &json!({})).is_err());
990	}
991
992	#[test]
993	fn tool_schemas_are_openai_function_shaped() {
994		let schemas = tool_schemas();
995		assert_eq!(schemas.len(), 3);
996		let names: Vec<&str> = schemas.iter().map(|s| s["function"]["name"].as_str().unwrap()).collect();
997		assert_eq!(names, vec!["read_fixture", "run_fixture_test", "write_fixture_patch"]);
998		for schema in &schemas {
999			assert_eq!(schema["type"], "function");
1000			assert_eq!(schema["function"]["parameters"]["type"], "object");
1001			assert_eq!(schema["function"]["parameters"]["additionalProperties"].as_bool(), Some(false));
1002			let required = schema["function"]["parameters"]["required"]
1003				.as_array()
1004				.expect("required present");
1005			assert!(!required.is_empty());
1006		}
1007		// fixture_id is enum-constrained to the deterministic fixture ids.
1008		assert_eq!(
1009			schemas[0]["function"]["parameters"]["properties"]["fixture_id"]["enum"],
1010			json!(["calc", "greeter"])
1011		);
1012	}
1013
1014	/// `std::env` is process-global and tests run in parallel - serialize env
1015	/// mutation through a static mutex and restore prior values after.
1016	static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1017
1018	fn with_live_tests_env(value: Option<&str>, f: impl FnOnce()) {
1019		let _guard = ENV_LOCK.lock().unwrap();
1020		let saved = std::env::var(LIVE_TESTS_ENV).ok();
1021		match value {
1022			Some(value) => std::env::set_var(LIVE_TESTS_ENV, value),
1023			None => std::env::remove_var(LIVE_TESTS_ENV),
1024		}
1025		f();
1026		match saved {
1027			Some(saved) => std::env::set_var(LIVE_TESTS_ENV, saved),
1028			None => std::env::remove_var(LIVE_TESTS_ENV),
1029		}
1030	}
1031}