metrale_bench/benchmarks/ssm_poison/
toolcall.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Path-independence of tool calls: one target turn, whose correct
4//! answer is to call nothing, reached by three predecessor paths that share the
5//! same long prefix. The replay script offers no tools, and its comparison
6//! passes reworded prose, so it cannot see a tool call that moves with history.
7//!
8//! Owner: bench, SSM poisoning gate.
9//! Invariants:
10//! - Only the target's tool calls are compared, raw and in order; its prose is
11//!   not.
12//!
13//! ```text
14//! A  prefix → ack → TARGET
15//! B  prefix → ack → a turn that calls get_current_weather → TARGET
16//! C  prefix → ack → a turn that calls web_search          → TARGET
17//! ```
18//!
19//! The user turns of the paths differ only in the interposed turn. Path A is
20//! the reference; the target's `tool_calls` must equal A's on B and C. The
21//! target asks for something no offered tool answers, so a call there is wrong
22//! whatever its wording.
23
24use serde_json::{Value, json};
25
26use crate::benchmarks::transcript::Transcript;
27
28/// 2026-09-26: The tool schemas offered on every turn of this probe.
29pub fn tools() -> Vec<Value> {
30    vec![
31        json!({
32            "type": "function",
33            "function": {
34                "name": "get_current_weather",
35                "description": "Get the current weather for a location.",
36                "parameters": {
37                    "type": "object",
38                    "properties": {
39                        "location": {"type": "string", "description": "City and state, e.g. San Francisco, CA"}
40                    },
41                    "required": ["location"]
42                }
43            }
44        }),
45        json!({
46            "type": "function",
47            "function": {
48                "name": "web_search",
49                "description": "Search the public web for a query string.",
50                "parameters": {
51                    "type": "object",
52                    "properties": {
53                        "query": {"type": "string", "description": "The search query"}
54                    },
55                    "required": ["query"]
56                }
57            }
58        }),
59    ]
60}
61
62/// 2026-09-26: The interposed turn of path B, which asks for a
63/// `get_current_weather` call.
64pub const CALLS_WEATHER: &str = "What is the current weather in San Francisco, CA? Use the tool.";
65
66/// 2026-09-26: The interposed turn of path C, which asks for a `web_search` call.
67pub const CALLS_SEARCH: &str =
68    "Search the web for the VirusTotal API domain report endpoint. Use the tool.";
69
70/// 2026-09-26: The target turn. Its answer is in the document the conversation
71/// already carries, and neither offered tool bears on it, so the correct reply
72/// calls nothing.
73pub const TARGET: &str = "From SYSTEM DOCUMENT 7741-C already in this \
74     conversation, state in one sentence what closed membership means. Answer \
75     from the document itself.";
76
77/// 2026-09-26: A labelled predecessor path to [`TARGET`].
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Path {
80    /// 2026-09-26: prefix → ack → target. The reference.
81    Direct,
82    /// 2026-09-26: prefix → ack → [`CALLS_WEATHER`] → target.
83    AfterWeather,
84    /// 2026-09-26: prefix → ack → [`CALLS_SEARCH`] → target.
85    AfterSearch,
86}
87
88impl Path {
89    pub const ALL: [Path; 3] = [Path::Direct, Path::AfterWeather, Path::AfterSearch];
90
91    pub fn label(self) -> &'static str {
92        match self {
93            Path::Direct => "direct",
94            Path::AfterWeather => "after-weather",
95            Path::AfterSearch => "after-search",
96        }
97    }
98
99    /// 2026-09-26: The turn to interpose between the ack and the target, if any.
100    pub fn interposed(self) -> Option<&'static str> {
101        match self {
102            Path::Direct => None,
103            Path::AfterWeather => Some(CALLS_WEATHER),
104            Path::AfterSearch => Some(CALLS_SEARCH),
105        }
106    }
107}
108
109/// 2026-09-26: The request body for a tool-offering turn: the replay probe's
110/// greedy, seed-0 body plus [`tools`] and `tool_choice: "auto"`, so the model
111/// decides whether to call.
112pub fn request_body(model: &str, messages: &[Value], max_tokens: usize) -> Value {
113    json!({
114        "model": model,
115        "stream": true,
116        "stream_options": {"include_usage": true},
117        "temperature": 0.0,
118        "seed": 0,
119        "max_tokens": max_tokens,
120        "messages": messages,
121        "tools": tools(),
122        "tool_choice": "auto",
123    })
124}
125
126/// 2026-09-26: One path's outcome: the target turn's transcript.
127#[derive(Debug, Clone)]
128pub struct PathResult {
129    pub path: Path,
130    pub target: Transcript,
131}
132
133impl PathResult {
134    /// 2026-09-26: The comparison key: the tool calls alone, as raw
135    /// `(name, arguments)` pairs in order. Prose is excluded.
136    pub fn calls(&self) -> Vec<(String, String)> {
137        self.target.tool_calls.clone()
138    }
139}
140
141/// 2026-09-26: One path disagreeing with the reference.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Divergence {
144    pub path: &'static str,
145    pub reference_calls: Vec<(String, String)>,
146    pub path_calls: Vec<(String, String)>,
147}
148
149impl Divergence {
150    /// 2026-09-26: A one-line reading for the log and the verdict.
151    pub fn describe(&self) -> String {
152        let fmt = |c: &Vec<(String, String)>| {
153            if c.is_empty() {
154                "no call".to_string()
155            } else {
156                c.iter()
157                    .map(|(n, a)| format!("{n}({a})"))
158                    .collect::<Vec<_>>()
159                    .join(" + ")
160            }
161        };
162        format!(
163            "{}: reference made {}, this path made {}",
164            self.path,
165            fmt(&self.reference_calls),
166            fmt(&self.path_calls)
167        )
168    }
169}
170
171/// 2026-09-26: Compare every path against the reference (`Path::Direct`).
172/// Returns one [`Divergence`] per path whose tool calls differ at all; there is
173/// no tolerance band as there is for replay prose. Empty when every path
174/// agrees, and also when no reference path is present.
175pub fn divergences(results: &[PathResult]) -> Vec<Divergence> {
176    let Some(reference) = results.iter().find(|r| r.path == Path::Direct) else {
177        return Vec::new();
178    };
179    let ref_calls = reference.calls();
180    results
181        .iter()
182        .filter(|r| r.path != Path::Direct)
183        .filter_map(|r| {
184            let calls = r.calls();
185            (calls != ref_calls).then(|| Divergence {
186                path: r.path.label(),
187                reference_calls: ref_calls.clone(),
188                path_calls: calls,
189            })
190        })
191        .collect()
192}
193
194/// 2026-09-26: Did the reference path itself call a tool on the target? A
195/// different finding from [`divergences`]: the driver fails the run on a
196/// divergence and only logs this one.
197pub fn reference_called(results: &[PathResult]) -> bool {
198    results
199        .iter()
200        .find(|r| r.path == Path::Direct)
201        .is_some_and(|r| !r.calls().is_empty())
202}
203
204#[cfg(test)]
205#[path = "toolcall_tests.rs"]
206mod tests;