|
1 | | -use super::history::canonicalize_tool_result_media_markers; |
| 1 | +use super::history::{ |
| 2 | + canonicalize_tool_result_media_markers, canonicalize_tool_result_media_markers_for, |
| 3 | +}; |
2 | 4 | use crate::tools::{Tool, ToolSpec}; |
3 | 5 | use serde_json::Value; |
4 | 6 | use std::fmt::Write; |
@@ -129,7 +131,12 @@ impl ToolDispatcher for XmlToolDispatcher { |
129 | 131 | let mut content = String::new(); |
130 | 132 | for result in results { |
131 | 133 | let status = if result.success { "ok" } else { "error" }; |
132 | | - let output = canonicalize_tool_result_media_markers(&result.output); |
| 134 | + // Provenance-gated: search/listing tools (content_search, |
| 135 | + // glob_search) must not have incidental image paths promoted to |
| 136 | + // routable [IMAGE:...] markers (PR #7345). The producing tool name is |
| 137 | + // known here, so canonicalize through the same shared helper the |
| 138 | + // turn loop uses. |
| 139 | + let output = canonicalize_tool_result_media_markers_for(&result.name, &result.output); |
133 | 140 | let _ = writeln!( |
134 | 141 | content, |
135 | 142 | "<tool_result name=\"{}\" status=\"{}\">\n{}\n</tool_result>", |
@@ -212,7 +219,8 @@ impl ToolDispatcher for NativeToolDispatcher { |
212 | 219 | .tool_call_id |
213 | 220 | .clone() |
214 | 221 | .unwrap_or_else(|| "unknown".to_string()), |
215 | | - content: canonicalize_tool_result_media_markers(&result.output), |
| 222 | + // Provenance-gated (PR #7345): see the XML dispatcher above. |
| 223 | + content: canonicalize_tool_result_media_markers_for(&result.name, &result.output), |
216 | 224 | }) |
217 | 225 | .collect(); |
218 | 226 | ConversationMessage::ToolResults(messages) |
@@ -386,6 +394,137 @@ mod tests { |
386 | 394 | } |
387 | 395 | } |
388 | 396 |
|
| 397 | + // ═══════════════════════════════════════════════════════════════════════ |
| 398 | + // provenance-gated media-marker canonicalization (PR #7345) |
| 399 | + // ═══════════════════════════════════════════════════════════════════════ |
| 400 | + // The dispatcher result-formatting path is reachable from `Agent::turn` |
| 401 | + // / `Agent::turn_streamed` (ACP, gateway WebSocket + RPC). A search/listing |
| 402 | + // tool that merely *lists* a local image path must NOT have that path |
| 403 | + // rewritten into a routable `[IMAGE:...]` marker - otherwise it falsely |
| 404 | + // triggers vision routing and a provider-capability error on a text-only |
| 405 | + // provider. A genuine image-producing tool (e.g. `image_gen`) MUST still be |
| 406 | + // canonicalized. Both dispatchers gate via the shared |
| 407 | + // `canonicalize_tool_result_media_markers_for(tool_name, ...)` helper. |
| 408 | + |
| 409 | + /// Write a throwaway PNG and return its absolute path string. An existing |
| 410 | + /// local image path is required for canonicalization to fire at all. |
| 411 | + fn write_temp_image(dir: &std::path::Path, name: &str) -> String { |
| 412 | + let image = dir.join(name); |
| 413 | + std::fs::write(&image, [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']).unwrap(); |
| 414 | + image.display().to_string() |
| 415 | + } |
| 416 | + |
| 417 | + fn xml_format_results_text( |
| 418 | + dispatcher: &XmlToolDispatcher, |
| 419 | + result: ToolExecutionResult, |
| 420 | + ) -> String { |
| 421 | + match dispatcher.format_results(&[result]) { |
| 422 | + ConversationMessage::Chat(chat) => chat.content, |
| 423 | + _ => panic!("XmlToolDispatcher::format_results must return a Chat message"), |
| 424 | + } |
| 425 | + } |
| 426 | + |
| 427 | + fn native_format_results_content( |
| 428 | + dispatcher: &NativeToolDispatcher, |
| 429 | + result: ToolExecutionResult, |
| 430 | + ) -> String { |
| 431 | + match dispatcher.format_results(&[result]) { |
| 432 | + ConversationMessage::ToolResults(results) => results[0].content.clone(), |
| 433 | + _ => panic!("NativeToolDispatcher::format_results must return ToolResults"), |
| 434 | + } |
| 435 | + } |
| 436 | + |
| 437 | + #[test] |
| 438 | + fn xml_format_results_does_not_promote_search_tool_image_paths() { |
| 439 | + let dir = tempfile::tempdir().unwrap(); |
| 440 | + let path = write_temp_image(dir.path(), "hit.png"); |
| 441 | + let xml = XmlToolDispatcher; |
| 442 | + |
| 443 | + for tool in ["content_search", "glob_search"] { |
| 444 | + let rendered = xml_format_results_text( |
| 445 | + &xml, |
| 446 | + ToolExecutionResult { |
| 447 | + name: tool.into(), |
| 448 | + output: format!("match: {path}"), |
| 449 | + success: true, |
| 450 | + tool_call_id: None, |
| 451 | + }, |
| 452 | + ); |
| 453 | + assert!( |
| 454 | + !rendered.contains("[IMAGE:"), |
| 455 | + "{tool} output must not be promoted to an image marker" |
| 456 | + ); |
| 457 | + assert!( |
| 458 | + rendered.contains(&path), |
| 459 | + "{tool} output must still carry the literal path text" |
| 460 | + ); |
| 461 | + } |
| 462 | + } |
| 463 | + |
| 464 | + #[test] |
| 465 | + fn native_format_results_does_not_promote_search_tool_image_paths() { |
| 466 | + let dir = tempfile::tempdir().unwrap(); |
| 467 | + let path = write_temp_image(dir.path(), "hit.png"); |
| 468 | + let native = NativeToolDispatcher; |
| 469 | + |
| 470 | + for tool in ["content_search", "glob_search"] { |
| 471 | + let content = native_format_results_content( |
| 472 | + &native, |
| 473 | + ToolExecutionResult { |
| 474 | + name: tool.into(), |
| 475 | + output: format!("found: {path}"), |
| 476 | + success: true, |
| 477 | + tool_call_id: Some("tc1".into()), |
| 478 | + }, |
| 479 | + ); |
| 480 | + assert!( |
| 481 | + !content.contains("[IMAGE:"), |
| 482 | + "{tool} output must not be promoted to an image marker" |
| 483 | + ); |
| 484 | + assert!(content.contains(&path)); |
| 485 | + } |
| 486 | + } |
| 487 | + |
| 488 | + #[test] |
| 489 | + fn format_results_still_promotes_image_producing_tool_paths() { |
| 490 | + // Default-allow: a genuinely image-producing tool keeps canonicalization |
| 491 | + // in BOTH dispatchers, so real tool-produced images still route to a |
| 492 | + // vision provider. |
| 493 | + let dir = tempfile::tempdir().unwrap(); |
| 494 | + let path = write_temp_image(dir.path(), "generated.png"); |
| 495 | + let expected = format!("[IMAGE:{path}]"); |
| 496 | + |
| 497 | + let xml = XmlToolDispatcher; |
| 498 | + let rendered = xml_format_results_text( |
| 499 | + &xml, |
| 500 | + ToolExecutionResult { |
| 501 | + name: "image_gen".into(), |
| 502 | + output: format!("saved to {path}"), |
| 503 | + success: true, |
| 504 | + tool_call_id: None, |
| 505 | + }, |
| 506 | + ); |
| 507 | + assert!( |
| 508 | + rendered.contains(&expected), |
| 509 | + "image_gen output must be canonicalized into a marker (XML)" |
| 510 | + ); |
| 511 | + |
| 512 | + let native = NativeToolDispatcher; |
| 513 | + let content = native_format_results_content( |
| 514 | + &native, |
| 515 | + ToolExecutionResult { |
| 516 | + name: "image_gen".into(), |
| 517 | + output: format!("saved to {path}"), |
| 518 | + success: true, |
| 519 | + tool_call_id: Some("tc1".into()), |
| 520 | + }, |
| 521 | + ); |
| 522 | + assert!( |
| 523 | + content.contains(&expected), |
| 524 | + "image_gen output must be canonicalized into a marker (native)" |
| 525 | + ); |
| 526 | + } |
| 527 | + |
389 | 528 | // ═══════════════════════════════════════════════════════════════════════ |
390 | 529 | // reasoning_content pass-through tests |
391 | 530 | // ═══════════════════════════════════════════════════════════════════════ |
|
0 commit comments