Skip to main content

embacle/
tool_simulation.rs

1// ABOUTME: Text-based tool simulation for CLI LLM runners that lack native function calling
2// ABOUTME: Provides catalog generation, tool call parsing, result formatting, and a full loop
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! # Text-Based Tool Simulation
8//!
9//! CLI LLM runners (Claude Code, Copilot, Cursor Agent, OpenCode) communicate
10//! via plain text and do not support native function calling. This module
11//! provides a **text-based tool simulation** layer that enables tool calling by:
12//!
13//! 1. Generating a markdown **tool catalog** from function declarations and
14//!    injecting it into the system prompt
15//! 2. Parsing `<tool_call>` XML blocks from LLM text output
16//! 3. Formatting tool results as `<tool_result>` XML blocks for re-injection
17//! 4. Running a full multi-turn **tool loop** that iterates until the LLM
18//!    produces a final text response
19//!
20//! This is the CLI counterpart to the SDK-managed tool calling in
21//! `CopilotHeadlessRunner` (requires `copilot-headless` feature).
22//!
23//! ## Quick Start
24//!
25//! ```rust,no_run
26//! use embacle::tool_simulation::*;
27//! use embacle::types::{ChatMessage, ChatRequest, LlmProvider};
28//! use serde_json::json;
29//! use std::sync::Arc;
30//!
31//! # async fn example(provider: &dyn LlmProvider) -> Result<(), embacle::types::RunnerError> {
32//! let declarations = vec![
33//!     FunctionDeclaration {
34//!         name: "get_weather".into(),
35//!         description: "Get weather for a city".into(),
36//!         parameters: Some(json!({"type": "object", "properties": {"city": {"type": "string"}}})),
37//!     },
38//! ];
39//!
40//! let handler: TextToolHandler = Arc::new(|name, args| {
41//!     FunctionResponse {
42//!         name: name.to_owned(),
43//!         response: json!({"temperature": 72}),
44//!     }
45//! });
46//!
47//! let mut messages = vec![
48//!     ChatMessage::system("You are a helpful assistant."),
49//!     ChatMessage::user("What's the weather in Paris?"),
50//! ];
51//!
52//! let result = execute_with_text_tools(
53//!     provider, &mut messages, &declarations, handler, 5,
54//! ).await?;
55//! println!("{}", result.content);
56//! # Ok(())
57//! # }
58//! ```
59
60use crate::types::{
61    ChatMessage, ChatRequest, ChatResponse, LlmProvider, MessageRole, RunnerError, TokenUsage,
62    ToolCallRequest, ToolDefinition,
63};
64use serde_json::Value;
65use std::fmt::Write;
66use std::sync::Arc;
67use tracing::{debug, info, warn};
68
69// ============================================================================
70// Types
71// ============================================================================
72
73/// A tool definition describing a callable function.
74///
75/// This is a type alias for [`ToolDefinition`] from core types, maintaining
76/// backward compatibility with existing code that uses `FunctionDeclaration`.
77pub type FunctionDeclaration = ToolDefinition;
78
79/// A parsed tool call extracted from LLM text output.
80///
81/// Produced by [`parse_tool_call_blocks()`] when an LLM response contains
82/// `<tool_call>` XML blocks.
83#[derive(Debug, Clone)]
84pub struct FunctionCall {
85    /// Name of the function to call
86    pub name: String,
87    /// Arguments for the function as a JSON object
88    pub args: Value,
89}
90
91impl From<ToolCallRequest> for FunctionCall {
92    fn from(tc: ToolCallRequest) -> Self {
93        Self {
94            name: tc.function_name,
95            args: tc.arguments,
96        }
97    }
98}
99
100impl From<FunctionCall> for ToolCallRequest {
101    fn from(fc: FunctionCall) -> Self {
102        Self {
103            id: format!("call_{}", fc.name),
104            function_name: fc.name,
105            arguments: fc.args,
106        }
107    }
108}
109
110/// A tool execution result to feed back to the LLM.
111///
112/// Produced by the caller's tool handler and formatted as `<tool_result>`
113/// blocks by [`format_tool_results_as_text()`].
114#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
115pub struct FunctionResponse {
116    /// Name of the function that was called
117    pub name: String,
118    /// Response content from the function
119    pub response: Value,
120}
121
122/// Internal deserialization target for `<tool_call>` JSON payloads
123#[derive(serde::Deserialize)]
124struct ToolCallPayload {
125    name: String,
126    #[serde(default)]
127    arguments: Option<Value>,
128}
129
130/// Callback type for executing tool calls.
131///
132/// Given a tool name and its arguments, returns a [`FunctionResponse`].
133/// This is the CLI counterpart to the SDK's `ToolHandler`.
134pub type TextToolHandler = Arc<dyn Fn(&str, &Value) -> FunctionResponse + Send + Sync>;
135
136/// Result of a text-based tool-calling conversation.
137///
138/// Analogous to [`HeadlessToolResponse`](crate::copilot_headless::HeadlessToolResponse)
139/// (requires `copilot-headless` feature) but for CLI providers.
140#[derive(Debug, Clone)]
141pub struct TextToolResponse {
142    /// Final text content from the LLM (with tool call blocks stripped)
143    pub content: String,
144    /// Token usage statistics from the last LLM call
145    pub usage: Option<TokenUsage>,
146    /// Finish reason from the last LLM call
147    pub finish_reason: Option<String>,
148    /// Total number of tool calls executed across all iterations
149    pub tool_calls_count: u32,
150}
151
152// ============================================================================
153// Tool Catalog Generation
154// ============================================================================
155
156/// Generate a text-based tool catalog from function declarations.
157///
158/// Produces a structured prompt that CLI-based LLMs will follow to emit
159/// `<tool_call>` XML blocks. The catalog uses code-generation framing
160/// ("generate the correct XML output") rather than tool-use framing
161/// ("you have tools available") because coding-assistant LLMs like Copilot
162/// refuse the latter due to their system prompt anchoring. Includes a
163/// few-shot example derived from the first declared function.
164///
165/// # Example
166///
167/// ```
168/// use embacle::tool_simulation::{FunctionDeclaration, generate_tool_catalog};
169/// use serde_json::json;
170///
171/// let decls = vec![FunctionDeclaration {
172///     name: "search".into(),
173///     description: "Search the web".into(),
174///     parameters: Some(json!({"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]})),
175/// }];
176///
177/// let catalog = generate_tool_catalog(&decls);
178/// assert!(catalog.contains("### search"));
179/// assert!(catalog.contains("`q` (string, required)"));
180/// ```
181#[must_use]
182pub fn generate_tool_catalog(declarations: &[FunctionDeclaration]) -> String {
183    let mut catalog = String::with_capacity(4096);
184
185    // Present the tools as a natural capability the assistant already has, not as
186    // an adversarial "I am testing a function-calling protocol / output ONLY XML"
187    // instruction. Reasoning-tuned models (Copilot-served Claude Sonnet from
188    // 2026-07 onward) read the old framing — plus the "Registered functions" label
189    // — as an injected jailbreak and refuse the whole turn, breaking character
190    // ("I'm GitHub Copilot, not <persona>"). Proven by A/B against live Copilot:
191    // an identical poisoned conversation history refuses under the old framing and
192    // stays fully in role under this one. The <tool_call> wire format is unchanged
193    // so parse_tool_call_blocks / strip_simulation_artifacts keep working verbatim.
194    catalog.push_str("\n\n");
195    catalog.push_str(
196        "You have access to the tools listed below to help with the user's \
197         request. When a tool would help, call it by emitting a block in exactly \
198         this format:\n\n",
199    );
200    catalog.push_str(
201        "<tool_call>\n{\"name\": \"FUNCTION_NAME\", \"arguments\": {\"PARAM\": \"VALUE\"}}\n</tool_call>\n\n",
202    );
203    catalog.push_str(
204        "Notes:\n\
205         - Emit a <tool_call> block whenever you need data or an action a tool \
206         provides; you may emit several blocks if more than one tool applies.\n\
207         - Only call the tools listed under \"Available tools\" below. Other tools \
208         (Glob, Grep, Read, Bash, Edit, Write, etc.) are not available in this environment.\n\
209         - After each call you receive a <tool_result> block; use its data to \
210         answer the user.\n\n",
211    );
212
213    // Function definitions
214    catalog.push_str("Available tools:\n\n");
215    for decl in declarations {
216        let _ = writeln!(catalog, "### {}", decl.name);
217        let _ = writeln!(catalog, "{}", decl.description);
218        append_parameter_docs(&mut catalog, decl);
219        catalog.push('\n');
220    }
221
222    // Few-shot example using the first declared function
223    if let Some(first) = declarations.first() {
224        append_few_shot_example(&mut catalog, first);
225    }
226
227    catalog
228}
229
230/// Deepest schema-node recursion when documenting a parameter.
231///
232/// A guard against a pathological or self-referential schema inflating the
233/// system prompt without bound, not a working limit. Note it counts schema
234/// nodes, and stepping from an array to its `items` costs a level, so the
235/// deepest real schema — `weeks` → week → `days` → day → day fields — sits at
236/// five, comfortably inside this.
237const MAX_PARAM_DEPTH: usize = 8;
238
239/// Append parameter documentation for a single function declaration
240fn append_parameter_docs(catalog: &mut String, decl: &FunctionDeclaration) {
241    let Some(ref params) = decl.parameters else {
242        return;
243    };
244    let Some(props_obj) = params.get("properties").and_then(|p| p.as_object()) else {
245        return;
246    };
247    if props_obj.is_empty() {
248        return;
249    }
250
251    catalog.push_str("Parameters:\n");
252    append_property_lines(catalog, params, 0);
253}
254
255/// Render one object schema's `properties` as bullet lines, recursing into
256/// nested objects and into the item schema of arrays of objects.
257///
258/// Without the recursion a nested parameter reached the model as nothing but
259/// its top-level name and type — `` `outline` (object) `` — leaving every inner
260/// field name invisible. On providers without native function calling the
261/// catalog is the *only* schema the model ever sees, so it had to guess the
262/// nested shape and guessed wrong every time: `save_training_plan` failed 24 of
263/// 24 live calls (2026-07-12 → 2026-07-28) while every flat-schema tool
264/// succeeded, the model sending `week_label`/`day`/`session` against a schema
265/// wanting `week_start`/`date`/`sport`/`workout`.
266///
267/// A parameter expands only when it genuinely has nested fields, which keeps
268/// the rendering of a purely scalar schema byte-identical to the pre-recursion
269/// output — tools that already worked gain neither a token nor a risk.
270fn append_property_lines(catalog: &mut String, schema: &Value, depth: usize) {
271    let Some(props_obj) = schema.get("properties").and_then(|p| p.as_object()) else {
272        return;
273    };
274    let required: Vec<&str> = schema
275        .get("required")
276        .and_then(|r| r.as_array())
277        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
278        .unwrap_or_default();
279
280    let indent = "  ".repeat(depth);
281    for (name, prop) in props_obj {
282        let type_str = prop.get("type").and_then(|t| t.as_str()).unwrap_or("any");
283        let is_required = required.contains(&name.as_str());
284        let req_label = if is_required { ", required" } else { "" };
285
286        let nested = nested_object_schema(prop).filter(|_| depth + 1 < MAX_PARAM_DEPTH);
287
288        // Describe every field the recursion reveals — whether it expands
289        // further or is a leaf. The format hints that make a nested field
290        // fillable at all live on the leaves ("Race date, YYYY-MM-DD.", "One
291        // of: rest | base | build | peak | taper."); a bare `date (string)`
292        // is exactly what let the model send a French week label.
293        //
294        // A top-level scalar stays bare, which is what keeps a schema with no
295        // nesting rendering byte-for-byte as it did before the recursion — the
296        // tools already succeeding on every call gain neither token nor risk.
297        let describe = depth > 0 || nested.is_some();
298        let description = prop
299            .get("description")
300            .and_then(|d| d.as_str())
301            .filter(|_| describe)
302            .map_or_else(String::new, |d| format!(" — {d}"));
303        let label = if nested.is_some() && type_str == "array" {
304            "array of object"
305        } else {
306            type_str
307        };
308        let _ = writeln!(
309            catalog,
310            "{indent}- `{name}` ({label}{req_label}){description}"
311        );
312
313        if let Some(inner) = nested {
314            append_property_lines(catalog, inner, depth + 1);
315        }
316    }
317}
318
319/// The object schema a parameter expands into — itself when it is an object
320/// carrying `properties`, or its `items` when it is an array of such objects.
321///
322/// `None` for a scalar or an array of scalars, which have no inner field names
323/// to reveal and so keep their single-line rendering.
324fn nested_object_schema(prop: &Value) -> Option<&Value> {
325    let has_fields = |v: &Value| {
326        v.get("properties")
327            .and_then(|p| p.as_object())
328            .is_some_and(|p| !p.is_empty())
329    };
330    if has_fields(prop) {
331        return Some(prop);
332    }
333    prop.get("items").filter(|items| has_fields(items))
334}
335
336/// Append a few-shot example showing the expected tool-call interaction
337fn append_few_shot_example(catalog: &mut String, decl: &FunctionDeclaration) {
338    catalog.push_str("Example interaction:\n\n");
339
340    // Build a plausible example argument from the first required param (or first param)
341    let example_args = build_example_args(decl);
342    let args_json = serde_json::to_string(&example_args).unwrap_or_else(|_| "{}".to_owned());
343
344    let _ = writeln!(catalog, "User: [asks a question related to {}]", decl.name);
345    catalog.push_str("Assistant:\n");
346    let _ = writeln!(
347        catalog,
348        "<tool_call>\n{{\"name\": \"{}\", \"arguments\": {args_json}}}\n</tool_call>",
349        decl.name
350    );
351}
352
353/// Build example arguments from a function declaration's parameter schema
354fn build_example_args(decl: &FunctionDeclaration) -> serde_json::Map<String, Value> {
355    let Some(ref params) = decl.parameters else {
356        return serde_json::Map::new();
357    };
358    match example_for_schema(params, 0) {
359        Value::Object(map) => map,
360        _ => serde_json::Map::new(),
361    }
362}
363
364/// Build a shape-correct example value for one schema node.
365///
366/// Recurses so a nested parameter is demonstrated as the object it actually is.
367/// The pre-recursion generator emitted `"outline": "example"` for an
368/// object-typed parameter — a worked example contradicting the very shape the
369/// model is being asked to produce, at the moment it is learning the format.
370fn example_for_schema(schema: &Value, depth: usize) -> Value {
371    let type_str = schema
372        .get("type")
373        .and_then(|t| t.as_str())
374        .unwrap_or("string");
375    match type_str {
376        "object" => {
377            let Some(props) = schema.get("properties").and_then(|p| p.as_object()) else {
378                return Value::Object(serde_json::Map::new());
379            };
380            if depth + 1 >= MAX_PARAM_DEPTH {
381                return Value::Object(serde_json::Map::new());
382            }
383            let mut map = serde_json::Map::new();
384            for (name, prop) in props {
385                map.insert(name.clone(), example_for_schema(prop, depth + 1));
386            }
387            Value::Object(map)
388        }
389        "array" => match schema.get("items") {
390            Some(items) if depth + 1 < MAX_PARAM_DEPTH => {
391                Value::Array(vec![example_for_schema(items, depth + 1)])
392            }
393            // No item schema to follow: the original placeholder element.
394            _ => Value::Array(vec![Value::String("example".to_owned())]),
395        },
396        "integer" | "number" => Value::Number(serde_json::Number::from(1)),
397        "boolean" => Value::Bool(true),
398        _ => Value::String("example".to_owned()),
399    }
400}
401
402/// Inject a tool catalog into the system prompt of a message list.
403///
404/// If the first message is a system message, the catalog is appended to it.
405/// Otherwise a new system message is inserted at position 0.
406pub fn inject_tool_catalog(messages: &mut Vec<ChatMessage>, catalog: &str) {
407    if let Some(system_msg) = messages.first_mut() {
408        if system_msg.role == MessageRole::System {
409            let augmented = format!("{}{catalog}", system_msg.content);
410            *system_msg = ChatMessage::system(augmented);
411            return;
412        }
413    }
414    // No system message found — insert one at position 0
415    messages.insert(0, ChatMessage::system(catalog));
416}
417
418// ============================================================================
419// Tool Call Parser
420// ============================================================================
421
422/// Parse `<tool_call>` blocks from LLM text output into structured function calls.
423///
424/// Expected format:
425/// ```text
426/// <tool_call>
427/// {"name": "get_activities", "arguments": {"provider": "strava", "limit": 25}}
428/// </tool_call>
429/// ```
430///
431/// Tolerant parser: malformed JSON blocks are skipped with a warning log.
432#[must_use]
433pub fn parse_tool_call_blocks(content: &str) -> Vec<FunctionCall> {
434    let mut calls = Vec::new();
435    let mut search_from = 0;
436
437    while let Some(start) = content[search_from..].find("<tool_call>") {
438        let abs_start = search_from + start + "<tool_call>".len();
439        let Some(end) = content[abs_start..].find("</tool_call>") else {
440            warn!("Found <tool_call> without matching </tool_call>");
441            break;
442        };
443        let abs_end = abs_start + end;
444        let json_str = content[abs_start..abs_end].trim();
445
446        match serde_json::from_str::<ToolCallPayload>(json_str) {
447            Ok(payload) => {
448                info!("Parsed tool call: {}", payload.name);
449                calls.push(FunctionCall {
450                    name: payload.name,
451                    args: payload
452                        .arguments
453                        .unwrap_or_else(|| Value::Object(serde_json::Map::new())),
454                });
455            }
456            Err(e) => {
457                warn!(
458                    "Failed to parse <tool_call> JSON ({} bytes): {e}",
459                    json_str.len()
460                );
461            }
462        }
463
464        search_from = abs_end + "</tool_call>".len();
465    }
466
467    calls
468}
469
470/// Strip `<tool_call>...</tool_call>` blocks from text, returning remaining content.
471///
472/// Useful for extracting the LLM's conversational text without the embedded
473/// tool invocations. Unclosed `<tool_call>` tags cause the rest of the text
474/// after the tag to be dropped.
475#[must_use]
476pub fn strip_tool_call_blocks(content: &str) -> String {
477    let mut result = String::with_capacity(content.len());
478    let mut search_from = 0;
479
480    while let Some(start) = content[search_from..].find("<tool_call>") {
481        let abs_start = search_from + start;
482        result.push_str(&content[search_from..abs_start]);
483
484        let close_tag = "</tool_call>";
485        if let Some(end) = content[abs_start..].find(close_tag) {
486            search_from = abs_start + end + close_tag.len();
487        } else {
488            // Unclosed tag — include the rest as-is
489            search_from = content.len();
490        }
491    }
492    result.push_str(&content[search_from..]);
493    result.trim().to_owned()
494}
495
496// ============================================================================
497// Tool Result Formatting
498// ============================================================================
499
500/// Literal preamble [`format_tool_results_as_text`] prepends to the injected
501/// tool-result turn. Defined as a constant so [`strip_tool_result_echo`] can
502/// remove it verbatim — the formatter and the stripper MUST stay in lockstep,
503/// otherwise an echoed preamble leaks to the user.
504const TOOL_RESULTS_PREAMBLE: &str = "Here are the results from the tools you requested:";
505
506/// Literal trailing instruction [`format_tool_results_as_text`] appends to the
507/// injected tool-result turn. Stripped by [`strip_tool_result_echo`] for the
508/// same lockstep reason as [`TOOL_RESULTS_PREAMBLE`].
509const TOOL_RESULTS_FOOTER: &str =
510    "Please analyze the data above and respond to the user's question.";
511
512/// Format function responses as text for injection into follow-up messages.
513///
514/// Uses `<tool_result>` blocks so the LLM can distinguish tool output from
515/// conversational text.
516///
517/// # Example
518///
519/// ```
520/// use embacle::tool_simulation::{FunctionResponse, format_tool_results_as_text};
521/// use serde_json::json;
522///
523/// let responses = vec![FunctionResponse {
524///     name: "search".into(),
525///     response: json!({"results": ["a", "b"]}),
526/// }];
527///
528/// let text = format_tool_results_as_text(&responses);
529/// assert!(text.contains("<tool_result name=\"search\">"));
530/// assert!(text.contains("</tool_result>"));
531/// ```
532#[must_use]
533pub fn format_tool_results_as_text(responses: &[FunctionResponse]) -> String {
534    let mut text = String::with_capacity(4096);
535    text.push_str(TOOL_RESULTS_PREAMBLE);
536    text.push_str("\n\n");
537
538    for resp in responses {
539        let _ = writeln!(text, "<tool_result name=\"{}\">", resp.name);
540        let json_str =
541            serde_json::to_string_pretty(&resp.response).unwrap_or_else(|_| "{}".to_owned());
542        let _ = writeln!(text, "{json_str}");
543        text.push_str("</tool_result>\n\n");
544    }
545
546    text.push_str(TOOL_RESULTS_FOOTER);
547    text
548}
549
550/// Strip echoed tool-result scaffolding from a model's reply.
551///
552/// In CLI/simulation mode, tool outputs are fed back to the model as a
553/// synthetic user turn built by [`format_tool_results_as_text`] — a
554/// [`TOOL_RESULTS_PREAMBLE`] line, one or more `<tool_result>...</tool_result>`
555/// blocks, and a [`TOOL_RESULTS_FOOTER`] line. Weaker CLI models sometimes copy
556/// that injected turn verbatim into their own output, which leaks the raw tool
557/// JSON to the end user. This removes the `<tool_result>` blocks plus the
558/// preamble/footer literals so only the model's own prose survives.
559///
560/// An unclosed `<tool_result` tag drops the remainder of the text: a half-open
561/// result block is always a leaked JSON dump, never prose. (This is the inverse
562/// of [`strip_tool_call_blocks`], which keeps the remainder — an unclosed
563/// `<tool_call>` is a malformed invocation the model may have prefaced with
564/// real text, whereas an unclosed `<tool_result>` is pure scaffolding.)
565#[must_use]
566pub fn strip_tool_result_echo(content: &str) -> String {
567    let mut result = String::with_capacity(content.len());
568    let mut search_from = 0;
569
570    while let Some(start) = content[search_from..].find("<tool_result") {
571        let abs_start = search_from + start;
572        result.push_str(&content[search_from..abs_start]);
573
574        let close_tag = "</tool_result>";
575        if let Some(end) = content[abs_start..].find(close_tag) {
576            search_from = abs_start + end + close_tag.len();
577        } else {
578            // Unclosed tag — drop the rest (it is a leaked JSON dump).
579            search_from = content.len();
580        }
581    }
582    result.push_str(&content[search_from..]);
583
584    result
585        .replace(TOOL_RESULTS_PREAMBLE, "")
586        .replace(TOOL_RESULTS_FOOTER, "")
587        .trim()
588        .to_owned()
589}
590
591/// Strip all simulation scaffolding from a model's user-facing reply: both
592/// `<tool_call>` blocks (via [`strip_tool_call_blocks`]) and echoed tool-result
593/// scaffolding (via [`strip_tool_result_echo`]).
594///
595/// Apply this at every point where CLI/simulation output becomes the
596/// user-visible answer, so neither the model's tool invocations nor any
597/// parroted-back tool output reaches the end user.
598#[must_use]
599pub fn strip_simulation_artifacts(content: &str) -> String {
600    strip_tool_result_echo(&strip_tool_call_blocks(content))
601}
602
603// ============================================================================
604// Full Tool Loop
605// ============================================================================
606
607/// Maximum number of tool-calling iterations for CLI providers.
608///
609/// CLI providers are slower (subprocess per call), so this is kept conservative.
610/// The caller may pass a lower value; it will be clamped to this ceiling.
611const MAX_TOOL_ITERATIONS: usize = 10;
612
613/// Execute a full text-based tool-calling conversation with a CLI provider.
614///
615/// This is the CLI counterpart to the SDK-managed tool calling in
616/// [`CopilotHeadlessRunner::converse()`](crate::copilot_headless::CopilotHeadlessRunner::converse).
617///
618/// # Flow
619///
620/// 1. Generate a tool catalog from `declarations` and inject it into the
621///    system prompt of `messages`
622/// 2. Call `provider.complete()` and parse `<tool_call>` blocks from the response
623/// 3. If tool calls are found: invoke `tool_handler` for each, format results
624///    as `<tool_result>` blocks, append to `messages`, and iterate
625/// 4. If no tool calls: return the final text response
626///
627/// # Arguments
628///
629/// - `provider` — Any [`LlmProvider`] implementation (typically a CLI runner)
630/// - `messages` — Mutable conversation history; will be extended in-place
631/// - `declarations` — Tool definitions to include in the catalog
632/// - `tool_handler` — Callback invoked for each parsed tool call
633/// - `max_iterations` — Maximum loop iterations (clamped to internal ceiling)
634///
635/// # Errors
636///
637/// Returns [`RunnerError`] if any `provider.complete()` call fails.
638pub async fn execute_with_text_tools(
639    provider: &dyn LlmProvider,
640    messages: &mut Vec<ChatMessage>,
641    declarations: &[FunctionDeclaration],
642    tool_handler: TextToolHandler,
643    max_iterations: usize,
644) -> Result<TextToolResponse, RunnerError> {
645    // Generate and inject tool catalog into the system prompt
646    let tool_catalog = generate_tool_catalog(declarations);
647    inject_tool_catalog(messages, &tool_catalog);
648
649    debug!(
650        message_count = messages.len(),
651        catalog_len = tool_catalog.len(),
652        tool_count = declarations.len(),
653        max_iterations,
654        "Text tool loop: starting with injected tool catalog"
655    );
656
657    let mut tool_calls_count: u32 = 0;
658    let effective_max = max_iterations.min(MAX_TOOL_ITERATIONS);
659
660    for iteration in 0..effective_max {
661        let request = ChatRequest::new(messages.clone());
662        let response: ChatResponse = provider.complete(&request).await?;
663
664        // Parse <tool_call> blocks from the response text
665        let parsed_tool_calls = parse_tool_call_blocks(&response.content);
666
667        if parsed_tool_calls.is_empty() {
668            // No tool calls — this is the final text response. Strip both tool
669            // calls and any echoed tool-result scaffolding so neither reaches
670            // the user.
671            let content = strip_simulation_artifacts(&response.content);
672            debug!(
673                iteration,
674                content_len = content.len(),
675                total_tool_calls = tool_calls_count,
676                "Text tool loop: final response (no tool calls)"
677            );
678            return Ok(TextToolResponse {
679                content,
680                usage: response.usage,
681                finish_reason: response.finish_reason,
682                tool_calls_count,
683            });
684        }
685
686        info!(
687            "Text tool iteration {}: parsed {} tool call(s)",
688            iteration,
689            parsed_tool_calls.len()
690        );
691
692        // Execute each tool call via the handler
693        let mut function_responses = Vec::with_capacity(parsed_tool_calls.len());
694        for call in &parsed_tool_calls {
695            info!(tool_name = %call.name, "Executing tool call");
696            let resp = tool_handler(&call.name, &call.args);
697            function_responses.push(resp);
698        }
699
700        #[allow(clippy::cast_possible_truncation)]
701        {
702            tool_calls_count += parsed_tool_calls.len() as u32;
703        }
704
705        // Add assistant message (with tool calls and any echoed tool-result
706        // scaffolding stripped, so parroted output never accumulates).
707        let assistant_text = strip_simulation_artifacts(&response.content);
708        if !assistant_text.is_empty() {
709            messages.push(ChatMessage::assistant(assistant_text));
710        }
711
712        // Format tool results as text and inject as user message
713        let tool_results_text = format_tool_results_as_text(&function_responses);
714        messages.push(ChatMessage::user(tool_results_text));
715    }
716
717    // Max iterations reached without a final text response
718    Ok(TextToolResponse {
719        content: String::new(),
720        usage: None,
721        finish_reason: Some("max_iterations".to_owned()),
722        tool_calls_count,
723    })
724}
725
726// ============================================================================
727// Tests
728// ============================================================================
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use serde_json::json;
734
735    // --- parse_tool_call_blocks tests ---
736
737    #[test]
738    fn parse_single_tool_call() {
739        let content = r#"Let me fetch your data.
740
741<tool_call>
742{"name": "get_activities", "arguments": {"provider": "strava", "limit": 25}}
743</tool_call>"#;
744
745        let calls = parse_tool_call_blocks(content);
746        assert_eq!(calls.len(), 1);
747        assert_eq!(calls[0].name, "get_activities");
748        assert_eq!(calls[0].args["provider"], "strava");
749        assert_eq!(calls[0].args["limit"], 25);
750    }
751
752    #[test]
753    fn parse_multiple_tool_calls() {
754        let content = r#"I'll fetch your data.
755
756<tool_call>
757{"name": "get_activities", "arguments": {"provider": "strava", "limit": 10}}
758</tool_call>
759
760And your profile:
761<tool_call>
762{"name": "get_athlete", "arguments": {"provider": "strava"}}
763</tool_call>"#;
764
765        let calls = parse_tool_call_blocks(content);
766        assert_eq!(calls.len(), 2);
767        assert_eq!(calls[0].name, "get_activities");
768        assert_eq!(calls[1].name, "get_athlete");
769    }
770
771    #[test]
772    fn parse_no_tool_calls() {
773        let content = "Here is your analysis of the data. You had a great week!";
774        let calls = parse_tool_call_blocks(content);
775        assert!(calls.is_empty());
776    }
777
778    #[test]
779    fn parse_malformed_json_skipped() {
780        let content = r#"<tool_call>
781{not valid json}
782</tool_call>
783
784<tool_call>
785{"name": "get_stats", "arguments": {"provider": "strava"}}
786</tool_call>"#;
787
788        let calls = parse_tool_call_blocks(content);
789        assert_eq!(calls.len(), 1);
790        assert_eq!(calls[0].name, "get_stats");
791    }
792
793    #[test]
794    fn parse_tool_call_without_arguments() {
795        let content = r#"<tool_call>
796{"name": "get_connection_status"}
797</tool_call>"#;
798
799        let calls = parse_tool_call_blocks(content);
800        assert_eq!(calls.len(), 1);
801        assert_eq!(calls[0].name, "get_connection_status");
802        assert!(calls[0].args.is_object());
803    }
804
805    // --- strip_tool_call_blocks tests ---
806
807    #[test]
808    fn strip_tool_call_blocks_removes_blocks() {
809        let content = r#"Let me fetch your data.
810
811<tool_call>
812{"name": "get_activities", "arguments": {"provider": "strava"}}
813</tool_call>
814
815And some more text."#;
816
817        let stripped = strip_tool_call_blocks(content);
818        assert_eq!(
819            stripped,
820            "Let me fetch your data.\n\n\n\nAnd some more text."
821        );
822        assert!(!stripped.contains("<tool_call>"));
823    }
824
825    #[test]
826    fn strip_preserves_no_tool_calls() {
827        let content = "Just plain text with no tool calls.";
828        let stripped = strip_tool_call_blocks(content);
829        assert_eq!(stripped, content);
830    }
831
832    // --- generate_tool_catalog tests ---
833
834    #[test]
835    fn generate_tool_catalog_has_tools() {
836        let declarations = vec![
837            FunctionDeclaration {
838                name: "get_activities".to_owned(),
839                description: "Get user's recent fitness activities".to_owned(),
840                parameters: Some(json!({
841                    "type": "object",
842                    "properties": {
843                        "provider": {"type": "string"},
844                        "limit": {"type": "integer"}
845                    },
846                    "required": ["provider"]
847                })),
848            },
849            FunctionDeclaration {
850                name: "get_athlete".to_owned(),
851                description: "Get user's athlete profile".to_owned(),
852                parameters: Some(json!({
853                    "type": "object",
854                    "properties": {
855                        "provider": {"type": "string"}
856                    },
857                    "required": ["provider"]
858                })),
859            },
860        ];
861
862        let catalog = generate_tool_catalog(&declarations);
863        assert!(catalog.contains("### get_activities"));
864        assert!(catalog.contains("### get_athlete"));
865        assert!(catalog.contains("<tool_call>"));
866        assert!(catalog.contains("`provider` (string, required)"));
867        assert!(catalog.contains("`limit` (integer)"));
868    }
869
870    #[test]
871    fn generate_tool_catalog_no_parameters() {
872        let declarations = vec![FunctionDeclaration {
873            name: "ping".to_owned(),
874            description: "Check connectivity".to_owned(),
875            parameters: None,
876        }];
877
878        let catalog = generate_tool_catalog(&declarations);
879        assert!(catalog.contains("### ping"));
880        assert!(catalog.contains("Check connectivity"));
881    }
882
883    #[test]
884    fn generate_tool_catalog_uses_natural_framing_not_injection_primer() {
885        // Regression (2026-07 coach identity-break outage): the old preamble
886        // ("I am testing a function-calling protocol / Output ONLY the raw XML /
887        // Registered functions") primed reasoning-tuned models to read the turn
888        // as a prompt injection and refuse in character. The catalog must present
889        // tools naturally while keeping the <tool_call> wire format the parser
890        // (parse_tool_call_blocks) depends on.
891        let declarations = vec![FunctionDeclaration {
892            name: "get_activities".to_owned(),
893            description: "Get the user's recent activities".to_owned(),
894            parameters: None,
895        }];
896        let catalog = generate_tool_catalog(&declarations);
897
898        // Wire format + tool listing the parser depends on survive unchanged.
899        assert!(catalog.contains("<tool_call>"));
900        assert!(catalog.contains("### get_activities"));
901
902        // The injection-priming framing is gone.
903        assert!(!catalog.contains("I am testing"));
904        assert!(!catalog.contains("function-calling protocol"));
905        assert!(!catalog.contains("Registered functions"));
906        assert!(!catalog.contains("Output ONLY the raw XML"));
907
908        // Natural, in-role framing is present instead.
909        assert!(catalog.contains("You have access to the tools"));
910        assert!(catalog.contains("Available tools:"));
911    }
912
913    /// A `save_training_plan`-shaped declaration: an object parameter holding a
914    /// nested object, and an array parameter whose items are objects that
915    /// themselves hold an array of objects.
916    fn nested_declaration() -> FunctionDeclaration {
917        FunctionDeclaration {
918            name: "save_training_plan".to_owned(),
919            description: "Persist the training plan you agreed with the athlete".to_owned(),
920            parameters: Some(json!({
921                "type": "object",
922                "properties": {
923                    "coach_id": {"type": "string", "description": "Coach persona slug."},
924                    "outline": {
925                        "type": "object",
926                        "description": "The plan outline.",
927                        "required": ["goal_race"],
928                        "properties": {
929                            "goal_race": {
930                                "type": "object",
931                                "description": "The goal (A) race.",
932                                "required": ["name", "date"],
933                                "properties": {
934                                    "name": {"type": "string", "description": "Race name."},
935                                    "date": {"type": "string", "description": "Race date, YYYY-MM-DD."}
936                                }
937                            }
938                        }
939                    },
940                    "weeks": {
941                        "type": "array",
942                        "description": "Day-by-day weeks to save.",
943                        "items": {
944                            "type": "object",
945                            "required": ["week_start", "days"],
946                            "properties": {
947                                "week_start": {"type": "string", "description": "First day, YYYY-MM-DD."},
948                                "days": {
949                                    "type": "array",
950                                    "description": "The day rows.",
951                                    "items": {
952                                        "type": "object",
953                                        "required": ["date", "sport"],
954                                        "properties": {
955                                            "date": {"type": "string", "description": "Day date, YYYY-MM-DD."},
956                                            "sport": {"type": "string", "description": "Sport or 'rest'."}
957                                        }
958                                    }
959                                }
960                            }
961                        }
962                    }
963                },
964                "required": ["outline"]
965            })),
966        }
967    }
968
969    #[test]
970    fn catalog_reveals_nested_object_and_array_item_fields() {
971        // Regression (2026-07-28): every inner field name was dropped, so on a
972        // provider without native function calling the model never saw
973        // `week_start` / `date` / `sport` and invented `week_label` / `day` /
974        // `session` instead — 24 of 24 live save_training_plan calls rejected.
975        let catalog = generate_tool_catalog(&[nested_declaration()]);
976
977        // Fields one level down, through an object.
978        assert!(catalog.contains("`goal_race` (object, required)"));
979        assert!(catalog.contains("`name` (string, required)"));
980
981        // Fields reached only through an array's item schema.
982        assert!(catalog.contains("`week_start` (string, required)"));
983        assert!(catalog.contains("`sport` (string, required)"));
984
985        // An array of objects is labelled as such, not bare `array`.
986        assert!(catalog.contains("`weeks` (array of object)"));
987        assert!(catalog.contains("`days` (array of object, required)"));
988
989        // Descriptions ride along on expanded fields: they carry the format
990        // hints ("YYYY-MM-DD") without which a field name cannot be filled in.
991        assert!(catalog.contains("Race date, YYYY-MM-DD."));
992        assert!(catalog.contains("First day, YYYY-MM-DD."));
993
994        // Nesting is legible as indentation.
995        assert!(catalog.contains("  - `goal_race`"));
996        assert!(catalog.contains("    - `date`"));
997    }
998
999    #[test]
1000    fn catalog_rendering_of_a_flat_schema_is_byte_identical() {
1001        // The recursion must be invisible to schemas that have no nesting: the
1002        // tools already succeeding on every call must gain neither a token nor
1003        // a behaviour change. Keys render in serde_json's sorted map order.
1004        let catalog = generate_tool_catalog(&[FunctionDeclaration {
1005            name: "get_activities".to_owned(),
1006            description: "Get the user's recent activities".to_owned(),
1007            parameters: Some(json!({
1008                "type": "object",
1009                "properties": {
1010                    "provider": {"type": "string", "description": "Fitness provider to query."},
1011                    "limit": {"type": "integer", "description": "How many to return."}
1012                },
1013                "required": ["provider"]
1014            })),
1015        }]);
1016
1017        assert!(
1018            catalog.contains("Parameters:\n- `limit` (integer)\n- `provider` (string, required)\n")
1019        );
1020        // Scalar parameters stay bare — no description, no indentation.
1021        assert!(!catalog.contains("Fitness provider to query."));
1022        assert!(!catalog.contains("  - `"));
1023    }
1024
1025    #[test]
1026    fn few_shot_example_has_the_nested_shape_not_a_placeholder_string() {
1027        // The worked example is the model's most literal template. Emitting
1028        // `"outline": "example"` for an object parameter taught the exact
1029        // mistake the live failures made.
1030        let args = build_example_args(&nested_declaration());
1031
1032        let outline = args.get("outline").expect("outline in example"); // Safe: test asserts on the declaration it just built
1033        assert!(
1034            outline.is_object(),
1035            "object parameter must render as an object, got {outline}"
1036        );
1037        assert!(outline
1038            .pointer("/goal_race/date")
1039            .is_some_and(Value::is_string));
1040
1041        let weeks = args.get("weeks").expect("weeks in example"); // Safe: test asserts on the declaration it just built
1042        assert!(weeks.is_array(), "array parameter must render as an array");
1043        assert!(
1044            weeks
1045                .pointer("/0/days/0/sport")
1046                .is_some_and(Value::is_string),
1047            "array items must recurse into their object schema: {weeks}"
1048        );
1049    }
1050
1051    // --- format_tool_results_as_text tests ---
1052
1053    #[test]
1054    fn format_tool_results_single() {
1055        let responses = vec![FunctionResponse {
1056            name: "get_stats".to_owned(),
1057            response: json!({"total_distance_km": 1234.5}),
1058        }];
1059
1060        let text = format_tool_results_as_text(&responses);
1061        assert!(text.contains("<tool_result name=\"get_stats\">"));
1062        assert!(text.contains("1234.5"));
1063        assert!(text.contains("</tool_result>"));
1064    }
1065
1066    #[test]
1067    fn format_tool_results_multiple() {
1068        let responses = vec![
1069            FunctionResponse {
1070                name: "get_weather".to_owned(),
1071                response: json!({"temp": 72}),
1072            },
1073            FunctionResponse {
1074                name: "get_time".to_owned(),
1075                response: json!({"time": "14:30"}),
1076            },
1077        ];
1078
1079        let text = format_tool_results_as_text(&responses);
1080        assert!(text.contains("<tool_result name=\"get_weather\">"));
1081        assert!(text.contains("<tool_result name=\"get_time\">"));
1082    }
1083
1084    // --- strip_tool_result_echo tests ---
1085
1086    #[test]
1087    fn strip_tool_result_echo_removes_full_echoed_turn() {
1088        // A weak CLI model parrots the entire injected turn back, then adds its
1089        // own analysis. Only the analysis must survive.
1090        let responses = vec![FunctionResponse {
1091            name: "get_activities".to_owned(),
1092            response: json!({"activities": [{"name": "Splish splash", "distance_km": 7.9}]}),
1093        }];
1094        let echoed = format!(
1095            "{}\n\nYour biggest ride this week was 7.9 km.",
1096            format_tool_results_as_text(&responses)
1097        );
1098
1099        let stripped = strip_tool_result_echo(&echoed);
1100        assert_eq!(stripped, "Your biggest ride this week was 7.9 km.");
1101        assert!(!stripped.contains("<tool_result"));
1102        assert!(!stripped.contains("Here are the results"));
1103        assert!(!stripped.contains("Please analyze the data"));
1104    }
1105
1106    #[test]
1107    fn strip_tool_result_echo_roundtrips_format_to_empty() {
1108        let responses = vec![
1109            FunctionResponse {
1110                name: "get_stats".to_owned(),
1111                response: json!({"total_distance_km": 1234.5}),
1112            },
1113            FunctionResponse {
1114                name: "get_athlete".to_owned(),
1115                response: json!({"name": "JF"}),
1116            },
1117        ];
1118
1119        // Echoing the injected turn verbatim leaves nothing user-facing.
1120        let stripped = strip_tool_result_echo(&format_tool_results_as_text(&responses));
1121        assert_eq!(stripped, "");
1122    }
1123
1124    #[test]
1125    fn strip_tool_result_echo_drops_unclosed_block() {
1126        let echoed = "Here is your data: <tool_result name=\"x\">\n{\"huge\": \"json dump";
1127        let stripped = strip_tool_result_echo(echoed);
1128        assert_eq!(stripped, "Here is your data:");
1129        assert!(!stripped.contains("json dump"));
1130    }
1131
1132    #[test]
1133    fn strip_tool_result_echo_preserves_clean_prose() {
1134        let content = "Your easy run kept HR in Zone 2 — solid aerobic work.";
1135        assert_eq!(strip_tool_result_echo(content), content);
1136    }
1137
1138    #[test]
1139    fn strip_simulation_artifacts_removes_both_scaffolds() {
1140        let content = "Fetching.\n\n<tool_call>\n{\"name\":\"get_activities\"}\n</tool_call>\n\n\
1141            Here are the results from the tools you requested:\n\n\
1142            <tool_result name=\"get_activities\">\n{\"x\":1}\n</tool_result>\n\n\
1143            Please analyze the data above and respond to the user's question.\n\n\
1144            You ran 5 km today.";
1145
1146        let stripped = strip_simulation_artifacts(content);
1147        assert!(!stripped.contains("<tool_call>"));
1148        assert!(!stripped.contains("<tool_result"));
1149        assert!(!stripped.contains("Here are the results"));
1150        assert!(stripped.contains("Fetching."));
1151        assert!(stripped.contains("You ran 5 km today."));
1152    }
1153
1154    // --- inject_tool_catalog tests ---
1155
1156    #[test]
1157    fn inject_appends_to_existing_system() {
1158        let mut messages = vec![
1159            ChatMessage::system("You are a helpful assistant."),
1160            ChatMessage::user("Hello"),
1161        ];
1162        let catalog = "\n\n## Tools\nSome tools here.";
1163
1164        inject_tool_catalog(&mut messages, catalog);
1165
1166        assert_eq!(messages.len(), 2);
1167        assert!(messages[0].content.contains("You are a helpful assistant."));
1168        assert!(messages[0].content.contains("## Tools"));
1169    }
1170
1171    #[test]
1172    fn inject_creates_system_when_missing() {
1173        let mut messages = vec![ChatMessage::user("Hello")];
1174        let catalog = "## Tools\nSome tools here.";
1175
1176        inject_tool_catalog(&mut messages, catalog);
1177
1178        assert_eq!(messages.len(), 2);
1179        assert_eq!(messages[0].role, MessageRole::System);
1180        assert!(messages[0].content.contains("## Tools"));
1181    }
1182}