use polyc_llm::ToolSpec;
use polyc_llm::request::ToolCall;
use crate::{CallDisposition, ToolExecutor, annotate_gated_specs};
pub(crate) fn try_recover<T: ToolExecutor + ?Sized>(
tools: &T,
tool_calls: &[ToolCall],
dispositions: &mut [CallDisposition],
tool_specs: &mut Vec<ToolSpec>,
fired: &mut bool,
enabled: bool,
) {
if !enabled || *fired {
return;
}
let Some(idx) = tool_calls
.iter()
.position(|tc| !tool_specs.iter().any(|s| s.name == tc.name))
else {
return;
};
let tc = &tool_calls[idx];
let mut matched = tools.recover_unadvertised(&tc.name, &tc.args_json);
matched.retain(|m| !tool_specs.iter().any(|s| s.name == m.name));
if matched.is_empty() {
return;
}
annotate_gated_specs(tools, &mut matched);
tracing::warn!(
requested = %tc.name,
matched = ?matched.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
count = matched.len(),
"escape hatch fired: the model called an unadvertised tool — a \
false-negative retrieval miss; widening the advertised set once"
);
dispositions[idx] = CallDisposition::Recovered {
requested: tc.name.clone(),
matched: matched.iter().map(|s| s.name.clone()).collect(),
};
tool_specs.extend(matched);
*fired = true;
}
pub(crate) fn escape_hatch_recovery_json(requested: &str, matched: &[String]) -> String {
let list = matched.join(", ");
let message = if matched.len() == 1 {
format!(
"No tool named {requested} was offered on this turn. 1 related tool \
is now available: {list}. Call it if it does what you need."
)
} else {
format!(
"No tool named {requested} was offered on this turn. {} related tools \
are now available: {list}. Call the one that does what you need.",
matched.len()
)
};
serde_json::json!({ "error": message, "related_tools": matched }).to_string()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use futures::{StreamExt, stream};
use polyc_llm::{
Chunk, CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage,
ToolSpec, error::DummyError,
};
use crate::{RunTurnOptions, ToolExecutor, run_turn_with};
struct UnknownCallingProvider {
calls: AtomicUsize,
advertised: std::sync::Mutex<Vec<Vec<(String, String)>>>,
transcripts: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
second_unknown: bool,
}
impl UnknownCallingProvider {
fn new(second_unknown: bool) -> Self {
Self {
calls: AtomicUsize::new(0),
advertised: std::sync::Mutex::new(Vec::new()),
transcripts: std::sync::Mutex::new(Vec::new()),
second_unknown,
}
}
fn advertised_names(&self) -> Vec<Vec<String>> {
self.advertised
.lock()
.unwrap()
.iter()
.map(|step| step.iter().map(|(n, _)| n.clone()).collect())
.collect()
}
}
#[async_trait]
impl LlmProvider for UnknownCallingProvider {
type Error = DummyError;
async fn complete(
&self,
req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
self.advertised.lock().unwrap().push(
req.tools
.iter()
.map(|t| (t.name.clone(), t.description.clone()))
.collect(),
);
self.transcripts.lock().unwrap().push(req.messages.clone());
let n = self.calls.fetch_add(1, Ordering::SeqCst);
let chunks = match n {
0 => vec![
Ok(Chunk::tool_call_start("call-1", "feeds__find_posts")),
Ok(Chunk::tool_call_args_delta(
"call-1",
r#"{"query":"rust concurrency"}"#,
)),
Ok(Chunk::tool_call_end("call-1")),
Ok(Chunk::Stop(polyc_llm::StopReason::ToolUse)),
],
1 if self.second_unknown => vec![
Ok(Chunk::tool_call_start("call-2", "bogus__again")),
Ok(Chunk::tool_call_args_delta("call-2", "{}")),
Ok(Chunk::tool_call_end("call-2")),
Ok(Chunk::Stop(polyc_llm::StopReason::ToolUse)),
],
_ => vec![
Ok(Chunk::text_delta("done")),
Ok(Chunk::Stop(polyc_llm::StopReason::EndTurn)),
],
};
Ok(stream::iter(chunks).boxed())
}
}
struct RecoveringTools {
hidden: Vec<ToolSpec>,
queries: std::sync::Mutex<Vec<(String, String)>>,
}
impl RecoveringTools {
fn new(hidden: Vec<ToolSpec>) -> Self {
Self {
hidden,
queries: std::sync::Mutex::new(Vec::new()),
}
}
fn hidden_pair() -> Vec<ToolSpec> {
vec![
ToolSpec::new(
"reader__search",
"Search the feeds by keyword",
serde_json::json!({"type": "object"}),
),
ToolSpec::new(
"reader__publish",
"Publish a post to the feeds",
serde_json::json!({"type": "object"}),
)
.approval_required(),
]
}
}
#[async_trait]
impl ToolExecutor for RecoveringTools {
fn specs(&self) -> Vec<ToolSpec> {
vec![ToolSpec::new(
"echo_tool",
"Echo the input text back",
serde_json::json!({"type": "object"}),
)]
}
fn needs_approval(&self, name: &str) -> bool {
self.specs()
.iter()
.chain(self.hidden.iter())
.any(|s| s.name == name && s.needs_approval)
}
fn required_capabilities(&self, _name: &str) -> polyc_capability::CapabilitySet {
polyc_capability::CapabilitySet::EMPTY
}
fn recover_unadvertised(&self, name: &str, args_json: &str) -> Vec<ToolSpec> {
self.queries
.lock()
.unwrap()
.push((name.to_owned(), args_json.to_owned()));
self.hidden.clone()
}
async fn execute(&self, name: &str, _args_json: &str) -> String {
format!(r#"{{"ran":"{name}"}}"#)
}
}
fn escape_hatch_opts() -> RunTurnOptions {
RunTurnOptions {
escape_hatch: true,
..Default::default()
}
}
fn final_tool_results(provider: &UnknownCallingProvider) -> Vec<String> {
provider
.transcripts
.lock()
.unwrap()
.last()
.cloned()
.expect("a turn ran")
.iter()
.flat_map(|m| m.content.iter())
.filter_map(|c| match c {
LlmContent::ToolResult(r) => Some(r.result_json.clone()),
_ => None,
})
.collect()
}
#[tokio::test]
async fn escape_hatch_recovers_an_unadvertised_call_once() {
let provider = UnknownCallingProvider::new(false);
let tools = RecoveringTools::new(RecoveringTools::hidden_pair());
let out = run_turn_with(
&provider,
&tools,
"scripted",
vec![LlmMessage::user("find rust posts")],
escape_hatch_opts(),
)
.await
.expect("turn");
assert!(out.pending_approvals.is_empty());
let queries = tools.queries.lock().unwrap().clone();
assert_eq!(queries.len(), 1, "the hatch fired exactly once");
assert_eq!(
queries[0],
(
"feeds__find_posts".to_owned(),
r#"{"query":"rust concurrency"}"#.to_owned()
),
"the raw called name and argument text reach recovery unaltered"
);
let steps = provider.advertised.lock().unwrap().clone();
assert_eq!(steps.len(), 2, "the turn drove exactly two steps");
assert_eq!(
steps[1][..steps[0].len()],
steps[0][..],
"the pre-widen set is a byte-stable prefix of the widened set"
);
let appended: Vec<&(String, String)> = steps[1][steps[0].len()..].iter().collect();
assert_eq!(
appended.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
["reader__search", "reader__publish"],
"the matched specs are appended at the END"
);
let publish = appended
.iter()
.find(|(n, _)| n == "reader__publish")
.expect("widened");
assert!(
publish
.1
.contains(polyc_llm::GATED_TOOL_APPROVAL_NOTE.as_str()),
"a gated widened spec carries the shared approval note: {}",
publish.1
);
let search = appended
.iter()
.find(|(n, _)| n == "reader__search")
.expect("widened");
assert!(
!search
.1
.contains(polyc_llm::GATED_TOOL_APPROVAL_NOTE.as_str()),
"an ungated widened spec is not annotated"
);
let results = final_tool_results(&provider);
assert_eq!(results.len(), 1);
let recovery: serde_json::Value = serde_json::from_str(&results[0]).unwrap();
let message = recovery["error"].as_str().unwrap();
assert!(
message.contains("feeds__find_posts"),
"names the tool the model called: {message}"
);
assert!(
message.contains("2 related tools are now available"),
"says what changed: {message}"
);
assert!(
message.contains("reader__search") && message.contains("reader__publish"),
"names the newly available tools: {message}"
);
assert_eq!(
recovery["related_tools"],
serde_json::json!(["reader__search", "reader__publish"]),
"the structured list mirrors the message"
);
}
#[tokio::test]
async fn escape_hatch_fires_at_most_once_per_turn() {
let provider = UnknownCallingProvider::new(true);
let tools = RecoveringTools::new(RecoveringTools::hidden_pair());
run_turn_with(
&provider,
&tools,
"scripted",
vec![LlmMessage::user("find rust posts")],
escape_hatch_opts(),
)
.await
.expect("turn");
assert_eq!(
tools.queries.lock().unwrap().len(),
1,
"the second unknown call must not consult recovery again"
);
let steps = provider.advertised_names();
assert_eq!(steps.len(), 3, "the turn drove exactly three steps");
assert_eq!(
steps[1], steps[2],
"no second widening: step 3 advertises exactly step 2's set"
);
let results = final_tool_results(&provider);
assert_eq!(results.len(), 2);
assert_eq!(
results[1], r#"{"ran":"bogus__again"}"#,
"the second unknown call falls through to today's execute path"
);
}
#[tokio::test]
async fn escape_hatch_off_leaves_unknown_calls_unchanged() {
let provider = UnknownCallingProvider::new(false);
let tools = RecoveringTools::new(RecoveringTools::hidden_pair());
run_turn_with(
&provider,
&tools,
"scripted",
vec![LlmMessage::user("find rust posts")],
RunTurnOptions::default(),
)
.await
.expect("turn");
assert!(
tools.queries.lock().unwrap().is_empty(),
"recovery is never consulted with the hatch off"
);
let steps = provider.advertised_names();
assert_eq!(steps[0], steps[1], "the advertised set never changes");
let results = final_tool_results(&provider);
assert_eq!(
results,
[r#"{"ran":"feeds__find_posts"}"#.to_owned()],
"the unknown call resolves through the executor exactly as today"
);
}
#[tokio::test]
async fn escape_hatch_with_empty_recovery_falls_through() {
let provider = UnknownCallingProvider::new(false);
let tools = RecoveringTools::new(Vec::new());
run_turn_with(
&provider,
&tools,
"scripted",
vec![LlmMessage::user("find rust posts")],
escape_hatch_opts(),
)
.await
.expect("turn");
assert_eq!(
tools.queries.lock().unwrap().len(),
1,
"recovery was consulted once"
);
let steps = provider.advertised_names();
assert_eq!(steps[0], steps[1], "an empty recovery widens nothing");
let results = final_tool_results(&provider);
assert_eq!(
results,
[r#"{"ran":"feeds__find_posts"}"#.to_owned()],
"the call resolves through the executor exactly as today"
);
}
}