1212
1313#![ allow( dead_code) ]
1414
15+ use std:: collections:: HashSet ;
1516use std:: path:: Path ;
1617
1718use async_trait:: async_trait;
@@ -534,6 +535,13 @@ fn classify_tool_call_with_semantics(
534535 semantics. classify ( name) . unwrap_or ( ToolSemantic :: Unknown )
535536}
536537
538+ /// Built-in tools that return file or search contents instead of running anything.
539+ fn is_retrieval_tool ( name : & str , command : Option < & str > ) -> bool {
540+ let lower = name. to_lowercase ( ) ;
541+ READ_TOOL_NAMES . contains ( & lower. as_str ( ) )
542+ || ( EDITOR_TOOL_NAMES . contains ( & lower. as_str ( ) ) && command == Some ( "view" ) )
543+ }
544+
537545fn is_builtin_tool_name ( lower : & str ) -> bool {
538546 WRITE_TOOL_NAMES . contains ( & lower)
539547 || EDIT_TOOL_NAMES . contains ( & lower)
@@ -736,6 +744,8 @@ fn extract_tool_signals_with_window_and_semantics(
736744 let namespaces = tool_namespaces ( & request. llm_request . extensions ) ;
737745 let mut tool_texts: Vec < ( String , bool ) > = Vec :: new ( ) ;
738746 let mut tool_calls: Vec < ObservedToolCall > = Vec :: new ( ) ;
747+ // IDs whose latest call is a retrieval tool.
748+ let mut retrieval_calls: HashSet < & str > = HashSet :: new ( ) ;
739749 let mut compacted = false ;
740750 let mut tool_result_count = 0usize ;
741751 let mut assistant_turn_count = 0usize ;
@@ -752,10 +762,33 @@ fn extract_tool_signals_with_window_and_semantics(
752762 . and_then ( |namespaces| split_qualified_name ( namespaces, & call. name ) )
753763 . map ( |( tool, _) | tool)
754764 . or_else ( || mcp_tool_name ( & call. name ) ) ;
765+ let command = command_of ( & call. arguments ) ;
766+ if !call. id . is_empty ( ) {
767+ // The joined name wins, as in `build_signal`. A joined name
768+ // configured as observe still counts when its bare name is a
769+ // retrieval tool, such as `mcp__files__read`.
770+ let full = classify_tool_call_with_semantics (
771+ & call. name ,
772+ command. as_deref ( ) ,
773+ semantics,
774+ ) ;
775+ let name = match ( full, bare_name) {
776+ ( ToolSemantic :: Unknown | ToolSemantic :: Observe , Some ( bare_name) ) => {
777+ bare_name
778+ }
779+ _ => call. name . as_str ( ) ,
780+ } ;
781+ // A reused ID links to its latest call.
782+ if is_retrieval_tool ( name, command. as_deref ( ) ) {
783+ retrieval_calls. insert ( call. id . as_str ( ) ) ;
784+ } else {
785+ retrieval_calls. remove ( call. id . as_str ( ) ) ;
786+ }
787+ }
755788 tool_calls. push ( ObservedToolCall {
756789 name : call. name . clone ( ) ,
757790 bare_name,
758- command : command_of ( & call . arguments ) ,
791+ command,
759792 } ) ;
760793 }
761794 ContentBlock :: ToolResult ( result) => {
@@ -768,8 +801,17 @@ fn extract_tool_signals_with_window_and_semantics(
768801 . collect :: < Vec < _ > > ( )
769802 . join ( "\n " ) ;
770803 let is_error = result. is_error == Some ( true ) ;
804+ let is_retrieval_result =
805+ !is_error && retrieval_calls. contains ( result. tool_call_id . as_str ( ) ) ;
771806 // An explicit failure remains a signal even without text.
772807 if !text. is_empty ( ) || is_error {
808+ // Read and search results show file contents, not the outcome
809+ // of a run. Drop the text but keep the slot so windows don't shift.
810+ let text = if is_retrieval_result {
811+ String :: new ( )
812+ } else {
813+ text
814+ } ;
773815 tool_texts. push ( ( text, is_error) ) ;
774816 }
775817 }
@@ -1570,6 +1612,43 @@ mod tests {
15701612 ) ) ;
15711613 }
15721614
1615+ #[ test]
1616+ fn retrieved_file_contents_are_ignored ( ) {
1617+ let call = |id : & str , name : & str , arguments : Value | Message {
1618+ role : Role :: Assistant ,
1619+ content : vec ! [ ContentBlock :: ToolCall ( ToolCall {
1620+ id: id. to_string( ) ,
1621+ name: name. to_string( ) ,
1622+ arguments,
1623+ } ) ] ,
1624+ } ;
1625+ let result = |id : & str , text : & str | Message {
1626+ role : Role :: User ,
1627+ content : vec ! [ ContentBlock :: ToolResult ( ToolResult {
1628+ tool_call_id: id. to_string( ) ,
1629+ content: vec![ ContentBlock :: Text {
1630+ text: text. to_string( ) ,
1631+ } ] ,
1632+ is_error: None ,
1633+ } ) ] ,
1634+ } ;
1635+ let signal = extract_tool_signals_with_window (
1636+ & with_messages ( vec ! [
1637+ call( "a" , "Bash" , json!( { "command" : "pytest" } ) ) ,
1638+ result( "a" , "Traceback (most recent call last):\n ValueError" ) ,
1639+ call( "b" , "Read" , json!( { "file_path" : "notes.md" } ) ) ,
1640+ result( "b" , "the worker ran out of memory" ) ,
1641+ call( "c" , "Grep" , json!( { "pattern" : "passed" } ) ) ,
1642+ result( "c" , "CHANGELOG.md: all tests passed" ) ,
1643+ ] ) ,
1644+ DEFAULT_RECENT_WINDOW ,
1645+ ) ;
1646+ // Only the real pytest run counts.
1647+ assert_eq ! ( signal. severity, HARD ) ;
1648+ assert ! ( !signal. tests_passed) ;
1649+ assert_eq ! ( signal. tool_result_count, 3 ) ;
1650+ }
1651+
15731652 #[ test]
15741653 fn severity_is_windowed_over_recent_results ( ) {
15751654 // An error two results back, then two clean results.
0 commit comments