@@ -122,21 +122,26 @@ fn ensure_run_id(inbound: &mut InboundMessage) -> Result<String, String> {
122122}
123123
124124fn text_looks_like_research_request ( content : & str ) -> bool {
125- let lower = content. to_ascii_lowercase ( ) ;
126- [
127- "research" ,
128- "literature" ,
129- "paper" ,
130- "state-of-the-art" ,
131- "state of the art" ,
132- "survey" ,
133- "arxiv" ,
134- "evidence" ,
135- "cite" ,
136- "compare methods" ,
137- ]
138- . iter ( )
139- . any ( |k| lower. contains ( k) )
125+ static RESEARCH_REQUEST_RE : OnceLock < Regex > = OnceLock :: new ( ) ;
126+ RESEARCH_REQUEST_RE
127+ . get_or_init ( || {
128+ Regex :: new (
129+ r"(?ix)
130+ \b(?:
131+ research(?:er|ers|ing|ed)? |
132+ literature |
133+ papers? |
134+ state[-\s]+of[-\s]+the[-\s]+art |
135+ surveys? |
136+ arxiv |
137+ evidence |
138+ cite |
139+ compare\s+methods
140+ )\b" ,
141+ )
142+ . expect ( "research-request regex" )
143+ } )
144+ . is_match ( content)
140145}
141146
142147fn context_has_tool_call ( context : & [ crate :: utils:: ChatMessage ] , tool_name : & str ) -> bool {
@@ -245,6 +250,14 @@ fn trim_context_to_budget(context: &mut Vec<crate::utils::ChatMessage>, max_toke
245250fn repair_tool_call_context ( context : & mut Vec < crate :: utils:: ChatMessage > ) {
246251 let mut i = 0 ;
247252 while i < context. len ( ) {
253+ // A tool result without an immediately preceding assistant tool-call
254+ // block is invalid for strict providers. It can be left behind by
255+ // legacy/corrupt history, so discard it before context trimming.
256+ if context[ i] . role == "tool" {
257+ context. remove ( i) ;
258+ continue ;
259+ }
260+
248261 let tool_call_ids: Vec < String > = match & context[ i] . tool_calls {
249262 Some ( calls) if context[ i] . role == "assistant" && !calls. is_empty ( ) => {
250263 calls. iter ( ) . map ( |tc| tc. id . clone ( ) ) . collect ( )
@@ -255,14 +268,22 @@ fn repair_tool_call_context(context: &mut Vec<crate::utils::ChatMessage>) {
255268 }
256269 } ;
257270
258- // Collect the set of tool_call_ids that have responses immediately following
271+ // Keep at most one response for each requested id. Mismatched,
272+ // id-less, and duplicate tool rows are orphaned protocol records and
273+ // would make strict providers reject the whole request.
274+ let requested: HashSet < String > = tool_call_ids. iter ( ) . cloned ( ) . collect ( ) ;
259275 let mut responded: HashSet < String > = HashSet :: new ( ) ;
260276 let mut j = i + 1 ;
261277 while j < context. len ( ) && context[ j] . role == "tool" {
262- if let Some ( ref id) = context[ j] . tool_call_id {
263- responded. insert ( id. clone ( ) ) ;
278+ let keep = context[ j]
279+ . tool_call_id
280+ . as_deref ( )
281+ . is_some_and ( |id| requested. contains ( id) && responded. insert ( id. to_string ( ) ) ) ;
282+ if keep {
283+ j += 1 ;
284+ } else {
285+ context. remove ( j) ;
264286 }
265- j += 1 ;
266287 }
267288
268289 // Append placeholder tool responses for any missing tool_call_ids at end of tool block
@@ -687,6 +708,97 @@ mod code_exec_gate_tests {
687708 }
688709}
689710
711+ #[ cfg( test) ]
712+ mod context_hardening_tests {
713+ use super :: * ;
714+
715+ fn assistant_with_calls ( ids : & [ & str ] ) -> crate :: utils:: ChatMessage {
716+ let mut message = crate :: utils:: ChatMessage :: assistant ( "" ) ;
717+ message. content = None ;
718+ message. tool_calls = Some (
719+ ids. iter ( )
720+ . map ( |id| crate :: utils:: ToolCallRequest {
721+ id : ( * id) . to_string ( ) ,
722+ tool_type : "function" . to_string ( ) ,
723+ extra_content : None ,
724+ function : crate :: utils:: ToolCallFunction {
725+ name : "read_file" . to_string ( ) ,
726+ arguments : "{}" . to_string ( ) ,
727+ } ,
728+ } )
729+ . collect ( ) ,
730+ ) ;
731+ message
732+ }
733+
734+ #[ test]
735+ fn research_detection_uses_word_and_phrase_boundaries ( ) {
736+ for positive in [
737+ "Research this topic" ,
738+ "review the papers" ,
739+ "give me state-of-the-art evidence" ,
740+ "cite primary sources" ,
741+ "compare methods" ,
742+ ] {
743+ assert ! ( text_looks_like_research_request( positive) , "{positive}" ) ;
744+ }
745+ for negative in [
746+ "I am excited about this" ,
747+ "the paperclip is broken" ,
748+ "surveying the room" ,
749+ "compare methodologies later" ,
750+ ] {
751+ assert ! ( !text_looks_like_research_request( negative) , "{negative}" ) ;
752+ }
753+ }
754+
755+ #[ test]
756+ fn repair_removes_orphan_mismatched_and_duplicate_tool_results ( ) {
757+ let mut context = vec ! [
758+ crate :: utils:: ChatMessage :: system( "system" ) ,
759+ crate :: utils:: ChatMessage :: tool( "orphan" , "orphan" , None ) ,
760+ assistant_with_calls( & [ "a" , "b" ] ) ,
761+ crate :: utils:: ChatMessage :: tool( "first" , "a" , None ) ,
762+ crate :: utils:: ChatMessage :: tool( "duplicate" , "a" , None ) ,
763+ crate :: utils:: ChatMessage :: tool( "wrong" , "other" , None ) ,
764+ crate :: utils:: ChatMessage :: user( "next" ) ,
765+ ] ;
766+
767+ repair_tool_call_context ( & mut context) ;
768+
769+ let tool_ids: Vec < _ > = context
770+ . iter ( )
771+ . filter ( |message| message. role == "tool" )
772+ . filter_map ( |message| message. tool_call_id . as_deref ( ) )
773+ . collect ( ) ;
774+ assert_eq ! ( tool_ids, [ "a" , "b" ] ) ;
775+ assert ! ( context. iter( ) . all( |message| {
776+ message
777+ . content
778+ . as_ref( )
779+ . is_none_or( |content| !content. text_content( ) . contains( "orphan" ) )
780+ } ) ) ;
781+ }
782+
783+ #[ test]
784+ fn trimming_keeps_assistant_tool_blocks_atomic ( ) {
785+ let mut context = vec ! [
786+ crate :: utils:: ChatMessage :: system( "system" ) ,
787+ crate :: utils:: ChatMessage :: user( & "x" . repeat( 400 ) ) ,
788+ assistant_with_calls( & [ "call" ] ) ,
789+ crate :: utils:: ChatMessage :: tool( "result" , "call" , None ) ,
790+ crate :: utils:: ChatMessage :: user( "recent" ) ,
791+ ] ;
792+
793+ trim_context_to_budget ( & mut context, 1 ) ;
794+
795+ assert ! ( !context. iter( ) . any( |message| message. role == "tool" ) ) ;
796+ assert ! ( !context
797+ . iter( )
798+ . any( |message| message. role == "assistant" && message. tool_calls. is_some( ) ) ) ;
799+ }
800+ }
801+
690802fn hook_observe_telemetry (
691803 hook_tool_ctx : Option < & Arc < ToolCallHookContext > > ,
692804 inbound : & crate :: bus:: InboundMessage ,
0 commit comments