diff --git a/litellm-rust/crates/cache/src/semantic.rs b/litellm-rust/crates/cache/src/semantic.rs index 990f5e47aa8..253efb47759 100644 --- a/litellm-rust/crates/cache/src/semantic.rs +++ b/litellm-rust/crates/cache/src/semantic.rs @@ -84,7 +84,7 @@ impl Embedder for PreparedEmbedding { } /// `get_str_from_messages_with_tools`: every message's content text, tool calls and tool results, -/// then its OpenAI `tool_calls`, then its search results. Each tool result is tagged with the +/// then its OpenAI `tool_calls`, then its search results. Each tool result is encoded with the /// position of the call it answers. pub fn str_from_messages(messages: &[Value]) -> String { let messages: Vec<_> = messages.iter().filter_map(Value::as_object).collect(); @@ -107,12 +107,16 @@ pub fn str_from_messages(messages: &[Value]) -> String { let mut text = String::new(); for message in messages { if message.get("role").and_then(Value::as_str) == Some("tool") { - text.push_str(&tool_result_tag( + let mut output = String::new(); + push_content_text(&mut output, message.get("content"), &call_ordinals); + text.push_str(&tool_result_json( message.get("tool_call_id"), &call_ordinals, + &output, )); + } else { + push_content_text(&mut text, message.get("content"), &call_ordinals); } - push_content_text(&mut text, message.get("content"), &call_ordinals); if let Some(Value::Array(tool_calls)) = message.get("tool_calls") { for tool_call in tool_calls.iter().filter_map(Value::as_object) { let function = tool_call.get("function"); @@ -142,8 +146,13 @@ fn push_content_text( text.push_str(&tool_call_json(block.get("name"), block.get("input"))); } Some("tool_result") => { - text.push_str(&tool_result_tag(block.get("tool_use_id"), call_ordinals)); - push_content_text(text, block.get("content"), call_ordinals); + let mut output = String::new(); + push_content_text(&mut output, block.get("content"), call_ordinals); + text.push_str(&tool_result_json( + block.get("tool_use_id"), + call_ordinals, + &output, + )); } _ => { if let Some(block_text) = block.get("text").and_then(Value::as_str) { @@ -167,14 +176,21 @@ fn tool_call_ordinals<'a>(call_ids: impl Iterator) -> HashMap< ordinals } -/// `tool_result_str`: `{"result_of_call":N}` for a result answering a known call, else empty. -fn tool_result_tag(call_id: Option<&Value>, call_ordinals: &HashMap<&str, usize>) -> String { - call_id +/// `tool_result_str`: `{"result_of_call":N,"output":...}`, with a `null` position when the result +/// answers no known call. +fn tool_result_json( + call_id: Option<&Value>, + call_ordinals: &HashMap<&str, usize>, + output: &str, +) -> String { + let ordinal = call_id .and_then(Value::as_str) - .and_then(|call_id| call_ordinals.get(call_id)) - .map_or_else(String::new, |ordinal| { - format!("{{\"result_of_call\":{ordinal}}}") - }) + .and_then(|call_id| call_ordinals.get(call_id)); + format!( + "{{\"result_of_call\":{},\"output\":{}}}", + compact_json(&json!(ordinal)), + compact_json(&Value::String(output.to_owned())), + ) } /// `tool_call_str`: the compact `{"name":...,"arguments":...}` a tool call contributes. @@ -258,14 +274,16 @@ fn collect_input_text( return; } if map.get("type").and_then(Value::as_str) == Some("function_call_output") { - let tag = tool_result_tag(map.get("call_id"), call_ordinals); - if !tag.is_empty() { - parts.push(tag); - if let Some(output) = map.get("output") { - collect_input_text(output, parts, call_ordinals); - } - return; + let mut output_parts = Vec::new(); + if let Some(output) = map.get("output") { + collect_input_text(output, &mut output_parts, call_ordinals); } + parts.push(tool_result_json( + map.get("call_id"), + call_ordinals, + python_strip(&output_parts.join("\n")), + )); + return; } if let Some(content) = map.get("content").filter(|content| !content.is_null()) { collect_input_text(content, parts, call_ordinals); diff --git a/litellm-rust/crates/cache/tests/semantic.rs b/litellm-rust/crates/cache/tests/semantic.rs index 7ede4948044..af87e6e0164 100644 --- a/litellm-rust/crates/cache/tests/semantic.rs +++ b/litellm-rust/crates/cache/tests/semantic.rs @@ -38,67 +38,67 @@ fn context(messages: Option, input: Option) -> SemanticCacheContex json!([{"role": "tool", "content": "small", "search_results": [ {"source": "s", "title": "t", "content": [{"text": "hidden payload"}]}, ]}]), - "smallsthidden payload", + r#"{"result_of_call":null,"output":"small"}sthidden payload"#, )] #[case::title_only_search_result( json!([{"role": "tool", "content": "small", "search_results": [ {"source": "s", "title": "long title", "content": []}, ]}]), - "smallslong title", + r#"{"result_of_call":null,"output":"small"}slong title"#, )] #[case::search_results_without_content( json!([{"role": "tool", "search_results": [{"source": "s", "title": "t"}]}]), - "st", + r#"{"result_of_call":null,"output":""}st"#, )] #[case::search_result_fields_in_python_order( json!([{"role": "tool", "content": "c", "search_results": [ {"citations": {"enabled": true}, "content": [{"text": "body"}], "title": "t", "source": "s"}, {"source": "s2"}, ]}]), - r#"cstbody{"enabled":true}s2"#, + r#"{"result_of_call":null,"output":"c"}stbody{"enabled":true}s2"#, )] #[case::null_citations_skipped( json!([{"role": "tool", "content": "c", "search_results": [ {"source": "s", "citations": null}, ]}]), - "cs", + r#"{"result_of_call":null,"output":"c"}s"#, )] #[case::non_string_and_non_object_entries_skipped( json!([{"role": "tool", "content": "c", "search_results": [ "junk", {"source": 1, "title": null, "content": ["junk", {"text": 3}, {"text": "kept"}]}, ]}]), - "ckept", + r#"{"result_of_call":null,"output":"c"}kept"#, )] #[case::non_list_search_results_skipped( json!([{"role": "tool", "content": "c", "search_results": {"source": "s"}}]), - "c", + r#"{"result_of_call":null,"output":"c"}"#, )] #[case::citations_compact_in_insertion_order( json!([{"role": "tool", "search_results": [ {"citations": {"z": 1, "a": [1.5, true, null], "m": {"k": "v"}}}, ]}]), - r#"{"z":1,"a":[1.5,true,null],"m":{"k":"v"}}"#, + r#"{"result_of_call":null,"output":""}{"z":1,"a":[1.5,true,null],"m":{"k":"v"}}"#, )] #[case::citations_ensure_ascii( json!([{"role": "tool", "search_results": [{"citations": ["caf\u{e9}", "\u{4e2d}"]}]}]), - r#"["caf\u00e9","\u4e2d"]"#, + r#"{"result_of_call":null,"output":""}["caf\u00e9","\u4e2d"]"#, )] #[case::citations_astral_chars_as_surrogate_pairs( json!([{"role": "tool", "search_results": [{"citations": "\u{1f600}"}]}]), - r#""\ud83d\ude00""#, + r#"{"result_of_call":null,"output":""}"\ud83d\ude00""#, )] #[case::citations_escapes( json!([{"role": "tool", "search_results": [{"citations": "q\"\\\n\t\u{1}/"}]}]), - r#""q\"\\\n\t\u0001/""#, + r#"{"result_of_call":null,"output":""}"q\"\\\n\t\u0001/""#, )] #[case::citations_large_float_exponent( json!([{"role": "tool", "search_results": [{"citations": [1e20, 1.0]}]}]), - "[1e+20,1.0]", + r#"{"result_of_call":null,"output":""}[1e+20,1.0]"#, )] #[case::citations_scalars( json!([{"role": "tool", "search_results": [{"citations": false}, {"citations": 3}]}]), - "false3", + r#"{"result_of_call":null,"output":""}false3"#, )] #[case::anthropic_tool_use_name_and_input_without_id( json!([ @@ -113,7 +113,7 @@ fn context(messages: Option, input: Option) -> SemanticCacheContex json!([{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t1", "content": "calc.py"}, ]}]), - "calc.py", + r#"{"result_of_call":null,"output":"calc.py"}"#, )] #[case::anthropic_nested_text_tool_result_then_text( json!([{"role": "user", "content": [ @@ -123,7 +123,7 @@ fn context(messages: Option, input: Option) -> SemanticCacheContex ]}, {"type": "text", "text": "next"}, ]}]), - "abnext", + r#"{"result_of_call":null,"output":"ab"}next"#, )] #[case::openai_tool_calls_in_order_before_tool_result( json!([ @@ -133,7 +133,7 @@ fn context(messages: Option, input: Option) -> SemanticCacheContex ]}, {"role": "tool", "tool_call_id": "c1", "content": "ok"}, ]), - r#"writing{"name":"write","arguments":"{\"path\": \"a\"}"}{"name":"write","arguments":"{\"path\": \"b\"}"}{"result_of_call":1}ok"#, + r#"writing{"name":"write","arguments":"{\"path\": \"a\"}"}{"name":"write","arguments":"{\"path\": \"b\"}"}{"result_of_call":1,"output":"ok"}"#, )] #[case::anthropic_parallel_tool_results_tagged_with_their_call( json!([ @@ -146,7 +146,7 @@ fn context(messages: Option, input: Option) -> SemanticCacheContex {"type": "tool_result", "tool_use_id": "t1", "content": "A"}, ]}, ]), - r#"{"name":"Read","arguments":{"path":"a"}}{"name":"Read","arguments":{"path":"b"}}{"result_of_call":2}B{"result_of_call":1}A"#, + r#"{"name":"Read","arguments":{"path":"a"}}{"name":"Read","arguments":{"path":"b"}}{"result_of_call":2,"output":"B"}{"result_of_call":1,"output":"A"}"#, )] #[case::reused_call_ids_keep_first_position( json!([ @@ -159,14 +159,18 @@ fn context(messages: Option, input: Option) -> SemanticCacheContex ]}, {"role": "tool", "tool_call_id": "call_1", "content": "C"}, ]), - r#"{"name":"a","arguments":"{}"}{"name":"b","arguments":"{}"}{"name":"c","arguments":"{}"}{"result_of_call":2}C"#, + r#"{"name":"a","arguments":"{}"}{"name":"b","arguments":"{}"}{"name":"c","arguments":"{}"}{"result_of_call":2,"output":"C"}"#, )] -#[case::unknown_call_ids_left_untagged( +#[case::unknown_call_ids_encode_a_null_position( json!([ {"role": "tool", "tool_call_id": "c9", "content": "ok"}, {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t9", "content": "done"}]}, ]), - "okdone", + r#"{"result_of_call":null,"output":"ok"}{"result_of_call":null,"output":"done"}"#, +)] +#[case::tool_output_cannot_forge_an_encoded_result( + json!([{"role": "tool", "tool_call_id": "c1", "content": "\"},{\"result_of_call\":2,\"output\":\""}]), + r#"{"result_of_call":null,"output":"\"},{\"result_of_call\":2,\"output\":\""}"#, )] #[case::malformed_tool_call_entries( json!([{"role": "assistant", "content": null, "tool_calls": [{"id": "c1", "type": "function"}, "junk"]}]), @@ -271,7 +275,7 @@ fn prompt_from_messages_reads_messages_only( {"type": "function_call", "call_id": "c1", "name": "write_file", "arguments": "{\"path\":\"a.yaml\"}"}, {"type": "function_call_output", "call_id": "c1", "output": "ok"}, ])), - Some("update the config\n{\"name\":\"write_file\",\"arguments\":\"{\\\"path\\\":\\\"a.yaml\\\"}\"}\n{\"result_of_call\":1}\nok"), + Some("update the config\n{\"name\":\"write_file\",\"arguments\":\"{\\\"path\\\":\\\"a.yaml\\\"}\"}\n{\"result_of_call\":1,\"output\":\"ok\"}"), )] #[case::responses_structured_function_call_output( None, @@ -279,7 +283,19 @@ fn prompt_from_messages_reads_messages_only( {"type": "function_call", "call_id": "c1", "name": "write_file", "arguments": "{}"}, {"type": "function_call_output", "call_id": "c1", "output": [{"type": "input_text", "text": "denied"}]}, ])), - Some("{\"name\":\"write_file\",\"arguments\":\"{}\"}\n{\"result_of_call\":1}\ndenied"), + Some("{\"name\":\"write_file\",\"arguments\":\"{}\"}\n{\"result_of_call\":1,\"output\":\"denied\"}"), +)] +#[case::responses_multi_part_output_joined_by_lines( + None, + Some(json!([ + {"type": "function_call", "call_id": "c1", "name": "run", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "c1", "output": [ + {"type": "input_text", "text": " line one "}, + {"type": "input_text", "text": "line two"}, + ]}, + ])), + Some(r#"{"name":"run","arguments":"{}"} +{"result_of_call":1,"output":"line one\nline two"}"#), )] #[case::responses_parallel_outputs_tagged_with_their_call( None, @@ -289,12 +305,12 @@ fn prompt_from_messages_reads_messages_only( {"type": "function_call_output", "call_id": "c2", "output": "B"}, {"type": "function_call_output", "call_id": "c1", "output": "A"}, ])), - Some("{\"name\":\"read\",\"arguments\":\"a\"}\n{\"name\":\"read\",\"arguments\":\"b\"}\n{\"result_of_call\":2}\nB\n{\"result_of_call\":1}\nA"), + Some("{\"name\":\"read\",\"arguments\":\"a\"}\n{\"name\":\"read\",\"arguments\":\"b\"}\n{\"result_of_call\":2,\"output\":\"B\"}\n{\"result_of_call\":1,\"output\":\"A\"}"), )] -#[case::responses_unknown_call_id_output_untagged( +#[case::responses_unknown_call_id_encodes_a_null_position( None, Some(json!([{"type": "function_call_output", "call_id": "c9", "output": "orphan"}])), - Some("orphan"), + Some(r#"{"result_of_call":null,"output":"orphan"}"#), )] fn prompt_from_context_matches_python( #[case] messages: Option, diff --git a/litellm/caching/redis_semantic_cache.py b/litellm/caching/redis_semantic_cache.py index 88f5acf026e..827fa91398f 100644 --- a/litellm/caching/redis_semantic_cache.py +++ b/litellm/caching/redis_semantic_cache.py @@ -272,11 +272,15 @@ class RedisSemanticCache(BaseCache): return None responses_input: Final = kwargs.get("input") - prompt_parts: Final[list[str]] = [] - cls._collect_responses_input_text(responses_input, prompt_parts, cls._responses_call_ordinals(responses_input)) - prompt: Final = "\n".join(prompt_parts).strip() + prompt: Final = cls._responses_input_prompt(responses_input, cls._responses_call_ordinals(responses_input)) return prompt or None + @classmethod + def _responses_input_prompt(cls, value: object, call_ordinals: Mapping[str, int]) -> str: + prompt_parts: Final[list[str]] = [] + cls._collect_responses_input_text(value, prompt_parts, call_ordinals) + return "\n".join(prompt_parts).strip() + @classmethod def _collect_responses_input_text( cls, value: object, prompt_parts: list[str], call_ordinals: Mapping[str, int] @@ -341,16 +345,17 @@ class RedisSemanticCache(BaseCache): if isinstance(item, dict) and item.get("type") == "function_call" ) - @staticmethod - def _function_call_as_prompt(value: object, call_ordinals: Mapping[str, int]) -> object: + @classmethod + def _function_call_as_prompt(cls, value: object, call_ordinals: Mapping[str, int]) -> object: if not isinstance(value, dict): return value if value.get("type") == "function_call": return tool_call_str(value.get("name"), value.get("arguments")) - result_tag: Final = ( - tool_result_str(value.get("call_id"), call_ordinals) if value.get("type") == "function_call_output" else "" + if value.get("type") != "function_call_output": + return value + return tool_result_str( + value.get("call_id"), call_ordinals, cls._responses_input_prompt(value.get("output"), call_ordinals) ) - return (result_tag, value.get("output")) if result_tag else value @staticmethod def _coerce_response_input_value(value: object) -> object: diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index dddb9f3b468..0c52fae86d7 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -199,7 +199,7 @@ def get_str_from_messages_with_tools(messages: object) -> str: """ ``get_str_from_messages`` that also keeps each conversation's tool calls and tool results, so agent turns that differ only in their tool exchange (Anthropic ``tool_use`` / ``tool_result``, OpenAI ``tool_calls``) - produce different text. Each result is tagged with the position of the call it answers, since call ids are + produce different text. Each result is encoded with the position of the call it answers, since call ids are random per session """ message_mappings: Final = tuple(_str_mappings(messages)) @@ -211,9 +211,9 @@ def tool_call_str(name: object, arguments: object) -> str: return f'{{"name":{_compact_json(name)},"arguments":{_compact_json(arguments)}}}' -def tool_result_str(call_id: object, call_ordinals: Mapping[str, int]) -> str: +def tool_result_str(call_id: object, call_ordinals: Mapping[str, int], output: str) -> str: ordinal: Final = call_ordinals.get(call_id) if isinstance(call_id, str) else None - return "" if ordinal is None else f'{{"result_of_call":{ordinal}}}' + return f'{{"result_of_call":{_compact_json(ordinal)},"output":{_compact_json(output)}}}' def tool_call_ordinals(call_ids: Iterable[object]) -> Mapping[str, int]: @@ -234,12 +234,13 @@ def _message_tool_call_ids(messages: Iterable[Mapping[str, object]]) -> Iterator def _message_str_with_tools(message: Mapping[str, object], call_ordinals: Mapping[str, int]) -> str: - result_tag: Final = ( - tool_result_str(message.get("tool_call_id"), call_ordinals) if message.get("role") == "tool" else "" - ) + content: Final = _content_str_with_tools(message.get("content"), call_ordinals) return ( - result_tag - + _content_str_with_tools(message.get("content"), call_ordinals) + ( + tool_result_str(message.get("tool_call_id"), call_ordinals, content) + if message.get("role") == "tool" + else content + ) + "".join(_openai_tool_call_str(tool_call) for tool_call in _str_mappings(message.get("tool_calls"))) + extract_search_results_text(message.get("search_results")) ) @@ -256,8 +257,8 @@ def _block_str_with_tools(block: Mapping[str, object], call_ordinals: Mapping[st if block_type == "tool_use": return tool_call_str(block.get("name"), block.get("input")) if block_type == "tool_result": - return tool_result_str(block.get("tool_use_id"), call_ordinals) + _content_str_with_tools( - block.get("content"), call_ordinals + return tool_result_str( + block.get("tool_use_id"), call_ordinals, _content_str_with_tools(block.get("content"), call_ordinals) ) text: Final = block.get("text") return text if isinstance(text, str) else "" diff --git a/tests/unit/caching/test_redis_semantic_cache.py b/tests/unit/caching/test_redis_semantic_cache.py index 6ca4481648b..3359796ec84 100644 --- a/tests/unit/caching/test_redis_semantic_cache.py +++ b/tests/unit/caching/test_redis_semantic_cache.py @@ -593,10 +593,10 @@ def test_redis_semantic_cache_prompt_extraction_keeps_tool_turns_distinct(): ] assert RedisSemanticCache._get_prompt_from_kwargs(messages=turn("ls")) == ( - 'fix the failing test{"name":"Bash","arguments":{"cmd":"ls"}}{"result_of_call":1}ok' + 'fix the failing test{"name":"Bash","arguments":{"cmd":"ls"}}{"result_of_call":1,"output":"ok"}' ) assert RedisSemanticCache._get_prompt_from_kwargs(messages=turn("pwd")) == ( - 'fix the failing test{"name":"Bash","arguments":{"cmd":"pwd"}}{"result_of_call":1}ok' + 'fix the failing test{"name":"Bash","arguments":{"cmd":"pwd"}}{"result_of_call":1,"output":"ok"}' ) @@ -612,7 +612,7 @@ def test_redis_semantic_cache_prompt_extraction_keeps_responses_function_calls() ) assert prompt == ( - 'update the config\n{"name":"write_file","arguments":"{\\"path\\":\\"a.yaml\\"}"}\n{"result_of_call":1}\nok' + 'update the config\n{"name":"write_file","arguments":"{\\"path\\":\\"a.yaml\\"}"}\n{"result_of_call":1,"output":"ok"}' ) @@ -633,10 +633,29 @@ def test_redis_semantic_cache_prompt_extraction_keeps_structured_function_call_o ) expected_call = '{"name":"write_file","arguments":"{\\"path\\":\\"a.txt\\"}"}' - assert prompt_for("wrote 5 bytes") == f'write hello\n{expected_call}\n{{"result_of_call":1}}\nwrote 5 bytes' + assert ( + prompt_for("wrote 5 bytes") == f'write hello\n{expected_call}\n{{"result_of_call":1,"output":"wrote 5 bytes"}}' + ) assert prompt_for("wrote 5 bytes") != prompt_for("PermissionError") +def test_redis_semantic_cache_prompt_extraction_joins_multi_part_function_call_output_lines(): + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + prompt = RedisSemanticCache._get_prompt_from_kwargs( + input=[ + {"type": "function_call", "call_id": "c1", "name": "run", "arguments": "{}"}, + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": " line one "}, {"type": "input_text", "text": "line two"}], + }, + ] + ) + + assert prompt == '{"name":"run","arguments":"{}"}\n{"result_of_call":1,"output":"line one\\nline two"}' + + def test_redis_semantic_cache_prompt_extraction_tells_apart_parallel_outputs_answering_different_calls(): from litellm.caching.redis_semantic_cache import RedisSemanticCache @@ -652,7 +671,7 @@ def test_redis_semantic_cache_prompt_extraction_tells_apart_parallel_outputs_ans assert prompt_for("c2", "c1") == ( '{"name":"read","arguments":"a"}\n{"name":"read","arguments":"b"}\n' - '{"result_of_call":2}\nempty\n{"result_of_call":1}\nsecret' + '{"result_of_call":2,"output":"empty"}\n{"result_of_call":1,"output":"secret"}' ) assert prompt_for("c1", "c2") != prompt_for("c2", "c1") diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 2bd593bea0d..59663de68d0 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -2028,7 +2028,7 @@ _TASK: Final = {"role": "user", "content": "fix the failing test"} ), pytest.param( [_TASK, {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "calc.py"}]}], - "fix the failing testcalc.py", + 'fix the failing test{"result_of_call":null,"output":"calc.py"}', id="anthropic-string-tool-result", ), pytest.param( @@ -2045,7 +2045,7 @@ _TASK: Final = {"role": "user", "content": "fix the failing test"} ], } ], - "abnext", + '{"result_of_call":null,"output":"ab"}next', id="anthropic-nested-text-tool-result-then-text", ), pytest.param( @@ -2062,7 +2062,7 @@ _TASK: Final = {"role": "user", "content": "fix the failing test"} {"role": "tool", "tool_call_id": "c1", "content": "ok"}, ], 'fix the failing testwriting{"name":"write","arguments":"{\\"path\\": \\"a\\"}"}' - '{"name":"write","arguments":"{\\"path\\": \\"b\\"}"}{"result_of_call":1}ok', + '{"name":"write","arguments":"{\\"path\\": \\"b\\"}"}{"result_of_call":1,"output":"ok"}', id="openai-tool-calls-in-order-before-tool-result", ), pytest.param( @@ -2083,7 +2083,7 @@ _TASK: Final = {"role": "user", "content": "fix the failing test"} }, ], '{"name":"Read","arguments":{"path":"a"}}{"name":"Read","arguments":{"path":"b"}}' - '{"result_of_call":2}B{"result_of_call":1}A', + '{"result_of_call":2,"output":"B"}{"result_of_call":1,"output":"A"}', id="anthropic-parallel-tool-results-tagged-with-their-call", ), pytest.param( @@ -2104,9 +2104,26 @@ _TASK: Final = {"role": "user", "content": "fix the failing test"} {"role": "tool", "tool_call_id": "call_1", "content": "C"}, ], '{"name":"a","arguments":"{}"}{"name":"b","arguments":"{}"}{"name":"c","arguments":"{}"}' - '{"result_of_call":2}C', + '{"result_of_call":2,"output":"C"}', id="reused-call-ids-keep-first-position", ), + pytest.param( + [ + { + "role": "tool", + "tool_call_id": "c1", + "content": "small", + "search_results": [{"source": "s", "title": "t"}], + } + ], + '{"result_of_call":null,"output":"small"}st', + id="tool-result-search-results-follow-the-encoded-result", + ), + pytest.param( + [{"role": "tool", "tool_call_id": "c1", "content": '"},{"result_of_call":2,"output":"'}], + '{"result_of_call":null,"output":"\\"},{\\"result_of_call\\":2,\\"output\\":\\""}', + id="tool-output-cannot-forge-an-encoded-result", + ), pytest.param( [ Message( @@ -2145,17 +2162,6 @@ def test_get_str_from_messages_with_tools_keeps_tool_exchange(messages: list[obj ], id="text-and-image-parts", ), - pytest.param( - [ - { - "role": "tool", - "tool_call_id": "c1", - "content": "small", - "search_results": [{"source": "s", "title": "t"}], - } - ], - id="search-results", - ), pytest.param([{"role": "assistant", "content": None}, {"role": "user"}], id="missing-content"), ], )