Skip to content

Composio contract-gate never executes in sub-agent path — 'what's my latest email' loops forever (GMAIL_FETCH_EMAILS surfaced 51× never runs) #5119

Description

@M3gA-Mind

Summary

Asking the agent "what's my latest email" (or any Composio-tool task routed through integrations_agent) loops and gives up with "the same tool call was issued 3 times in a row with identical arguments and no new information — the run is stuck repeating one action without making progress." Gmail is connected and healthy — the failure is entirely in agent tool-execution: the Composio contract-gate never advances to actually executing the tool, so GMAIL_FETCH_EMAILS never runs and the email is never fetched.

Diagnosed from a user's openhuman.2026-07-21.log.

Root cause

The Composio contract-gate design is two-step: on the first call to an action tool it returns the tool's full contract (schema) so the agent learns the exact arguments, and expects a second call to actually execute. In the sub-agent path this second (executing) call never happens:

  • The main agent delegates to a fresh integrations_agent sub-agent on each attempt (new task_id every time — sub-80b72971…, sub-14722780…, sub-9dddbf51…), running on burst-v1 with ~2 iterations.
  • Each fresh spawn gets a fresh gate, so it logs [composio][contract-gate] returning full contract before first execute tool=GMAIL_FETCH_EMAILS and stops there. In this one log that line fires 51 times and GMAIL_FETCH_EMAILS never executes.
  • Each sub-agent completes iterations=2, output_chars≈109 — just the contract text, no email. The main agent never receives the email → re-delegates → wait backoff (10s/15s/20s) → the loop-detection guard fires and it gives up.

Log excerpt (one of many identical cycles):

23:16:27 [subagent_runner] dispatching agent_id=integrations_agent task_id=sub-80b72971… model=burst-v1
23:16:31 [composio][contract-gate] returning full contract before first execute tool=GMAIL_FETCH_EMAILS
23:16:35 [subagent_runner] completed agent_id=integrations_agent … iterations=2 output_chars=109
23:16:39 [wait] tool=wait duration_ms=10000
23:16:52 [agent] delegating to integrations_agent … (skill_filter=gmail)   ← spawns a FRESH agent+gate, repeats

'returning full contract before first execute' count: 51 across the session; zero actual executions.

This is a predicted failure

This is the exact forward-risk oxoxDev flagged on PR #4995 (the contract-gate work for #4853 "Composio Gmail search returns no results because agents cannot see the full tool contract"; epic #4249):

"the seen-set is scoped to the ComposioActionTool instance lifetime, not per-turn… LazyToolkitResolver::resolve() builds a fresh gate per call; when that path is wired for dispatch, a fresh gate per resolve means the contract is surfaced on every call and the action never proceeds — cache the resolved tool per turn."

It has materialized in the sub-agent delegation path, compounded by burst-v1 not reliably making the second (executing) call within its iteration budget.

Proposed fix

  • Persist the contract-gate "already-surfaced" state across sub-agent spawns within one user turn (cache the resolved tool + gate per turn, per oxoxDev's note), so re-delegation doesn't reset it.
  • And/or auto-execute the follow-up call within the same run after surfacing the contract, rather than relying on burst-v1 to re-call.
  • And/or skip the two-step gate for simple single-shot fetches like GMAIL_FETCH_EMAILS.

Acceptance criteria

  • A re-delegated Composio action (e.g. GMAIL_FETCH_EMAILS via integrations_agent) actually executes and returns a result — the contract is surfaced at most once per user turn, not once per spawn.
  • "What's my latest email" returns the email instead of looping into the "same tool call 3×" guard.
  • Regression test: a delegated Composio fetch across sub-agent spawns within one turn executes exactly once past the gate.

Related

Implementation Plan

Triage Assessment

This issue is relevant, critical, and not a duplicate. The root cause has been confirmed against the source code at three locations. The code even contains a NOTE comment (at src/openhuman/agent/harness/subagent_runner/ops/provider.rs:250-257) predicting exactly this failure mode. The problem is that LazyToolkitResolver::resolve() builds a fresh ComposioActionTool (and therefore a fresh, empty ContractGate) on every call, so the contract is surfaced on every sub-agent spawn but the execution never proceeds.

Proposed Scope

In-scope:

  1. Cache the resolved ComposioActionTool (and its ContractGate seen-set) per user turn so that re-delegation to the sub-agent reuses the same gate instance, allowing the contract to be surfaced exactly once and execution to proceed on subsequent calls.
  2. Add a fallback safety net: if the gate has been consulted N times (e.g. 3+) for the same slug without proceeding to execution, auto-execute on the next call by returning Proceed unconditionally.
  3. Unit-test the fix to cover both the normal surface-then-execute path and the repeated-delegation path.

Explicit non-goals:

Implementation Steps

  1. Cache resolved tool per user turn

    • Primary file: src/openhuman/agent/harness/subagent_runner/ops/provider.rs. The LazyToolkitResolver::resolve() method (lines 258-268) currently calls ComposioActionTool::new(...) on every invocation.
    • Add per-turn caching: maintain a HashMap<String, Box<dyn Tool>> (keyed by the action slug) inside the LazyToolkitResolver struct (line 237-240). The LazyToolkitResolver already lives for the duration of one integrations_agent spawn (see runner.rs:766), so its lifetime is naturally scoped to the turn.
    • Before building a new ComposioActionTool, check the cache by slug. If found, return the cached tool (which carries its ContractGate seen-set forward). If not found, build, cache, and return.
    • Update the crate::openhuman::context::prompt::ConnectedIntegrationTool struct if it's missing a Clone derive needed for caching (verify at src/openhuman/context/prompt/mod.rs).
  2. Add auto-proceed safety net

    • File: src/openhuman/composio/contract_gate.rs. The ContractGate struct (line 31-42) currently tracks a simple HashSet<String> of seen slugs.
    • Replace seen: Mutex<HashSet<String>> with seen: Mutex<HashMap<String, u32>> where the value is a consult count.
    • In mark_seen(self, slug: &str) -> bool, increment the counter. Return true (first time) only when the counter was 0. Add a new method consult(self, slug: &str) -> GateDecision that: if counter == 0 -> surface; if counter > 3 -> proceed unconditionally (auto-execute safety net); else -> proceed.
    • Rename mark_seen to a more descriptive gate_consult and move the decision logic into contract_gate.rs away from action_tool.rs.
    • The existing consult in action_tool.rs (which calls mark_seen and checks the result) should be refactored to call the new gate_consult method.
  3. Update the NOTE comment to reflect resolution

    • File: src/openhuman/agent/harness/subagent_runner/ops/provider.rs:250-257. Update the NOTE comment after the fix is implemented so it no longer describes a known-forward-risk but rather documents the caching strategy that solved it.

Verification

  • Unit tests:
    • src/openhuman/composio/contract_gate_tests.rs -- extend with tests for:
      • Repeated surface calls on the same slug return Proceed after the first surface.
      • After 3+ consults without proceeding, the gate auto-returns Proceed.
      • Independent gating across different slugs (if one slug is blocked, others proceed independently).
    • src/openhuman/composio/action_tool_tests.rs -- add an integration-level test that simulates multiple resolves from the same LazyToolkitResolver and confirms the gate surfaces once then proceeds.
  • Raw E2E tests: extend tests/raw_coverage/composio_ops_raw_coverage_e2e.rs or composio_raw_coverage_e2e.rs with a scenario that invokes ComposioActionTool twice on the same slug via the same LazyToolkitResolver and verifies the second call does not surface the contract again.
  • Manual verification: the issue author's reported log pattern ("returning full contract before first execute" count 51, zero executions) should no longer reproduce with the fix.

Risks and Open Questions

  • Cached tool lifetime vs config changes. The ComposioActionTool resolves the Composio client per-execute from Arc<Config> (Prioritize fully local speech and Composer operation #1710 constraint). Caching the tool instance is safe because the config reference is shared (Arc<Config>), so mid-session mode toggles are still honoured on the next execute -- the tool just survives across spawns. Verify this is true by checking that create_composio_client(config) is called inside the execute method, not at construction time (confirmed: it is per-call at action_tool.rs execute path).
  • Cache invalidation on user turn boundary. The LazyToolkitResolver currently lives per integrations_agent spawn. If the main agent delegates to integrations_agent in consecutive turns without re-spawning the resolver, the cache persists across turns. Is this desired? Probably yes -- the contract-gate state should also persist across turns within one user request. If not, the cache can be cleared when a new user turn begins (detectable at runner.rs:766 area where the task_id changes).

Acceptance Criteria

  • A re-delegated Composio action (e.g. GMAIL_FETCH_EMAILS via integrations_agent) actually executes and returns a result -- the contract is surfaced at most once per LazyToolkitResolver instance, not once per resolve call.
  • "What's my latest email" returns the email instead of looping into the "same tool call 3x" guard.
  • Regression test: a delegated Composio fetch across resolves from the same LazyToolkitResolver executes exactly once past the gate.
  • The auto-proceed safety net prevents indefinite gating even in edge cases (e.g. cached tool is somehow nil).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugcomposioComposio-backed provider integrations, sync, and provider adapters.priority: criticalBlocks boot or core functionality

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions