Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ jobs:
openhuman/agent/harness/subagent_runner/tool_prep.rs
openhuman/agent_registry/agents/loader.rs
openhuman/inference/local/mod.rs
openhuman/tinyagents/contract_gate_tests.rs
openhuman/tool_registry/ops_tests.rs
openhuman/tool_registry/schemas.rs
openhuman/tools/ops_tests.rs
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ argon2 = "0.5"
rand = "0.10"
dirs = "5"
sha2 = "0.10"
# Fast, stable (cross-process reproducible) non-cryptographic hash. Used by the
# contract gate to fingerprint a delivered contract body so a later rescan can
# detect that summarization / a size cap / rewriting mutated it.
xxhash-rust = { version = "0.8", features = ["xxh3"] }
# Line-level text diffs for the memory_diff module (modified-item unified diffs).
similar = "2"
# Git-backed change ledger for the memory_diff module: snapshots are commits,
Expand Down
27 changes: 27 additions & 0 deletions src/openhuman/agent/harness/subagent_runner/handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,30 @@ pub(super) fn chunk_content(content: &str, budget: usize) -> Vec<String> {

chunks
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn clean_tool_output_collapses_indentation_whitespace() {
// Documents the hazard the contract gate's `trusted_verbatim` guard exists
// for: `clean_tool_output` collapses every run of spaces/tabs to ONE space,
// flattening a pretty-printed JSON schema's 2/4/6-space nesting to 1 space.
// A contract-gate delivery run through this would change the bytes after its
// `[contract-gate:…]` marker, so the rescan digest would miss and re-deliver
// forever — hence `HandoffMiddleware::after_tool` must skip trusted results.
// If this assertion ever changes, revisit that guard.
let pretty =
"{\n \"properties\": {\n \"max_results\": {\n \"default\": 10\n }\n }\n}";
let cleaned = clean_tool_output(pretty);
assert!(
cleaned.contains("\n \"properties\": {"),
"nested indentation must collapse to a single space, got:\n{cleaned}"
);
assert!(
!cleaned.contains(" \"properties\""),
"no run of 2+ spaces should survive, got:\n{cleaned}"
);
}
}
13 changes: 5 additions & 8 deletions src/openhuman/agent/harness/subagent_runner/ops/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,11 @@ pub(crate) struct LazyToolkitResolver {
const TIER4_MIN_SLUG_LEN: usize = 8;

impl LazyToolkitResolver {
/// NOTE (contract gate, #4853): this builds a *fresh* `ComposioActionTool`
/// — and therefore a fresh, empty `ContractGate` — on every call. The gate's
/// surface-once state lives in the tool instance, so if a future wiring
/// resolves a new tool per invocation the gate would surface the full
/// contract on every call and the retry would never see `first_time == false`
/// to proceed. When this path is wired to actually dispatch, cache the
/// resolved tool (and its gate) per turn so a given action can proceed after
/// its contract has been surfaced once.
/// NOTE (contract gate, #4853): this may build a *fresh* `ComposioActionTool`
/// per call, which is fine — per-action contract gating now lives in the
/// `tinyagents::contract_gate` middleware and derives "already surfaced" from
/// the transcript, not from any per-tool-instance state. So a new tool per
/// invocation no longer risks re-surfacing the contract every call.
pub(super) fn resolve(&self, name: &str) -> Option<Box<dyn crate::openhuman::tools::Tool>> {
let action = self.find_action(name)?;
Some(Box::new(
Expand Down
7 changes: 7 additions & 0 deletions src/openhuman/agent/tools/run_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ impl Tool for RunWorkflowTool {
RUN_WORKFLOW_TOOL_NAME
}

/// Expose this profile's runnable-workflow allowlist to the contract gate so
/// it never renders the input contract of a workflow this profile scopes out
/// (issue #4853; matches the `execute` allowlist check below).
fn workflow_gate_allowlist(&self) -> Option<&std::collections::HashSet<String>> {
self.skill_allowlist.as_ref()
}

fn description(&self) -> &str {
"Run another workflow as a subagent and wait for its result, the way a \
function call waits on its callee. Spawns the target workflow as a \
Expand Down
133 changes: 22 additions & 111 deletions src/openhuman/composio/action_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,6 @@ pub struct ComposioActionTool {
/// Composio connection. Used when the sub-agent is spawned for a
/// particular account (e.g. "send from my work Gmail").
connection_id: Option<String>,
/// Per-turn contract gate (#4853). On the first call to this action the
/// gate surfaces the action's FULL live input schema/description so the
/// model composes well-formed arguments (e.g. correctly-quoted Gmail
/// queries) instead of guessing from the thin spawn-time schema; the retry
/// executes normally. Held per tool instance, which lives for one
/// `integrations_agent` spawn, so "seen" is scoped to that turn.
gate: super::contract_gate::ContractGate,
}

impl ComposioActionTool {
Expand All @@ -100,7 +93,6 @@ impl ComposioActionTool {
description,
parameters,
connection_id,
gate: super::contract_gate::ContractGate::new(),
}
}
}
Expand All @@ -123,6 +115,10 @@ impl Tool for ComposioActionTool {
&self.action_name
}

fn composio_action_slug(&self) -> Option<&str> {
Some(&self.action_name)
}

fn description(&self) -> &str {
&self.description
}
Expand Down Expand Up @@ -192,15 +188,13 @@ impl Tool for ComposioActionTool {
}
}

// [#1710 Wave 4 / #4853] Reload the live config snapshot ONCE, up front,
// and use it for BOTH the contract-gate lookup and dispatch. A mid-session
// `composio.mode` / credential / workspace change must route the gate's
// live-catalog fetch and the actual execution through the SAME config;
// consulting the captured spawn-time `self.config` here would let the gate
// resolve (or skip) a contract against stale routing while dispatch used
// fresh routing. Anchored to this tool's original config path rather than
// re-resolving process-global `OPENHUMAN_WORKSPACE` (the tool is scoped to
// the user/workspace it was created for).
// [#1710 Wave 4] Reload the live config snapshot ONCE, up front, and use it
// for dispatch. A mid-session `composio.mode` / credential / workspace
// change must route execution through fresh config; consulting the captured
// spawn-time `self.config` here would dispatch against stale routing.
// Anchored to this tool's original config path rather than re-resolving
// process-global `OPENHUMAN_WORKSPACE` (the tool is scoped to the
// user/workspace it was created for).
let live_config =
match config_rpc::reload_config_snapshot_with_timeout(self.config.as_ref()).await {
Ok(c) => c,
Expand All @@ -217,26 +211,14 @@ impl Tool for ComposioActionTool {
}
};

// Contract gate (#4853): the per-action tool is built from the thin
// spawn-time `list_tools` schema (often `{"type":"object"}` with no
// field descriptions), so the model guesses argument formats — most
// visibly sending unquoted Gmail `query` strings that return zero
// results. On the first call this turn, surface the action's FULL live
// contract (input schema + description) as a recoverable tool error and
// let the retry — now with the schema in context — execute. Degrades to
// a normal execute whenever the contract can't be resolved (see
// `contract_gate::consult`), so an unconfigured/offline client never
// blocks the action. Uses `live_config` so gate routing matches dispatch.
match super::contract_gate::consult(&self.gate, &live_config, &self.action_name).await {
super::contract_gate::GateDecision::Surface(contract) => {
tracing::info!(
tool = %self.action_name,
"[composio][contract-gate] returning full contract before first execute"
);
return Ok(ToolResult::error(contract));
}
super::contract_gate::GateDecision::Proceed => {}
}
// NOTE: per-action contract gating now lives in the unified
// `tinyagents::contract_gate` middleware (issue #4853), whose
// transcript-derived presence + payload-digest integrity re-delivers the
// contract if it is summarised or compacted out of context — closing the
// gap the in-memory `composio::contract_gate` (#4995) documented. That
// module is left in place but no longer consulted here (removal tracked as
// follow-up); the middleware short-circuits the first call before execute,
// so this path only runs once the contract is already in context.

// Inject `timeZone` / `singleEvents` defaults for Google
// Calendar list slugs (issue #1714). The per-action surface is
Expand All @@ -258,9 +240,9 @@ impl Tool for ComposioActionTool {

// Resolve the client through the mode-aware factory on every call so a
// direct-mode toggle takes effect immediately (#1710), reusing the
// `live_config` snapshot reloaded above so the gate lookup and this
// dispatch share identical routing. The pre-baked-client variant routed
// all executions through the backend tinyhumans tenant regardless of mode.
// `live_config` snapshot reloaded above so a mid-session mode/credential
// change routes this dispatch through fresh config. The pre-baked-client
// variant routed all executions through the backend tenant regardless of mode.
let kind = match create_composio_client(&live_config) {
Ok(kind) => kind,
Err(e) => {
Expand Down Expand Up @@ -523,77 +505,6 @@ mod tests {
);
}

// Seeds the flows/tinyflows live-catalog cache, so it only builds with the
// `flows` feature on (the gate degrades to a no-op when flows is off).
#[cfg(feature = "flows")]
#[tokio::test]
async fn contract_gate_surfaces_full_contract_then_proceeds_on_retry() {
// Regression for #4853: the FIRST per-action execute this turn must
// return the action's FULL live contract (so the model composes a
// well-formed query) instead of running with the thin spawn-time
// schema; the retry then proceeds to real dispatch. A unique toolkit is
// seeded so this is deterministic and never touches the network.
use crate::openhuman::config::TEST_ENV_LOCK;
use crate::openhuman::tinyflows::caps::{seed_live_catalog_cache, ToolContract};
let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

let toolkit = "cgateexec";
let slug = "CGATEEXEC_FETCH_ITEMS";
seed_live_catalog_cache(
toolkit,
vec![ToolContract {
slug: slug.to_string(),
toolkit: toolkit.to_string(),
description: Some("Search items. Quote multi-word phrases.".to_string()),
required_args: vec!["query".to_string()],
input_schema: Some(serde_json::json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
})),
output_fields: Vec::new(),
output_schema: None,
primary_array_path: None,
is_curated: false,
}],
);

let tmp = tempfile::tempdir().expect("tempdir");
let _workspace_guard = WorkspaceEnvGuard::set(tmp.path());
let mut config = Config::default();
config.config_path = tmp.path().join("config.toml");
config.workspace_dir = tmp.path().join("workspace");
config.save().await.expect("save fake config to disk");

let t = ComposioActionTool::new(
Arc::new(config),
slug.to_string(),
"search items".to_string(),
None,
);

// First call: gate surfaces the contract (recoverable tool error).
let first = t.execute(serde_json::json!({})).await.unwrap();
assert!(
first.is_error,
"first call must surface a recoverable error"
);
let first_msg = error_text(&first);
assert!(
first_msg.contains("Input JSON schema"),
"first call must carry the full contract, got: {first_msg}"
);

// Retry: gate proceeds; dispatch fails downstream (no session token) but
// crucially NOT with the contract text — proving the gate did not block.
let second = t.execute(serde_json::json!({})).await.unwrap();
let second_msg = error_text(&second);
assert!(
!second_msg.contains("Input JSON schema"),
"retry must proceed past the gate to real dispatch, got: {second_msg}"
);
}

// ── Factory routing (#1710) ──────────────────────────────────────
//
// Regression coverage for the bug fix: `ComposioActionTool` now
Expand Down
24 changes: 21 additions & 3 deletions src/openhuman/composio/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ use crate::openhuman::agent::harness::current_task_recency_window;
use crate::openhuman::agent::harness::definition::SandboxMode;
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::config::Config;
use crate::openhuman::tinyagents::contract_gate;
use crate::openhuman::tools::traits::{
PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolResult,
};
Expand Down Expand Up @@ -1229,9 +1230,26 @@ impl Tool for ComposioListToolsTool {
}
}

let mut result = ToolResult::success(
serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
);
let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into());
// Pre-credit the contract gate ONLY when the model will actually
// read the full JSON schemas (i.e. NOT the thin markdown path):
// then a `composio_execute` for any listed slug skips a redundant
// contract re-delivery. Under `prefer_markdown` the model reads
// the compact rendering (schemas dropped) via `output_for_llm`,
// which is exactly the thin listing the gate guards against — so
// leave the marker off there. Marker leads for the prefix scan
// (issue #4853).
let content = if options.prefer_markdown {
json
} else {
contract_gate::prefix_with_present_marker(
resp.tools
.iter()
.map(|t| contract_gate::composio_key(&t.function.name)),
&json,
)
};
let mut result = ToolResult::success(content);
if options.prefer_markdown {
result.markdown_formatted = Some(render_tools_markdown(&resp));
}
Expand Down
Loading
Loading