1use 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
69pub type FunctionDeclaration = ToolDefinition;
78
79#[derive(Debug, Clone)]
84pub struct FunctionCall {
85 pub name: String,
87 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
115pub struct FunctionResponse {
116 pub name: String,
118 pub response: Value,
120}
121
122#[derive(serde::Deserialize)]
124struct ToolCallPayload {
125 name: String,
126 #[serde(default)]
127 arguments: Option<Value>,
128}
129
130pub type TextToolHandler = Arc<dyn Fn(&str, &Value) -> FunctionResponse + Send + Sync>;
135
136#[derive(Debug, Clone)]
141pub struct TextToolResponse {
142 pub content: String,
144 pub usage: Option<TokenUsage>,
146 pub finish_reason: Option<String>,
148 pub tool_calls_count: u32,
150}
151
152#[must_use]
182pub fn generate_tool_catalog(declarations: &[FunctionDeclaration]) -> String {
183 let mut catalog = String::with_capacity(4096);
184
185 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 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 if let Some(first) = declarations.first() {
224 append_few_shot_example(&mut catalog, first);
225 }
226
227 catalog
228}
229
230const MAX_PARAM_DEPTH: usize = 8;
238
239fn 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
255fn 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 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
319fn 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
336fn append_few_shot_example(catalog: &mut String, decl: &FunctionDeclaration) {
338 catalog.push_str("Example interaction:\n\n");
339
340 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
353fn 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
364fn 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 _ => 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
402pub 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 messages.insert(0, ChatMessage::system(catalog));
416}
417
418#[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#[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 search_from = content.len();
490 }
491 }
492 result.push_str(&content[search_from..]);
493 result.trim().to_owned()
494}
495
496const TOOL_RESULTS_PREAMBLE: &str = "Here are the results from the tools you requested:";
505
506const TOOL_RESULTS_FOOTER: &str =
510 "Please analyze the data above and respond to the user's question.";
511
512#[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#[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 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#[must_use]
599pub fn strip_simulation_artifacts(content: &str) -> String {
600 strip_tool_result_echo(&strip_tool_call_blocks(content))
601}
602
603const MAX_TOOL_ITERATIONS: usize = 10;
612
613pub 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 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 let parsed_tool_calls = parse_tool_call_blocks(&response.content);
666
667 if parsed_tool_calls.is_empty() {
668 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 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 let assistant_text = strip_simulation_artifacts(&response.content);
708 if !assistant_text.is_empty() {
709 messages.push(ChatMessage::assistant(assistant_text));
710 }
711
712 let tool_results_text = format_tool_results_as_text(&function_responses);
714 messages.push(ChatMessage::user(tool_results_text));
715 }
716
717 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#[cfg(test)]
731mod tests {
732 use super::*;
733 use serde_json::json;
734
735 #[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 #[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 #[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 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 assert!(catalog.contains("<tool_call>"));
900 assert!(catalog.contains("### get_activities"));
901
902 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 assert!(catalog.contains("You have access to the tools"));
910 assert!(catalog.contains("Available tools:"));
911 }
912
913 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 let catalog = generate_tool_catalog(&[nested_declaration()]);
976
977 assert!(catalog.contains("`goal_race` (object, required)"));
979 assert!(catalog.contains("`name` (string, required)"));
980
981 assert!(catalog.contains("`week_start` (string, required)"));
983 assert!(catalog.contains("`sport` (string, required)"));
984
985 assert!(catalog.contains("`weeks` (array of object)"));
987 assert!(catalog.contains("`days` (array of object, required)"));
988
989 assert!(catalog.contains("Race date, YYYY-MM-DD."));
992 assert!(catalog.contains("First day, YYYY-MM-DD."));
993
994 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 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 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 let args = build_example_args(&nested_declaration());
1031
1032 let outline = args.get("outline").expect("outline in example"); 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"); 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 #[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 #[test]
1087 fn strip_tool_result_echo_removes_full_echoed_turn() {
1088 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 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 #[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}