fix(subagent): resolve truncated near-miss Composio tool slugs (#3152) (#3774)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-22 10:15:58 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent c09b118cbe
commit 801a5bbf4f
3 changed files with 125 additions and 0 deletions
@@ -157,6 +157,13 @@ pub(crate) struct LazyToolkitResolver {
pub(crate) actions: Vec<crate::openhuman::context::prompt::ConnectedIntegrationTool>,
}
/// Minimum normalized-slug length before the prefix/superstring tier in
/// [`LazyToolkitResolver::find_action`] engages (#3152). Below this, a stray
/// short slug (`notion`, `gmail`) would prefix-match too many actions; the
/// uniqueness check would reject it anyway, but the length gate makes the
/// intent explicit and skips needless scans.
const TIER4_MIN_SLUG_LEN: usize = 8;
impl LazyToolkitResolver {
pub(super) fn resolve(&self, name: &str) -> Option<Box<dyn crate::openhuman::tools::Tool>> {
let action = self.find_action(name)?;
@@ -222,6 +229,38 @@ impl LazyToolkitResolver {
norm = %norm,
"[subagent_runner] ambiguous normalized-slug match — multiple actions resolve to the same slug; not resolving"
);
return None;
}
// Tier 4: unique prefix/superstring match (#3152). Models
// routinely emit a TRUNCATED action slug — `NOTION_SEARCH_NOTION`
// for the catalogued `NOTION_SEARCH_NOTION_PAGE` — or, less often,
// a suffixed one. Accept only when exactly one action's normalized
// slug extends the request (or vice-versa). Gated on a non-trivial
// request length so a short or hallucinated slug can't fan out
// across many actions, and strictly unique so a near-miss WRITE
// can never silently dispatch to the wrong action (data-integrity:
// a mis-resolved create/update would touch the wrong resource).
if norm.len() >= TIER4_MIN_SLUG_LEN {
let mut prefix_matches = self.actions.iter().filter(|a| {
let cand = normalize_slug(&a.name);
!cand.is_empty() && (cand.starts_with(&norm) || norm.starts_with(&cand))
});
if let Some(action) = prefix_matches.next() {
if prefix_matches.next().is_none() {
tracing::info!(
requested = %name,
matched = %action.name,
"[subagent_runner] resolved tool by unique prefix/superstring match"
);
return Some(action);
}
tracing::warn!(
requested = %name,
norm = %norm,
"[subagent_runner] ambiguous prefix/superstring match — multiple actions share the slug prefix; not resolving"
);
}
}
}
None
@@ -1473,3 +1473,66 @@ fn nested_subagent_dispatch_runs_on_a_constrained_worker_stack() {
outcome.output
);
}
// ── Repro: issue #3152 — near-miss write slug fails to resolve ──────
//
// The model emits `NOTION_SEARCH_NOTION` (drops the `_PAGE` suffix). The
// real action `NOTION_SEARCH_NOTION_PAGE` is the unique superstring, yet
// find_action's three tiers (exact / case-insensitive / normalized) all
// miss → None → lazy registration never fires → allowlist gate blocks the
// write. Asserts DESIRED post-fix behaviour → RED until the unique
// prefix/superstring resolution tier lands. Must stay conservative: a
// fabricated slug with no unique match must still resolve to None (covered
// by `lazy_resolver_tolerates_near_miss_slugs`).
#[test]
fn repro_3152_near_miss_write_slug_resolves_uniquely() {
use crate::openhuman::context::prompt::ConnectedIntegrationTool;
let mk = |name: &str| ConnectedIntegrationTool {
name: name.into(),
description: "d".into(),
parameters: None,
};
let resolver = LazyToolkitResolver {
config: std::sync::Arc::new(crate::openhuman::config::Config::default()),
actions: vec![
mk("NOTION_SEARCH_NOTION_PAGE"),
mk("NOTION_CREATE_NOTION_PAGE"),
mk("NOTION_FETCH_DATA"),
],
};
let resolved = resolver
.resolve("NOTION_SEARCH_NOTION")
.expect("#3152: near-miss write slug must resolve to its unique superstring");
assert_eq!(resolved.name(), "NOTION_SEARCH_NOTION_PAGE");
}
// ── Guard: #3152 prefix tier must stay strictly unique ──────────────
//
// When a truncated slug prefix-matches MORE than one catalogued action,
// the resolver must refuse rather than guess — a mis-dispatched write
// could create/update the wrong resource (data-integrity). Also asserts
// the length gate: a too-short request never fans out.
#[test]
fn prefix_tier_refuses_ambiguous_and_short_slugs() {
use crate::openhuman::context::prompt::ConnectedIntegrationTool;
let mk = |name: &str| ConnectedIntegrationTool {
name: name.into(),
description: "d".into(),
parameters: None,
};
let resolver = LazyToolkitResolver {
config: std::sync::Arc::new(crate::openhuman::config::Config::default()),
actions: vec![
mk("NOTION_SEARCH_NOTION_PAGE"),
mk("NOTION_SEARCH_NOTION_DATABASE"),
mk("NOTION_CREATE_NOTION_PAGE"),
],
};
// `NOTION_SEARCH_NOTION` is a prefix of TWO actions → ambiguous → None.
assert!(
resolver.resolve("NOTION_SEARCH_NOTION").is_none(),
"#3152: ambiguous prefix must not silently dispatch to a guess"
);
// Short slug below the length gate never engages the prefix tier.
assert!(resolver.resolve("NOTION").is_none());
}
@@ -332,3 +332,26 @@ fn real_data_full_funnel_report() {
);
assert!(total_out < total_in / 3, "overall reduction should be >66%");
}
// ── Repro: issue #3152 — Composio write action unreachable ──────────
//
// `integrations_agent` asked to CREATE a Notion page. Notion is a
// HEAVY_SCHEMA toolkit → production top_k = 12. The verb gate + score cull
// advertise only read-leaning actions, so `NOTION_CREATE_NOTION_PAGE` never
// reaches the model. Asserts DESIRED post-fix behaviour → RED until the
// write-reservation fix lands.
#[test]
fn repro_3152_create_page_reachable_in_top_k() {
let actions = load_real_toolkit("notion");
let hits = filter_actions_by_prompt(
"create a notion page with the meeting notes and give me the link",
&actions,
12,
);
assert_in_top(
&actions,
&hits,
"NOTION_CREATE_NOTION_PAGE",
"#3152 notion create-page",
);
}