fix(flows): real-output probe to derive the array path when the tool schema is absent (B16) (#4702)

This commit is contained in:
Cyrus Gray
2026-07-08 18:21:15 +05:30
committed by GitHub
parent 8fc9a13da7
commit 9830c90d5a
9 changed files with 1257 additions and 52 deletions
@@ -1053,9 +1053,13 @@ mod tests {
// SAVED to test it (a real run the prompt gates behind user
// confirmation), and `save_workflow` a built graph onto a flow the host
// ALREADY created (the prompt bar's instant-create path) — but it can
// never create/enable a flow or perform a raw integration action. This
// pins the invariant in the agent definition itself, not just the tool
// implementations.
// never create/enable a flow or perform an arbitrary raw integration
// action. One narrow, deliberate carve-out (B12): `get_tool_output_sample`
// DOES make a real Composio call, but only ever a Read-scope one
// (hard-refused otherwise, regardless of the user's scope preference)
// against an already-connected toolkit — see `builder_tools.rs`'s
// module doc. This pins the invariant in the agent definition itself,
// not just the tool implementations.
let def = find("workflow_builder");
assert_eq!(def.agent_tier, AgentTier::Worker);
assert_eq!(def.delegate_name.as_deref(), Some("build_workflow"));
@@ -1082,6 +1086,7 @@ mod tests {
"list_flow_connections",
"search_tool_catalog",
"get_tool_contract",
"get_tool_output_sample",
"list_agent_profiles",
"dry_run_workflow",
"run_flow",
@@ -30,14 +30,22 @@ hint = "reasoning"
# a confirmed real test-run of a SAVED flow + save_workflow (persist a graph
# onto an EXISTING flow only). NO shell, NO file writes, NO channel sends, NO
# composio_execute, and NO flows_create/set_enabled — the agent can never
# create or enable a flow, or perform a real integration action directly.
# Composio access is limited to LISTING toolkits/connections and raising the
# inline CONNECT card (an approval-gated OAuth hand-off).
# create or enable a flow, or perform an arbitrary real integration action
# directly. Composio access is limited to LISTING toolkits/connections,
# raising the inline CONNECT card (an approval-gated OAuth hand-off), and one
# narrow carve-out below.
# `run_flow` executes a flow the user has ALREADY saved to test it — a real
# run, but the prompt requires asking the user to confirm first, and the flow's
# own approval gate still pauses outbound-action nodes. (Named `run_flow`, not
# `run_workflow` — that name belongs to the unrelated legacy skills-workflow
# runner tool, which is registered in the same default tool registry.)
# `get_tool_output_sample` (B12) IS a real Composio call — the one deliberate
# carve-out — but it is hard-gated to Read-scope actions only (regardless of
# the user's scope preference) against an already-connected toolkit, and
# exists solely to sample the real output shape of a source tool whose live
# listing publishes no output schema at all (verified for every GitHub
# action), so a downstream split_out.path is never guessed. See
# `builder_tools.rs`'s module doc.
named = [
"propose_workflow",
"revise_workflow",
@@ -48,6 +56,7 @@ named = [
"list_flow_connections",
"search_tool_catalog",
"get_tool_contract",
"get_tool_output_sample",
"list_agent_profiles",
"dry_run_workflow",
"run_flow",
@@ -256,6 +256,17 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`.
`"path": "json.data.messages"` — as the downstream `split_out.path`.
`primary_array_path` already includes the `data.` segment above, so
just prefix `json.` — don't guess where the array lives in the response.
**If `get_tool_contract` returns `primary_array_path: null` for a source
tool you plan to `split_out` (its live listing has no output schema at
all — this is genuinely true for every GitHub action, e.g.
`GITHUB_LIST_REPOSITORY_ISSUES`), do NOT default to `"json.data"`** — that
targets the WHOLE payload container (e.g. `{issues: [...]}` itself), so
the split yields exactly ONE item instead of one per real result. Instead
call `get_tool_output_sample { slug, args }` (the SAME `args` you're
wiring into the real node) to make one bounded, read-only, real call and
get the ACTUAL array path (e.g. `"data.issues"`, not `"data.items"`) —
it only works on an already-connected, Read-scope action, so if the
toolkit isn't connected yet, note that to the user instead of guessing.
- **App not connected yet?** You can still build the node with a real
slug from `search_tool_catalog` (searches the FULL live catalog
regardless of connection state) and ground it with `get_tool_contract
+153 -3
View File
@@ -13,6 +13,7 @@
//! | [`ListFlowConnectionsTool`] | `None` | read: connection refs (ids/names only) |
//! | [`SearchToolCatalogTool`] | `None` | read: real Composio tool slugs (live catalog) |
//! | [`GetToolContractTool`] | `None` | read: one action's FULL live contract |
//! | [`GetToolOutputSampleTool`] | `ReadOnly` | ONE bounded real Composio call (Read-scope only, connected toolkit only) |
//! | [`ListAgentProfilesTool`] | `None` | read: selectable agent kinds (`agent_ref`)|
//! | [`DryRunWorkflowTool`] | `Execute` (tier-gated) | run a *draft* against MOCK capabilities |
//! | [`SaveWorkflowTool`] | `Write` | persist a graph onto an EXISTING flow |
@@ -32,8 +33,18 @@
//! `composio_list_toolkits`, `composio_list_connections`, `composio_connect`
//! (defined in `composio/tools.rs`) — so the builder can link an app the
//! workflow needs before proposing. Those stay within the invariant: connect
//! is an approval-gated OAuth hand-off, and `composio_execute` (running a real
//! action) remains deliberately OUT of scope.
//! is an approval-gated OAuth hand-off, and `composio_execute` (running an
//! arbitrary real action, any scope) remains deliberately OUT of scope.
//!
//! **One narrow, deliberate carve-out (B12):** [`GetToolOutputSampleTool`]
//! (`get_tool_output_sample`) DOES perform a real Composio call — but only
//! ever a `Read`-scope one (hard-refused otherwise, regardless of the user's
//! per-toolkit scope preference), and only against a toolkit the user has
//! ALREADY connected. It exists because some actions' live listings publish
//! no output schema at all (verified for every GitHub action), leaving
//! `get_tool_contract` with no ground truth for a downstream `split_out.path`
//! — this makes exactly one bounded real read to observe the actual shape
//! instead. It can never send/create/update/delete anything.
use std::sync::Arc;
@@ -802,7 +813,20 @@ impl Tool for GetToolContractTool {
};
match catalog.iter().find(|c| c.slug.eq_ignore_ascii_case(&slug)) {
Some(contract) => Ok(ToolResult::success(serde_json::to_string_pretty(contract)?)),
Some(contract) => {
// B12: a prior real-output probe (get_tool_output_sample) for
// this exact slug is ACTUAL observed data and always wins
// over the schema-derived hint — most relevant for an action
// whose live listing publishes no output schema at all (e.g.
// every GitHub action verified live as of this fix), where
// `contract.primary_array_path` would otherwise be
// permanently `None`.
let contract =
crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone());
Ok(ToolResult::success(serde_json::to_string_pretty(
&contract,
)?))
}
None => Ok(ToolResult::error(format!(
"'{slug}' is not a real action in the '{toolkit}' toolkit's live catalog — use \
search_tool_catalog to find a real slug."
@@ -811,6 +835,132 @@ impl Tool for GetToolContractTool {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// get_tool_output_sample — READ-ONLY real Composio call: the B12 output probe
// ─────────────────────────────────────────────────────────────────────────────
/// `get_tool_output_sample`: make ONE bounded, READ-ONLY, REAL Composio call
/// for `slug` and derive its `primary_array_path`/`output_fields` from the
/// ACTUAL response, overriding `get_tool_contract`'s schema-derived hint for
/// this slug from then on (see
/// [`crate::openhuman::tinyflows::caps::apply_probe_override`]).
///
/// **Exists because a schema-derived hint sometimes doesn't exist at all**:
/// Composio's live listing genuinely omits `output_parameters` for some
/// actions — verified live for every GitHub action, including the curated
/// `GITHUB_LIST_REPOSITORY_ISSUES` — leaving `get_tool_contract`'s
/// `primary_array_path` permanently `null`. Without ground truth the builder
/// has been observed guessing the whole-payload `"json.data"` as a
/// `split_out.path` (live flow "funny reminders v2": one item — the
/// `{issues:[...]}` container itself — instead of the real per-item list),
/// silently degrading a fan-out to a single item.
///
/// **This is a deliberate, narrow carve-out of the workflow-builder agent's
/// "propose/read only, no composio_execute" invariant** (see this module's
/// top doc): unlike `composio_execute`, this tool can ONLY ever perform a
/// `Read`-scope action (gated by
/// [`crate::openhuman::tinyflows::caps::probe_tool_output_sample`]'s scope
/// check, which ignores the user's per-toolkit scope preference — a probe
/// must never perform a real mutation no matter what the user has toggled
/// on) against a toolkit the user has ALREADY connected. No message is sent,
/// no record created/updated/deleted, ever.
///
/// Pass the SAME `args` you intend to wire into the real `tool_call` node —
/// this samples THAT call, not a generic fixture. Omit `args` (or pass `{}`)
/// for a zero-required-arg action.
pub struct GetToolOutputSampleTool {
config: Arc<Config>,
}
impl GetToolOutputSampleTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for GetToolOutputSampleTool {
fn name(&self) -> &str {
"get_tool_output_sample"
}
fn description(&self) -> &str {
"Make ONE bounded, READ-ONLY, REAL call to a Composio action and derive its real \
`primary_array_path`/`output_fields` from the ACTUAL response — use this when \
get_tool_contract returns `output_schema: null` / `primary_array_path: null` for a \
source tool you plan to `split_out` (e.g. every GitHub action, verified live), so a \
downstream split_out.path never fans out over the whole-payload container by mistake. \
Only ever performs a Read action (refuses Write/Admin actions unconditionally, \
regardless of the user's scope preference) against an ALREADY-CONNECTED toolkit — never \
sends, creates, updates, or deletes anything. Pass the SAME args you intend to wire into \
the real tool_call node — this samples THAT exact call. Call get_tool_contract again \
afterward (or trust this tool's own `primary_array_path`/`output_fields`) to see the \
override applied. Real actions only, not `oh:` or `=`-derived slugs."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The exact Composio action slug, e.g. 'GITHUB_LIST_REPOSITORY_ISSUES'."
},
"args": {
"type": "object",
"description": "Arguments for the real call — the SAME ones you intend to wire into the tool_call node (e.g. {\"owner\": \"acme\", \"repo\": \"widgets\"}). Omit for a zero-required-arg action."
}
},
"required": ["slug"],
"additionalProperties": false
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
fn external_effect(&self) -> bool {
false
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let slug = match args.get("slug").and_then(Value::as_str).map(str::trim) {
Some(s) if !s.is_empty() => s.to_string(),
_ => return Ok(ToolResult::error("Missing 'slug' parameter".to_string())),
};
let call_args = args.get("args").cloned().unwrap_or(json!({}));
tracing::debug!(
target: "flows",
%slug,
"[flows] get_tool_output_sample: tool invoked"
);
match crate::openhuman::tinyflows::caps::probe_tool_output_sample(
&self.config,
&slug,
call_args,
)
.await
{
Ok(sample) => {
let primary_array_path_for_split_out = sample
.primary_array_path
.as_ref()
.map(|p| format!("json.{p}"));
Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
"slug": slug,
"primary_array_path": sample.primary_array_path,
"split_out_path": primary_array_path_for_split_out,
"output_fields": sample.output_fields,
}))?))
}
Err(e) => Ok(ToolResult::error(e)),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// list_agent_profiles — read-only: selectable agent kinds for an `agent` node
// ─────────────────────────────────────────────────────────────────────────────
+100 -1
View File
@@ -190,7 +190,9 @@ async fn list_flow_connections_is_read_only() {
// share a toolkit key (same discipline the pre-fix required-args/response-
// fields caches already required).
use crate::openhuman::tinyflows::caps::{seed_live_catalog_cache, ToolContract};
use crate::openhuman::tinyflows::caps::{
seed_live_catalog_cache, seed_probe_cache, ProbedOutputSample, ToolContract,
};
fn seeded_gmail_send_contract() -> ToolContract {
ToolContract {
@@ -401,6 +403,103 @@ async fn get_tool_contract_rejects_a_hallucinated_slug() {
assert!(result.output().contains("not a real action"));
}
/// B12: a cached real-output probe overrides `get_tool_contract`'s
/// schema-derived `primary_array_path`/`output_fields` — most relevant for a
/// slug whose live listing (like every GitHub action, verified live) has NO
/// output schema at all, so the schema-derived fields would otherwise be
/// permanently empty/null.
#[tokio::test]
async fn get_tool_contract_applies_a_cached_probe_override() {
let contract = ToolContract {
slug: "PROBEOVERRIDETEST_LIST_REPOSITORY_ISSUES".to_string(),
toolkit: "probeoverridetest".to_string(),
description: None,
required_args: vec!["owner".to_string(), "repo".to_string()],
input_schema: None,
output_fields: vec![],
output_schema: None,
primary_array_path: None,
is_curated: true,
};
seed_live_catalog_cache("probeoverridetest", vec![contract]);
seed_probe_cache(
"PROBEOVERRIDETEST_LIST_REPOSITORY_ISSUES",
ProbedOutputSample {
primary_array_path: Some("data.issues".to_string()),
output_fields: vec!["issues".to_string(), "total_count".to_string()],
sample: json!({ "data": { "issues": [], "total_count": 0 } }),
},
);
let tmp = TempDir::new().unwrap();
let tool = GetToolContractTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "slug": "PROBEOVERRIDETEST_LIST_REPOSITORY_ISSUES" }))
.await
.unwrap();
assert!(!result.is_error, "{}", result.output());
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["primary_array_path"], "data.issues");
assert_eq!(parsed["output_fields"], json!(["issues", "total_count"]));
// The schema-derived field stays null — the probe overrides the HINT
// fields, it doesn't fabricate a schema that was never published.
assert!(parsed["output_schema"].is_null());
}
// ── get_tool_output_sample (B12: the real-output probe) ─────────────────────
#[test]
fn get_tool_output_sample_is_read_only_permission_with_no_external_effect() {
let tmp = TempDir::new().unwrap();
let tool = GetToolOutputSampleTool::new(test_config(&tmp));
assert_eq!(tool.name(), "get_tool_output_sample");
assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
assert!(!tool.external_effect());
}
#[tokio::test]
async fn get_tool_output_sample_missing_slug_is_error() {
let tmp = TempDir::new().unwrap();
let tool = GetToolOutputSampleTool::new(test_config(&tmp));
let result = tool.execute(json!({})).await.unwrap();
assert!(result.is_error);
assert!(result.output().contains("Missing 'slug'"));
}
/// The scope gate runs BEFORE any client/network call, so a Write-scope
/// action is refused entirely offline — this must never depend on a live
/// Composio backend to prove the probe can't perform a real mutation.
#[tokio::test]
async fn get_tool_output_sample_refuses_a_write_scope_action() {
let tmp = TempDir::new().unwrap();
let tool = GetToolOutputSampleTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "slug": "GMAIL_SEND_EMAIL" }))
.await
.unwrap();
assert!(result.is_error);
assert!(result.output().contains("READ-only"), "{}", result.output());
}
/// The connected-toolkit gate runs before the real call too — in a test
/// environment with no backend session, `fetch_connected_integrations`
/// degrades to empty (best-effort, per its own doc), so a Read-scope action
/// against an unconnected toolkit is refused without ever reaching a client.
#[tokio::test]
async fn get_tool_output_sample_refuses_an_unconnected_toolkit() {
let tmp = TempDir::new().unwrap();
let tool = GetToolOutputSampleTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "slug": "GITHUB_LIST_REPOSITORY_ISSUES" }))
.await
.unwrap();
assert!(result.is_error);
assert!(
result.output().contains("not connected") || result.output().contains("no active"),
"{}",
result.output()
);
}
// ── dry_run_workflow ─────────────────────────────────────────────────────────
#[test]
+131 -41
View File
@@ -288,8 +288,15 @@ async fn graph_output_field_warnings(config: &Config, graph: &WorkflowGraph) ->
else {
continue;
};
// Output schema unknown — nothing real to check `field_path` against.
if contract.output_schema.is_none() {
// B12: a real-output probe (`get_tool_output_sample`) for this
// exact slug overrides the schema-derived `output_fields` — most
// relevant for an action whose live listing publishes no output
// schema at all (e.g. every GitHub action, verified live).
let contract =
crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone());
// Nothing real to check `field_path` against — schema unknown AND
// no probed output fields either.
if contract.output_schema.is_none() && contract.output_fields.is_empty() {
continue;
}
@@ -357,25 +364,74 @@ async fn graph_output_field_warnings(config: &Config, graph: &WorkflowGraph) ->
warnings
}
/// Author-time WARN/suggest (systemic tool-contract fix, Part 2d): a
/// `split_out` node whose direct predecessor is a `tool_call` calling a REAL
/// Composio action with a KNOWN `primary_array_path` (see
/// [`crate::openhuman::tinyflows::caps::compute_composio_array_path`] — this
/// already bakes in the `data.` segment Composio's execute-response wrapper
/// adds, so `expected` below comes out `"json.data.<…>"` for a real action
/// with no extra handling needed here), but whose configured `config.path`
/// doesn't match the `json.<primary_array_path>` convention (dereferencing
/// the `{json,text,raw}` envelope's own `json` field, then the action's real
/// array property). Advisory: a mismatched path degrades the fan-out (or
/// silently produces one item instead of many) rather than crashing, so this
/// suggests the real path instead of rejecting.
/// Given a Composio action's payload-only `output_schema` (see
/// [`crate::openhuman::tinyflows::caps::ToolContract::output_fields`]'s doc —
/// NEVER includes the runtime `data` envelope) and a `split_out.path`
/// addressed relative to the ENVELOPE (`json.<envelope_field…>`, e.g.
/// `"json.data"` or `"json.data.issues"`), resolves whether the path lands on
/// something that is DEFINITELY not an array.
///
/// Skipped when the predecessor's action has no known `primary_array_path`
/// (nothing to suggest), or when `split_out`'s predecessor isn't a
/// `tool_call` at all (no envelope/array-path convention applies).
/// `Some(true)` — non-array (an object or scalar): a `split_out` over this
/// path fans out over exactly ONE item, the classic "wrong array path"
/// signal [`graph_split_out_path_warnings`]'s generic enforcement flags.
/// `Some(false)` — array: the path is fine. `None` — the path can't be
/// resolved against the schema at all (an unpublished/unknown nested field,
/// or a path missing the `data.` segment entirely) — stay silent rather than
/// guess; that's a distinct failure mode from "resolves to a non-array".
fn schema_says_path_is_non_array(output_schema: &Value, configured_path: &str) -> Option<bool> {
let relative = configured_path
.strip_prefix("json.")
.unwrap_or(configured_path);
if relative == "data" {
// Whole-payload access (`json.data`) — non-array unless the payload's
// own root schema type is literally "array" (a bare-array response,
// e.g. a REST endpoint that returns `[...]` directly), in which case
// `json.data` legitimately IS the real list.
let ty = output_schema.get("type").and_then(Value::as_str)?;
return Some(ty != "array");
}
let rest = relative.strip_prefix("data.").filter(|r| !r.is_empty())?;
let mut node = output_schema;
for seg in rest.split('.') {
node = node.get("properties")?.get(seg)?;
}
let ty = node.get("type").and_then(Value::as_str)?;
Some(ty != "array")
}
/// Author-time WARN/suggest (systemic tool-contract fix, Part 2d, extended by
/// B12): a `split_out` node whose direct predecessor is a `tool_call` calling
/// a REAL Composio action, checked two ways:
///
/// 1. **KNOWN `primary_array_path`** (see
/// [`crate::openhuman::tinyflows::caps::compute_composio_array_path`] —
/// this already bakes in the `data.` segment Composio's execute-response
/// wrapper adds, so `expected` below comes out `"json.data.<…>"` with no
/// extra handling needed here — and, via
/// [`crate::openhuman::tinyflows::caps::apply_probe_override`], a real
/// `get_tool_output_sample` probe for this slug overrides a schema that
/// never named an array at all): if the configured `config.path` doesn't match the
/// `json.<primary_array_path>` convention, suggest the real path.
/// 2. **UNKNOWN `primary_array_path`, but a KNOWN `output_schema`/probe that
/// proves the configured path is definitely NOT an array** (B12
/// enforcement, "regardless" of whether a correct path can be suggested —
/// catches the class at build time even when nothing to suggest is
/// derivable): warn generically. This is exactly the live bug this fix
/// closes — `GITHUB_LIST_REPOSITORY_ISSUES` publishes no output schema at
/// all, so a builder without a probe guessed the whole-payload
/// `"json.data"`, silently fanning out over ONE item (the `{issues:
/// [...]}` container) instead of the real per-issue list.
///
/// Both are advisory: a mismatched/non-array path degrades the fan-out (or
/// silently produces one item instead of many) rather than crashing.
///
/// Skipped entirely when `split_out`'s predecessor isn't a `tool_call` at all
/// (no envelope/array-path convention applies), or when NEITHER a
/// `primary_array_path` NOR an `output_schema` is known (truly nothing to
/// check against).
async fn graph_split_out_path_warnings(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug;
use crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog;
use crate::openhuman::tinyflows::caps::{apply_probe_override, fetch_live_toolkit_catalog};
let mut warnings = Vec::new();
for node in &graph.nodes {
@@ -409,29 +465,63 @@ async fn graph_split_out_path_warnings(config: &Config, graph: &WorkflowGraph) -
else {
continue;
};
let Some(primary) = contract.primary_array_path.as_deref() else {
continue;
};
let expected = format!("json.{primary}");
if configured_path != Some(expected.as_str()) {
tracing::warn!(
target: "flows",
node = %node.id,
predecessor = %pred.id,
pred_slug,
configured_path,
%expected,
"[flows] wiring check: split_out.path does not match the predecessor tool's real array path"
);
let configured_display = configured_path
.map(|p| format!("\"{p}\""))
.unwrap_or_else(|| "unset".to_string());
warnings.push(format!(
"Node '{}': split_out.path is {configured_display} but its predecessor \
tool_call `{}` (`{pred_slug}`) wraps its real array at `{expected}` — set \
config.path to \"{expected}\" to fan out over the actual response list.",
node.id, pred.id,
));
// B12: a real-output probe overrides the schema-derived
// `primary_array_path` for this exact slug when one is cached.
let contract = apply_probe_override(contract.clone());
match contract.primary_array_path.as_deref() {
Some(primary) => {
let expected = format!("json.{primary}");
if configured_path != Some(expected.as_str()) {
tracing::warn!(
target: "flows",
node = %node.id,
predecessor = %pred.id,
pred_slug,
configured_path,
%expected,
"[flows] wiring check: split_out.path does not match the predecessor tool's real array path"
);
let configured_display = configured_path
.map(|p| format!("\"{p}\""))
.unwrap_or_else(|| "unset".to_string());
warnings.push(format!(
"Node '{}': split_out.path is {configured_display} but its predecessor \
tool_call `{}` (`{pred_slug}`) wraps its real array at `{expected}` — set \
config.path to \"{expected}\" to fan out over the actual response list.",
node.id, pred.id,
));
}
}
// No known array anywhere in this action's real output — the
// generic non-array enforcement is the only thing left that
// can catch a wrong path here (nothing to suggest, but a
// known-non-array hit is still a strong signal).
None => {
let Some(cp) = configured_path else { continue };
let Some(schema) = contract.output_schema.as_ref() else {
continue;
};
if schema_says_path_is_non_array(schema, cp) == Some(true) {
tracing::warn!(
target: "flows",
node = %node.id,
predecessor = %pred.id,
pred_slug,
configured_path = cp,
"[flows] wiring check: split_out.path resolves to a non-array — likely the wrong array path"
);
warnings.push(format!(
"Node '{}': split_out.path is \"{cp}\" but tool_call `{}` (`{pred_slug}`)'s \
known real output does not name an array at that path (or names no array \
property at all) — this fans out over a single object instead of a real \
list. If the action's real output nests the list under a named field (e.g. \
`data.issues`), call get_tool_output_sample {{ slug: \"{pred_slug}\" }} to \
sample the real response, then re-check with get_tool_contract.",
node.id, pred.id,
));
}
}
}
}
}
+236 -1
View File
@@ -1829,7 +1829,9 @@ fn binding_to_agent_with_matching_schema_is_accepted() {
// test below seeds the exact toolkit it needs via `seed_live_catalog_cache`
// so none of this touches a live Composio backend.
use crate::openhuman::tinyflows::caps::{seed_live_catalog_cache, ToolContract};
use crate::openhuman::tinyflows::caps::{
seed_live_catalog_cache, seed_probe_cache, ProbedOutputSample, ToolContract,
};
fn seeded_slack_send_contract() -> ToolContract {
ToolContract {
@@ -2369,6 +2371,239 @@ async fn graph_wiring_warnings_suggests_the_real_split_out_path() {
);
}
/// B12 enforcement: a `split_out.path` that resolves to a NON-array (an
/// object, here) against a KNOWN output schema is flagged even though the
/// action names no array anywhere (`primary_array_path` is `None`) — there
/// is nothing to *suggest*, but a definite non-array hit is still a strong
/// "wrong array path" signal worth catching at build time.
#[tokio::test]
async fn graph_wiring_warnings_flags_a_split_out_path_that_resolves_to_a_non_array() {
// seeded_slack_send_contract's output_schema names only scalar fields
// (ts/channel) — a real, known schema with no array in it anywhere.
let mut contract = seeded_slack_send_contract();
contract.slug = "NONARRAYFANOUT_SEND_MESSAGE".to_string();
contract.toolkit = "nonarrayfanout".to_string();
seed_live_catalog_cache("nonarrayfanout", vec![contract]);
let config = Config::default();
let g = graph(json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "post", "kind": "tool_call", "name": "Post",
"config": { "slug": "NONARRAYFANOUT_SEND_MESSAGE",
"args": { "channel": "#general", "text": "hi" } } },
{ "id": "split", "kind": "split_out", "name": "Split",
"config": { "path": "json.data" } }
],
"edges": [
{ "from_node": "t", "to_node": "post" },
{ "from_node": "post", "to_node": "split" }
]
}));
let warnings = graph_wiring_warnings(&config, &g).await;
assert!(
warnings
.iter()
.any(|w| w.contains("split") && w.contains("does not name an array")),
"{warnings:?}"
);
}
/// The non-array enforcement stays SILENT when the action's output schema is
/// genuinely unknown (not just "known but arrayless") — nothing real to check
/// the path against, so no false positive.
#[tokio::test]
async fn graph_wiring_warnings_is_silent_on_split_out_when_schema_is_wholly_unknown() {
let contract = ToolContract {
slug: "UNKNOWNSCHEMA_DO_THING".to_string(),
toolkit: "unknownschema".to_string(),
description: None,
required_args: vec![],
input_schema: None,
output_fields: vec![],
output_schema: None,
primary_array_path: None,
is_curated: true,
};
seed_live_catalog_cache("unknownschema", vec![contract]);
let config = Config::default();
let g = graph(json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "post", "kind": "tool_call", "name": "Post",
"config": { "slug": "UNKNOWNSCHEMA_DO_THING", "args": {} } },
{ "id": "split", "kind": "split_out", "name": "Split",
"config": { "path": "json.data" } }
],
"edges": [
{ "from_node": "t", "to_node": "post" },
{ "from_node": "post", "to_node": "split" }
]
}));
assert!(
graph_wiring_warnings(&config, &g).await.is_empty(),
"{:?}",
graph_wiring_warnings(&config, &g).await
);
}
/// B12 end-to-end: the EXACT live bug shape (flow "funny reminders v2").
/// `GITHUB_LIST_REPOSITORY_ISSUES`-equivalent contract has NO schema at all
/// (`output_schema: None`, `primary_array_path: None` — verified live for
/// every GitHub action), so before a probe the enforcement above has nothing
/// to check the configured `"json.data"` against and stays silent. Once
/// `get_tool_output_sample` has probed the slug (seeded here via
/// `seed_probe_cache`, standing in for a real bounded call), the cached
/// `primary_array_path` overrides the schema-derived (absent) hint and the
/// EXISTING mismatch-suggestion path fires with the real nested path.
#[tokio::test]
async fn graph_wiring_warnings_suggests_the_probed_split_out_path_when_schema_is_unknown() {
let contract = ToolContract {
slug: "GHPROBEFANOUT_LIST_REPOSITORY_ISSUES".to_string(),
toolkit: "ghprobefanout".to_string(),
description: None,
required_args: vec!["owner".to_string(), "repo".to_string()],
input_schema: None,
output_fields: vec![],
output_schema: None,
primary_array_path: None,
is_curated: true,
};
seed_live_catalog_cache("ghprobefanout", vec![contract]);
seed_probe_cache(
"GHPROBEFANOUT_LIST_REPOSITORY_ISSUES",
ProbedOutputSample {
primary_array_path: Some("data.issues".to_string()),
output_fields: vec!["issues".to_string(), "total_count".to_string()],
sample: json!({ "data": { "issues": [], "total_count": 0 } }),
},
);
let config = Config::default();
let g = graph(json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "post", "kind": "tool_call", "name": "Post",
"config": { "slug": "GHPROBEFANOUT_LIST_REPOSITORY_ISSUES",
"args": { "owner": "acme", "repo": "widgets" } } },
// The exact wrong guess observed live: whole-payload access
// instead of the real nested `data.issues`.
{ "id": "split", "kind": "split_out", "name": "Split",
"config": { "path": "json.data" } }
],
"edges": [
{ "from_node": "t", "to_node": "post" },
{ "from_node": "post", "to_node": "split" }
]
}));
let warnings = graph_wiring_warnings(&config, &g).await;
assert!(
warnings.iter().any(|w| w.contains("json.data.issues")),
"{warnings:?}"
);
// Fixed: once config.path matches the probed real path, the warning
// clears.
let fixed = graph(json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "post", "kind": "tool_call", "name": "Post",
"config": { "slug": "GHPROBEFANOUT_LIST_REPOSITORY_ISSUES",
"args": { "owner": "acme", "repo": "widgets" } } },
{ "id": "split", "kind": "split_out", "name": "Split",
"config": { "path": "json.data.issues" } }
],
"edges": [
{ "from_node": "t", "to_node": "post" },
{ "from_node": "post", "to_node": "split" }
]
}));
assert!(
graph_wiring_warnings(&config, &fixed).await.is_empty(),
"{:?}",
graph_wiring_warnings(&config, &fixed).await
);
}
/// CodeRabbit (PR #4702 review): parity coverage for the probe-override path
/// in `graph_output_field_warnings` — mirrors
/// `graph_wiring_warnings_suggests_the_probed_split_out_path_when_schema_is_unknown`
/// above, but for a downstream FIELD binding rather than `split_out.path`.
/// With no schema at all (`output_schema: None`, `output_fields: []`), the
/// field-not-in-output_fields check would otherwise stay silent (nothing
/// real to check against) — once `get_tool_output_sample` has probed the
/// slug, the probed `output_fields` become the ground truth: a binding to a
/// probed-real field is silent, and a binding to a field NOT in the probed
/// set is flagged, exactly like the schema-known case already covers.
#[tokio::test]
async fn graph_wiring_warnings_uses_the_probed_output_fields_when_schema_is_unknown() {
let contract = ToolContract {
slug: "GHPROBEFIELDS_LIST_REPOSITORY_ISSUES".to_string(),
toolkit: "ghprobefields".to_string(),
description: None,
required_args: vec!["owner".to_string(), "repo".to_string()],
input_schema: None,
output_fields: vec![],
output_schema: None,
primary_array_path: None,
is_curated: true,
};
seed_live_catalog_cache("ghprobefields", vec![contract]);
seed_probe_cache(
"GHPROBEFIELDS_LIST_REPOSITORY_ISSUES",
ProbedOutputSample {
primary_array_path: Some("data.issues".to_string()),
output_fields: vec!["issues".to_string(), "total_count".to_string()],
sample: json!({ "data": { "issues": [], "total_count": 0 } }),
},
);
let config = Config::default();
// A binding to a field the probe actually observed — silent.
let real_field = graph(json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "post", "kind": "tool_call", "name": "Post",
"config": { "slug": "GHPROBEFIELDS_LIST_REPOSITORY_ISSUES",
"args": { "owner": "acme", "repo": "widgets" } } },
{ "id": "xform", "kind": "transform", "name": "Log",
"config": { "set": { "note": "=nodes.post.item.json.data.total_count" } } }
],
"edges": [
{ "from_node": "t", "to_node": "post" },
{ "from_node": "post", "to_node": "xform" }
]
}));
assert!(
graph_wiring_warnings(&config, &real_field).await.is_empty(),
"a probed-real field must not warn: {:?}",
graph_wiring_warnings(&config, &real_field).await
);
// A binding to a field the probe did NOT observe — flagged, using the
// probed output_fields as ground truth even though the schema itself is
// unknown.
let fake_field = graph(json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "post", "kind": "tool_call", "name": "Post",
"config": { "slug": "GHPROBEFIELDS_LIST_REPOSITORY_ISSUES",
"args": { "owner": "acme", "repo": "widgets" } } },
{ "id": "xform", "kind": "transform", "name": "Log",
"config": { "set": { "note": "=nodes.post.item.json.data.not_a_probed_field" } } }
],
"edges": [
{ "from_node": "t", "to_node": "post" },
{ "from_node": "post", "to_node": "xform" }
]
}));
let warnings = graph_wiring_warnings(&config, &fake_field).await;
assert!(
warnings
.iter()
.any(|w| w.contains("not_a_probed_field") && w.contains("post")),
"{warnings:?}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// degrade_completed_status (PR2 — run honesty)
// ─────────────────────────────────────────────────────────────────────────────
+597
View File
@@ -1776,6 +1776,387 @@ pub(crate) fn compute_composio_array_path(schema: Option<&Value>) -> Option<Stri
Some(format!("data.{path}"))
}
// ─────────────────────────────────────────────────────────────────────────────
// Real-output probe (systemic tool-contract fix, Part 3 / B12)
// ─────────────────────────────────────────────────────────────────────────────
//
// [`compute_composio_array_path`] above is entirely schema-derived — it has
// nothing to walk when Composio (or the backend-proxied listing path, see
// [`crate::openhuman::composio::ComposioToolFunction::output_parameters`]'s
// doc) simply never publishes `output_parameters` for an action. Verified
// live: EVERY GitHub action's `get_tool_contract` (including the curated
// `GITHUB_LIST_REPOSITORY_ISSUES`) comes back `output_fields: [],
// output_schema: null, primary_array_path: null` — there is no schema at all
// to fix a walker bug in. The one remaining source of ground truth is the
// real response itself, so [`probe_tool_output_sample`] makes ONE bounded,
// READ-only, REAL Composio call and derives the same shape of hint
// (`primary_array_path`/`output_fields`) from the ACTUAL value instead.
//
// This is the exact bug observed live on flow "funny reminders v2": with no
// schema to consult, the builder guessed `split_out.path = "json.data"` (the
// whole envelope payload — one item, the `{issues:[...]}` container) instead
// of the real `"json.data.issues"`, and the downstream condition/agent saw
// the wrong shape and produced zero reminders.
/// Top-level [`crate::openhuman::composio::ComposioExecuteResponse`] fields
/// that are never part of the tool's own payload — skipped at the ROOT by
/// [`compute_primary_array_path_from_value`]'s scan so an envelope field
/// never masquerades as (or shadows) the real array. None of these are ever
/// arrays in practice, but the skip is explicit so a future envelope field
/// can't silently win a shallowest-wins tie against a real nested array.
const COMPOSIO_ENVELOPE_META_KEYS_AT_ROOT: &[&str] =
&["successful", "error", "costUsd", "markdownFormatted"];
/// [`compute_primary_array_path`]'s counterpart for a REAL runtime value
/// rather than a schema — walks a
/// [`crate::openhuman::composio::ComposioExecuteResponse`]-shaped value
/// (envelope AND payload together, e.g. `{data: {issues: [...]}, successful:
/// true, …}`) breadth-first for the first array-typed property, skipping
/// [`COMPOSIO_ENVELOPE_META_KEYS_AT_ROOT`] at the root.
///
/// Because the scan starts at the envelope root (not the unwrapped payload),
/// a hit under `data` naturally comes back prefixed (`"data.issues"`) with no
/// separate `data.` stitching step needed, unlike
/// [`compute_composio_array_path`] — this walks real data, not a schema
/// relative to the unwrapped payload.
///
/// `None` when no array is found anywhere in the value (e.g. every field is a
/// scalar) — a genuinely empty real list still serializes as `[]`, an array,
/// so this only returns `None` for a shape that truly has no list anywhere.
pub(crate) fn compute_primary_array_path_from_value(value: &Value) -> Option<String> {
let mut queue: std::collections::VecDeque<(String, &Value)> = std::collections::VecDeque::new();
queue.push_back((String::new(), value));
while let Some((path, node)) = queue.pop_front() {
let Some(obj) = node.as_object() else {
continue;
};
for (key, v) in obj {
if path.is_empty() && COMPOSIO_ENVELOPE_META_KEYS_AT_ROOT.contains(&key.as_str()) {
continue;
}
if v.is_array() {
let prop_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
return Some(prop_path);
}
}
for (key, v) in obj {
if path.is_empty() && COMPOSIO_ENVELOPE_META_KEYS_AT_ROOT.contains(&key.as_str()) {
continue;
}
let prop_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
queue.push_back((prop_path, v));
}
}
None
}
/// One real, LIVE-sampled Composio action result — [`probe_tool_output_sample`]'s
/// cached ground truth, keyed by action slug (uppercased). Distinct from
/// (and takes priority over, via [`apply_probe_override`]) the schema-derived
/// fields on [`ToolContract`]: a probe is an ACTUAL observed response, not a
/// published schema Composio may or may not provide.
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct ProbedOutputSample {
/// Dotted path (relative to the envelope's own `json` field — prefix with
/// `"json."` for a `split_out.path`, same convention as
/// [`ToolContract::primary_array_path`]) to the first array found in the
/// real response. `None` when the real response named no array at all.
pub primary_array_path: Option<String>,
/// Top-level field names of the real response's `data` payload — the
/// probed analogue of [`ToolContract::output_fields`].
pub output_fields: Vec<String>,
/// The full envelope-shaped sample value the probe observed, verbatim —
/// returned to `probe_tool_output_sample`'s IMMEDIATE caller only for
/// this one call. **Never persisted** into [`PROBE_CACHE`]
/// ([`cache_probe_result`] redacts it to `Value::Null` before inserting)
/// — the process-wide cache is keyed by slug alone, and a real probe
/// response can carry one user/connection/args' actual private data
/// (repo issues, messages, …); nothing else in the process reads a
/// CACHED sample (only the derived `primary_array_path`/`output_fields`
/// do), so retaining the full payload there would be pure unnecessary
/// exposure (see PR #4702 review).
pub sample: Value,
}
/// Process-level cache backing [`probe_tool_output_sample`]: action slug
/// (uppercased) → the [`ProbedOutputSample`] it produced. One real probe per
/// slug per process — mirrors [`LIVE_CATALOG_CACHE`]'s one-fetch-per-process
/// shape, and for the same reason: a probe is a real, potentially
/// rate-limited/billed external call, not something to repeat every turn.
///
/// Entries here always have `sample` redacted to `Value::Null` — see
/// [`cache_probe_result`] and [`ProbedOutputSample::sample`]'s doc.
static PROBE_CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, ProbedOutputSample>>,
> = std::sync::OnceLock::new();
/// Seeds the probe cache for a slug — test hook so [`apply_probe_override`]
/// and the enforcement checks that consult a probe can be exercised without a
/// live Composio backend. Unlike [`cache_probe_result`], does NOT redact
/// `sample` — tests seed only small synthetic fixtures, never real user data.
#[cfg(test)]
pub(crate) fn seed_probe_cache(slug: &str, sample: ProbedOutputSample) {
PROBE_CACHE
.get_or_init(Default::default)
.lock()
.expect("probe cache poisoned")
.insert(slug.trim().to_ascii_uppercase(), sample);
}
/// Caches the DERIVED metadata from a real probe — never the raw `sample`
/// payload itself (redacted to `Value::Null` here). See
/// [`ProbedOutputSample::sample`]'s doc for why: a real probe response can
/// contain one user/connection/args' actual private data, and nothing that
/// reads from the cache (only [`apply_probe_override`]) ever needs the raw
/// payload — only the derived `primary_array_path`/`output_fields`.
fn cache_probe_result(slug: &str, sample: ProbedOutputSample) {
let cached = ProbedOutputSample {
sample: Value::Null,
..sample
};
if let Ok(mut cache) = PROBE_CACHE.get_or_init(Default::default).lock() {
cache.insert(slug.trim().to_ascii_uppercase(), cached);
}
}
/// Looks up a cached [`ProbedOutputSample`] for `slug`, or `None` when
/// [`probe_tool_output_sample`] has never successfully probed it this
/// process.
pub(crate) fn probed_output_sample(slug: &str) -> Option<ProbedOutputSample> {
PROBE_CACHE
.get_or_init(Default::default)
.lock()
.ok()?
.get(&slug.trim().to_ascii_uppercase())
.cloned()
}
/// Overlays a cached [`probed_output_sample`] (if any) onto a schema-derived
/// [`ToolContract`] — the probe, being an ACTUAL observed response, always
/// wins over the schema-derived hint when both are present. A contract with
/// no cached probe passes through unchanged. Called everywhere a
/// [`ToolContract`] is consulted for wiring (`get_tool_contract`,
/// `graph_output_field_warnings`, `graph_split_out_path_warnings`) so a
/// probe the builder already ran is never shadowed by a stale/absent schema.
///
/// `primary_array_path` is overlaid UNCONDITIONALLY (including `None`) once a
/// probe exists: a probe's `None` is itself meaningful ("the real response
/// named no array anywhere"), not "no opinion" — leaving a stale
/// schema-derived path in place after a real observation disproves it would
/// let a since-confirmed-wrong `split_out.path` keep looking supported (see
/// PR #4702 review). `output_fields` only overlays when non-empty since an
/// empty probe result there genuinely means "unknown", not "confirmed empty".
pub(crate) fn apply_probe_override(mut contract: ToolContract) -> ToolContract {
if let Some(probe) = probed_output_sample(&contract.slug) {
contract.primary_array_path = probe.primary_array_path;
if !probe.output_fields.is_empty() {
contract.output_fields = probe.output_fields;
}
}
contract
}
/// Best-effort, but FAIL-CLOSED, classification of a Composio action slug's
/// [`ToolScope`] — mirrors [`flow_tool_allowed`]'s Path A / Path B split
/// rather than trusting `classify_unknown`'s verb heuristic unconditionally:
///
/// - Toolkit has no extractable prefix at all (`toolkit_from_slug` fails):
/// `None` — nothing to confirm a scope against.
/// - Toolkit HAS a static curated catalog (`get_provider().curated_tools()`
/// or `catalog_for_toolkit`): the slug's scope is authoritative ONLY if the
/// slug is actually one of that catalog's curated entries. An uncurated
/// slug on a cataloged toolkit resolves to `None` — it must NOT fall
/// through to the verb heuristic, which can misclassify an uncurated write
/// action (e.g. a connected GitHub/Gmail action not in the curated list)
/// as `Read` by name alone (see PR #4702 review — this exact hole would
/// otherwise let the probe execute a real write).
/// - Toolkit has NO static catalog at all: falls back to `classify_unknown`
/// — the same authority [`flow_tool_allowed`]'s Path B accepts once it has
/// already confirmed (via its own connected + live-catalog checks) the
/// slug is real; here it's just the scope signal, gated further below.
///
/// Used exclusively by [`probe_tool_output_sample`] to hard-refuse anything
/// that isn't a CONFIDENTLY CONFIRMED `Read` action REGARDLESS of the user's
/// per-toolkit scope preference — unlike [`flow_tool_allowed`], which honors
/// a user's opt-in to Write/Admin for a real `tool_call` node, a
/// schema-discovery probe must never perform a real mutation no matter what
/// the user has toggled on, and must never rely on a heuristic guess to
/// decide that: the builder never asked for (and the user never approved)
/// THIS specific write. `None` means "refuse — no confirmed Read scope", not
/// "assume Read".
fn resolve_composio_action_scope(
slug: &str,
) -> Option<crate::openhuman::memory_sync::composio::providers::ToolScope> {
use crate::openhuman::memory_sync::composio::providers::{
catalog_for_toolkit, classify_unknown, find_curated, get_provider, toolkit_from_slug,
};
let toolkit = toolkit_from_slug(slug)?;
match get_provider(&toolkit)
.and_then(|p| p.curated_tools())
.or_else(|| catalog_for_toolkit(&toolkit))
{
// A static catalog exists for this toolkit — only a genuinely
// curated entry's scope is trustworthy; an uncurated slug fails
// closed rather than being guessed via the verb heuristic.
Some(catalog) => find_curated(catalog, slug).map(|curated| curated.scope),
// No static catalog anywhere for this toolkit — the heuristic is
// the only available signal (still gated by the connected-toolkit
// check below in `probe_tool_output_sample`).
None => Some(classify_unknown(slug)),
}
}
/// `get_tool_output_sample`'s implementation — see the module comment above
/// this section for why it exists. Gates, in order (fail closed on any):
///
/// 1. **Scope**: [`resolve_composio_action_scope`] must CONFIRM `slug` as
/// `Read` (`None` — no confirmed scope, e.g. an uncurated slug on a
/// cataloged toolkit — refuses exactly like a confirmed non-`Read` scope
/// does; it is never treated as "assume Read").
/// 2. **Connected**: the slug's toolkit must have an active Composio
/// connection.
///
/// On success, derives + caches a [`ProbedOutputSample`] (process-lifetime,
/// keyed by slug) and returns it. `args` is forwarded verbatim to the real
/// call — the builder should pass the SAME arguments it intends to wire into
/// the real `tool_call` node (this is a sample of THAT call, not a generic
/// fixture); omitted/`null` becomes `{}`, which is fine for a
/// zero-required-arg action.
pub(crate) async fn probe_tool_output_sample(
config: &Config,
slug: &str,
args: Value,
) -> std::result::Result<ProbedOutputSample, String> {
let slug = slug.trim();
if slug.is_empty() {
return Err("get_tool_output_sample: slug must not be empty".to_string());
}
match resolve_composio_action_scope(slug) {
Some(crate::openhuman::memory_sync::composio::providers::ToolScope::Read) => {}
Some(other) => {
tracing::warn!(
target: "flows",
%slug,
scope = other.as_str(),
"[flows] get_tool_output_sample: refused — not a Read-scope action"
);
return Err(format!(
"get_tool_output_sample refuses `{slug}`: classified as {} — this probe is \
READ-only and never performs a real mutation, regardless of the user's scope \
preference. Use get_tool_contract for its schema-derived (possibly unknown) \
output shape instead.",
other.as_str()
));
}
None => {
tracing::warn!(
target: "flows",
%slug,
"[flows] get_tool_output_sample: refused — no confirmed Read scope (either no \
extractable toolkit, or an uncurated slug on a toolkit with a static curated \
catalog — fails closed rather than guessing via the verb heuristic)"
);
return Err(format!(
"get_tool_output_sample refuses `{slug}`: could not confirm this is a Read-scope \
action. Either no toolkit could be extracted from the slug, or its toolkit ships \
a static curated catalog and this slug is not one of its curated actions — this \
probe never falls back to a verb-name heuristic in that case, since an uncurated \
action on a cataloged toolkit could really be a write. Use get_tool_contract for \
its schema-derived (possibly unknown) output shape instead."
));
}
}
let Some(toolkit) = crate::openhuman::memory_sync::composio::providers::toolkit_from_slug(slug)
else {
return Err(format!(
"get_tool_output_sample: could not extract a toolkit from slug '{slug}' — it must \
look like '<TOOLKIT>_<ACTION>'."
));
};
let integrations = crate::openhuman::composio::fetch_connected_integrations(config).await;
let connected = integrations
.iter()
.any(|i| i.connected && i.toolkit.eq_ignore_ascii_case(&toolkit));
if !connected {
tracing::warn!(target: "flows", %slug, %toolkit, "[flows] get_tool_output_sample: refused — toolkit not connected");
return Err(format!(
"get_tool_output_sample refuses `{slug}`: the '{toolkit}' toolkit has no active \
Composio connection for this user — connect it first (composio_connect), or fall \
back to get_tool_contract's schema-derived hint."
));
}
tracing::debug!(
target: "flows",
%slug,
%toolkit,
"[flows] get_tool_output_sample: probing the real live response (read-only, bounded, one call)"
);
let kind = create_composio_client(config).map_err(|e| e.to_string())?;
let args_opt = if args.is_null() { None } else { Some(args) };
let resp = match kind {
ComposioClientKind::Backend(client) => client
.execute_tool(slug, args_opt)
.await
.map_err(|e| format!("get_tool_output_sample: real call to `{slug}` failed: {e}"))?,
ComposioClientKind::Direct(tool) => {
direct_execute(&tool, slug, args_opt, &config.composio.entity_id, None)
.await
.map_err(|e| format!("get_tool_output_sample: real call to `{slug}` failed: {e}"))?
}
};
if !resp.successful {
let detail = resp
.error
.as_deref()
.map(str::trim)
.filter(|e| !e.is_empty())
.unwrap_or("no error detail returned by the provider");
return Err(format!(
"get_tool_output_sample: `{slug}` reported failure at the connected provider: {detail}"
));
}
let envelope = serde_json::to_value(&resp).map_err(|e| {
format!("get_tool_output_sample: could not serialize the real response: {e}")
})?;
let primary_array_path = compute_primary_array_path_from_value(&envelope);
let output_fields = resp
.data
.as_object()
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
let sample = ProbedOutputSample {
primary_array_path,
output_fields,
sample: envelope,
};
cache_probe_result(slug, sample.clone());
tracing::info!(
target: "flows",
%slug,
primary_array_path = ?sample.primary_array_path,
"[flows] get_tool_output_sample: probed + cached the real output shape"
);
Ok(sample)
}
/// Best-effort lookup of a Composio action's **required** top-level parameter
/// names — a thin projection over [`fetch_live_toolkit_catalog`]'s
/// [`ToolContract`]s (this used to run its own independent
@@ -4034,6 +4415,222 @@ mod tests {
);
}
// ── compute_primary_array_path_from_value (B12: the real-output probe) ──
#[test]
fn compute_primary_array_path_from_value_finds_a_named_array_under_data() {
// The exact GITHUB_LIST_REPOSITORY_ISSUES shape observed live: the
// real array lives at `data.issues` (a NAMED field), not `data.items`
// — and there is no schema at all to derive this from (verified live:
// `output_schema: null` for this action), so only a real-value probe
// can find it.
let value = json!({
"data": { "issues": [ { "id": 1 }, { "id": 2 } ], "total_count": 2 },
"successful": true,
"error": null,
"costUsd": 0.0,
"markdownFormatted": null
});
assert_eq!(
compute_primary_array_path_from_value(&value),
Some("data.issues".to_string())
);
}
#[test]
fn compute_primary_array_path_from_value_skips_envelope_metadata_at_the_root() {
// None of the envelope's OTHER top-level fields are ever arrays in
// practice, but the skip-list is explicit so one never wins a
// shallowest-wins tie against a real nested array.
let value = json!({
"successful": true,
"error": null,
"costUsd": 0.0,
"markdownFormatted": null,
"data": { "messages": ["a", "b"] }
});
assert_eq!(
compute_primary_array_path_from_value(&value),
Some("data.messages".to_string())
);
}
#[test]
fn compute_primary_array_path_from_value_none_when_no_array_anywhere() {
let value = json!({
"data": { "id": "abc123", "name": "octocat" },
"successful": true
});
assert_eq!(compute_primary_array_path_from_value(&value), None);
assert_eq!(compute_primary_array_path_from_value(&json!(null)), None);
assert_eq!(
compute_primary_array_path_from_value(&json!("scalar")),
None
);
}
// ── apply_probe_override (B12) ───────────────────────────────────────────
fn bare_contract(slug: &str) -> ToolContract {
ToolContract {
slug: slug.to_string(),
toolkit: "github".to_string(),
description: None,
required_args: vec![],
input_schema: None,
output_fields: vec![],
output_schema: None,
primary_array_path: None,
is_curated: true,
}
}
#[test]
fn apply_probe_override_overlays_a_cached_probe_onto_a_schemaless_contract() {
seed_probe_cache(
"PROBETEST_LIST_REPOSITORY_ISSUES",
ProbedOutputSample {
primary_array_path: Some("data.issues".to_string()),
output_fields: vec!["issues".to_string(), "total_count".to_string()],
sample: json!({ "data": { "issues": [], "total_count": 0 } }),
},
);
let contract = bare_contract("PROBETEST_LIST_REPOSITORY_ISSUES");
assert_eq!(contract.primary_array_path, None);
let overridden = apply_probe_override(contract);
assert_eq!(
overridden.primary_array_path,
Some("data.issues".to_string())
);
assert_eq!(
overridden.output_fields,
vec!["issues".to_string(), "total_count".to_string()]
);
}
#[test]
fn apply_probe_override_passes_through_unchanged_without_a_cached_probe() {
let contract = bare_contract("PROBETEST_SOME_UNPROBED_ACTION");
let overridden = apply_probe_override(contract.clone());
assert_eq!(overridden.primary_array_path, contract.primary_array_path);
assert_eq!(overridden.output_fields, contract.output_fields);
}
/// CodeRabbit (PR #4702 review): a probe that OBSERVED the real response
/// and found no array anywhere must CLEAR a stale schema-derived
/// `primary_array_path`, not merely leave it in place because the probe's
/// own path happens to be `None`. A schema-derived path a real
/// observation just disproved is worse than no path at all — it would
/// otherwise keep suggesting a `split_out.path` the probe itself showed
/// is wrong.
#[test]
fn apply_probe_override_clears_a_stale_schema_path_when_the_probe_finds_no_array() {
seed_probe_cache(
"PROBETEST_CLEARS_STALE_PATH",
ProbedOutputSample {
primary_array_path: None,
output_fields: vec![],
sample: json!({ "data": { "id": "abc123" } }),
},
);
let mut contract = bare_contract("PROBETEST_CLEARS_STALE_PATH");
contract.primary_array_path = Some("data.items".to_string());
let overridden = apply_probe_override(contract);
assert_eq!(overridden.primary_array_path, None);
}
/// PR #4702 review (security): the process-wide [`PROBE_CACHE`] must
/// never retain the raw observed payload — only derived metadata. A real
/// probe response can carry one user/connection/args' actual private
/// data (repo issues, messages, …), and nothing that reads the CACHE
/// (only [`apply_probe_override`], via [`probed_output_sample`]) ever
/// needs the raw payload.
#[test]
fn cache_probe_result_redacts_the_raw_sample_before_caching() {
cache_probe_result(
"PROBETEST_REDACTS_SAMPLE",
ProbedOutputSample {
primary_array_path: Some("data.issues".to_string()),
output_fields: vec!["issues".to_string()],
sample: json!({ "data": { "issues": [{"secret": "do-not-retain"}] } }),
},
);
let cached =
probed_output_sample("PROBETEST_REDACTS_SAMPLE").expect("just cached this slug");
assert_eq!(cached.sample, Value::Null);
// The derived metadata is still cached faithfully — only the raw
// payload is redacted.
assert_eq!(cached.primary_array_path, Some("data.issues".to_string()));
}
// ── resolve_composio_action_scope (B12: hard Read-only gate) ─────────────
#[test]
fn resolve_composio_action_scope_uses_the_curated_catalog_when_available() {
use crate::openhuman::memory_sync::composio::providers::ToolScope;
// GITHUB_LIST_REPOSITORY_ISSUES is curated as Read (github/tools.rs).
assert_eq!(
resolve_composio_action_scope("GITHUB_LIST_REPOSITORY_ISSUES"),
Some(ToolScope::Read)
);
// A curated Write action must classify as Write, not Read — the probe
// must refuse it regardless of the verb heuristic agreeing or not.
assert_eq!(
resolve_composio_action_scope("GMAIL_SEND_EMAIL"),
Some(ToolScope::Write)
);
}
/// PR #4702 review (P1): a toolkit with a static curated catalog (like
/// `github`) must NOT fall through to the `classify_unknown` verb
/// heuristic for a slug that isn't actually one of its curated actions —
/// `GITHUB_LIST_WORKFLOWS` is a REAL GitHub action name (reads as
/// Read-scope by its `LIST` verb) that was deliberately left uncurated
/// (see the commented-out entry in `github/tools.rs`), so this must
/// resolve to `None` (fail closed), not `Some(ToolScope::Read)` — the
/// heuristic agreeing with the "looks safe" name is exactly the
/// misclassification hole this guards against.
#[test]
fn resolve_composio_action_scope_rejects_an_uncurated_slug_on_a_cataloged_toolkit() {
assert_eq!(resolve_composio_action_scope("GITHUB_LIST_WORKFLOWS"), None);
}
#[test]
fn resolve_composio_action_scope_falls_back_to_the_verb_heuristic_only_without_a_static_catalog(
) {
use crate::openhuman::memory_sync::composio::providers::ToolScope;
assert_eq!(
resolve_composio_action_scope("MADEUPTOOLKIT_LIST_THINGS"),
Some(ToolScope::Read)
);
assert_eq!(
resolve_composio_action_scope("MADEUPTOOLKIT_DELETE_THING"),
Some(ToolScope::Admin)
);
}
// ── probe_tool_output_sample (B12: gates) ────────────────────────────────
#[tokio::test]
async fn probe_tool_output_sample_refuses_a_non_read_action_before_any_client_call() {
let config = Config::default();
let result = probe_tool_output_sample(&config, "GMAIL_SEND_EMAIL", json!({})).await;
let err = result.expect_err("a Write action must be refused");
assert!(err.contains("READ-only"), "{err}");
}
/// PR #4702 review (P1): the probe entry point itself must refuse an
/// uncurated-but-read-sounding slug on a cataloged toolkit BEFORE any
/// client call — not just `resolve_composio_action_scope` in isolation.
#[tokio::test]
async fn probe_tool_output_sample_refuses_an_uncurated_slug_on_a_cataloged_toolkit_before_any_client_call(
) {
let config = Config::default();
let result = probe_tool_output_sample(&config, "GITHUB_LIST_WORKFLOWS", json!({})).await;
let err = result.expect_err("an uncurated slug on a cataloged toolkit must be refused");
assert!(err.contains("could not confirm"), "{err}");
}
// ── fetch_live_toolkit_catalog / composio_required_args /
// composio_response_fields delegation ─────────────────────────────────
+9
View File
@@ -301,6 +301,15 @@ pub fn all_tools_with_runtime(
// search_tool_catalog — the grounding step before WIRING a node's
// args/downstream bindings (systemic tool-contract fix, Part 1).
Box::new(GetToolContractTool::new(config.clone())),
// B12: ONE bounded, READ-ONLY, REAL Composio call to derive the real
// primary_array_path/output_fields when the live listing publishes no
// output schema at all (verified for every GitHub action) — overrides
// get_tool_contract's schema-derived hint for that slug from then on.
// Read-scope actions only (hard-refused otherwise), connected
// toolkits only — see builder_tools.rs's module doc for the carve-out
// this makes in the workflow-builder agent's "no composio_execute"
// invariant.
Box::new(GetToolOutputSampleTool::new(config.clone())),
// Ground an `agent` node's `agent_ref` in real registered agent-kind ids
// (researcher / code_executor / …) — the agent analogue of
// search_tool_catalog. Read-only.