diff --git a/src/openhuman/agent/harness/subagent_runner/ops/provider.rs b/src/openhuman/agent/harness/subagent_runner/ops/provider.rs index 2d70d8abe..a59bd821d 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/provider.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/provider.rs @@ -157,6 +157,13 @@ pub(crate) struct LazyToolkitResolver { pub(crate) actions: Vec, } +/// 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> { 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 diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index a3d962858..a7f8dc87d 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -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()); +} diff --git a/src/openhuman/agent/harness/tool_filter_tests.rs b/src/openhuman/agent/harness/tool_filter_tests.rs index ba4ee9676..6c81b4b7f 100644 --- a/src/openhuman/agent/harness/tool_filter_tests.rs +++ b/src/openhuman/agent/harness/tool_filter_tests.rs @@ -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", + ); +}