Skip to main content

auth_cloudflare/
tool_loop.rs

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