Skip to content

Commit 9eeb653

Browse files
committed
fix: harden repaired agent context
1 parent 2e93e06 commit 9eeb653

1 file changed

Lines changed: 131 additions & 19 deletions

File tree

src/agent/mod.rs

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -122,21 +122,26 @@ fn ensure_run_id(inbound: &mut InboundMessage) -> Result<String, String> {
122122
}
123123

124124
fn 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

142147
fn 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
245250
fn 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+
690802
fn hook_observe_telemetry(
691803
hook_tool_ctx: Option<&Arc<ToolCallHookContext>>,
692804
inbound: &crate::bus::InboundMessage,

0 commit comments

Comments
 (0)