You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 freshintegrations_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_EMAILSnever 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.
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:
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.
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.
Unit-test the fix to cover both the normal surface-then-execute path and the repeated-delegation path.
Adding a ToolMiddleware seam at the turn-harness level (this is the natural home but is a larger change).
Implementation Steps
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).
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.
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).
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, soGMAIL_FETCH_EMAILSnever 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:
integrations_agentsub-agent on each attempt (newtask_idevery time —sub-80b72971…,sub-14722780…,sub-9dddbf51…), running onburst-v1with ~2 iterations.[composio][contract-gate] returning full contract before first execute tool=GMAIL_FETCH_EMAILSand stops there. In this one log that line fires 51 times andGMAIL_FETCH_EMAILSnever executes.iterations=2, output_chars≈109— just the contract text, no email. The main agent never receives the email → re-delegates →waitbackoff (10s/15s/20s) → the loop-detection guard fires and it gives up.Log excerpt (one of many identical cycles):
'returning full contract before first execute' count: 51across 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):
It has materialized in the sub-agent delegation path, compounded by
burst-v1not reliably making the second (executing) call within its iteration budget.Proposed fix
burst-v1to re-call.GMAIL_FETCH_EMAILS.Acceptance criteria
GMAIL_FETCH_EMAILSviaintegrations_agent) actually executes and returns a result — the contract is surfaced at most once per user turn, not once per spawn.Related
No backend session for cloud embeddings, cf. Data Sync hides pipeline failures — shows 'synced' when vectors, spaCy, and memory tree are all failing #4690), Gmail syncUNIQUE constraint failed: memory_docs.document_id(near fix(memory): use stable document_id as sync upsert key (fixes #4947 Bug 2 secret-guard sync failure) #4953),GET /orchestration/v1/steering404, Notion sync "Invalid request data provided".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 thatLazyToolkitResolver::resolve()builds a freshComposioActionTool(and therefore a fresh, emptyContractGate) on every call, so the contract is surfaced on every sub-agent spawn but the execution never proceeds.Proposed Scope
In-scope:
ComposioActionTool(and itsContractGateseen-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.Proceedunconditionally.Explicit non-goals:
composio_executedispatcher, MCP bridges, or Workflow dispatchers (tracked as follow-up in the original PR fix(composio): gate per-action tools on their full contract (#4853) #4995).ToolMiddlewareseam at the turn-harness level (this is the natural home but is a larger change).Implementation Steps
Cache resolved tool per user turn
src/openhuman/agent/harness/subagent_runner/ops/provider.rs. TheLazyToolkitResolver::resolve()method (lines 258-268) currently callsComposioActionTool::new(...)on every invocation.HashMap<String, Box<dyn Tool>>(keyed by the action slug) inside theLazyToolkitResolverstruct (line 237-240). TheLazyToolkitResolveralready lives for the duration of oneintegrations_agentspawn (seerunner.rs:766), so its lifetime is naturally scoped to the turn.ComposioActionTool, check the cache by slug. If found, return the cached tool (which carries itsContractGateseen-set forward). If not found, build, cache, and return.crate::openhuman::context::prompt::ConnectedIntegrationToolstruct if it's missing a Clone derive needed for caching (verify atsrc/openhuman/context/prompt/mod.rs).Add auto-proceed safety net
src/openhuman/composio/contract_gate.rs. TheContractGatestruct (line 31-42) currently tracks a simpleHashSet<String>of seen slugs.seen: Mutex<HashSet<String>>withseen: Mutex<HashMap<String, u32>>where the value is a consult count.mark_seen(self, slug: &str) -> bool, increment the counter. Returntrue(first time) only when the counter was 0. Add a new methodconsult(self, slug: &str) -> GateDecisionthat: if counter == 0 -> surface; if counter > 3 -> proceed unconditionally (auto-execute safety net); else -> proceed.mark_seento a more descriptivegate_consultand move the decision logic intocontract_gate.rsaway fromaction_tool.rs.consultinaction_tool.rs(which callsmark_seenand checks the result) should be refactored to call the newgate_consultmethod.Update the NOTE comment to reflect resolution
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
src/openhuman/composio/contract_gate_tests.rs-- extend with tests for: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.tests/raw_coverage/composio_ops_raw_coverage_e2e.rsorcomposio_raw_coverage_e2e.rswith a scenario that invokesComposioActionTooltwice on the same slug via the sameLazyToolkitResolverand verifies the second call does not surface the contract again.Risks and Open Questions
ComposioActionToolresolves the Composio client per-execute fromArc<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 thatcreate_composio_client(config)is called inside the execute method, not at construction time (confirmed: it is per-call ataction_tool.rsexecute path).LazyToolkitResolvercurrently lives perintegrations_agentspawn. If the main agent delegates tointegrations_agentin 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 atrunner.rs:766area where the task_id changes).Acceptance Criteria