Skip to content

Commit d3cfe9e

Browse files
authored
feat(stage): Don't use signals from text inside files (#837)
* feat(stage): Don't use signals from text inside files We look for specific strings like "out of memory" in tool call output, to see if there is an error. If the tool call was a log file read that contained that string we would think it was an error and potentially escalate. Now we track if the text came from a Read action and then we don't look at that text for tool call signals. Fixes: https://linear.app/nvidia/issue/SWITCH-1566 Assisted-by: Claude:Opus 5.5 high Signed-off-by: Graham King <grahamk@nvidia.com>
1 parent 08b075d commit d3cfe9e

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

‎crates/libsy/src/algorithms/util/tool_signals.rs‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
1313
#![allow(dead_code)]
1414

15+
use std::collections::HashSet;
1516
use std::path::Path;
1617

1718
use 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+
537545
fn 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):\nValueError"),
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.

‎docs/routing_algorithms/stage_router_routing.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ confidence in `[0, 1]`. One maxed scoring dimension produces about `0.46`;
3535
corroborating evidence pushes confidence decisively past a `0.5` threshold.
3636
Repeated failures, critical-error severity, and context compaction are hard
3737
overrides to the capable tier. An active capable hold also bypasses the scorer.
38+
Severity and test results come from tool output that ran something. The contents
39+
returned by the built-in read and search tools do not count, unless the tool
40+
reports a failure.
3841

3942
`confidence_threshold` sets how sure that estimate must be before the router acts
4043
on the signal alone. Scores inside the ambiguous band go to the optional

0 commit comments

Comments
 (0)