Skip to content

Subagent A2A: synchronous consume-and-refine (blocking invoke_agent for subagent sessions) #478

Description

@rockfordlhotka

Background

A2A invocation is currently denied to subagents (ToolProfiles.Subagent denies source a2a, added in #425). The original rationale — "results fold into the primary session, not the subagent's; silently useless here" — is now stale: with the a2aAwaiter drain, the whiteboard-surfacing in SubagentResultHandler, and #424's fold-back safety net, a subagent's A2A result is no longer lost. It reaches the primary's consolidation synthesis (or, if orphaned, the primary as a late notification via #424).

But that only gives dispatcher semantics: a subagent can fire off an A2A call, and the primary eventually sees the answer — the subagent itself never does. Its loop is a single agentLoopRunner.RunAsync(...) that ends after invoke_agent returns "dispatched, STOP, wait," and the reply arrives async long after the loop returned. So today a subagent cannot consult a peer and refine its own reasoning with the answer — e.g. "ask AdvisorCouncil, then incorporate their recommendation into my analysis."

This issue makes that work.

Design

When invoke_agent is called from a subagent session, make it block until the peer's terminal result arrives and return the result text/data as the tool response, so the subagent's LLM loop continues naturally with the answer in hand. Subagents are background work bounded by their own timeout, so blocking a subagent loop iteration on a remote call (with a deadline) is acceptable — and a2aAwaiter already demonstrates the infra can await A2A by session.

For the primary session, behavior is unchanged (async dispatch → fold-back via the result handler). The branch is selected by ISubagentSessionResolver.IsSubagentSession(sessionId) (the seam added in #424), so the same invoke_agent tool serves both modes.

Rendezvous

InvokeAgentExecutor already tracks each dispatch as a PendingA2ATask (with a Cts) in A2ATaskTracker, and A2ATaskResultHandler already receives the terminal result for that correlationId/taskId. Add a TaskCompletionSource rendezvous so the synchronous caller gets the payload:

  • PendingA2ATask gains TaskCompletionSource<AgentTaskResult>? Completion (set only in synchronous mode).
  • InvokeAgentExecutor: when resolver.IsSubagentSession(request.SessionId), create the TCS, dispatch as today, then await the TCS bounded by the subagent's CancellationToken (and a configurable max wait, capped by the existing per-invocation A2A timeout). Return the result text (+ data key) as the ToolInvokeResponse content instead of the "dispatched, STOP" message.
  • A2ATaskResultHandler.HandleTerminalResultAsync: if pending.Completion is set, complete it with the result and skip the WM-stash + synthesis/fold-back path (the subagent's tool call owns the result). The non-synchronous (primary / fire-and-forget) path is unchanged.

Timeout / failure → #424 is the fallback

On timeout or cancellation, the tool returns a degraded response ("peer '{name}' did not respond within {N}s; proceeding without it") so the subagent can continue or wrap up. The pending stays tracked, so if the reply arrives later, #424's fold-back surfaces it to the primary — belt-and-suspenders, no silent loss.

Tool-profile change (prerequisite)

Drop "a2a" from ToolProfiles.Subagent's denied sources and update the now-stale comment. ToolProfileSnapshotTests will fail until the snapshot is updated — the intended conscious-decision gate.

Implementation order

  1. src/RockBot.Tools/ToolProfile.cs — remove a2a from ToolProfiles.Subagent; update comment.
  2. src/RockBot.A2A/PendingA2ATask.cs — add TaskCompletionSource<AgentTaskResult>? Completion.
  3. src/RockBot.A2A/InvokeAgentExecutor.cs — synchronous branch keyed on ISubagentSessionResolver.IsSubagentSession; await the TCS with the subagent CT + max-wait; return the result as tool content. Inject ISubagentSessionResolver (already registered).
  4. src/RockBot.A2A/A2ATaskResultHandler.cs — complete pending.Completion and short-circuit the WM-stash/synthesis path when set.
  5. Subagent directive / invoke_agent description — clarify that for subagents the call returns the peer's answer synchronously (so the LLM knows it can use the result), distinct from the primary's async semantics.
  6. Tests.

Tests

  • InvokeAgentExecutor synchronous path: dispatch → result completes TCS → tool returns the result text/data.
  • Timeout path: TCS not completed within budget → degraded tool response; pending remains for A2A late-reply fold-back to primary session #424 fold-back.
  • A2ATaskResultHandler: with Completion set, completes the TCS and does not stash/fold; without it, behaves as today (incl. A2A late-reply fold-back to primary session #424 fold-back).
  • Primary-session invoke_agent unchanged (regression).
  • MSTest + Rocks per repo convention; no RabbitMQ-gated changes (in-process rendezvous).

Risks / considerations

  • Subagent slot occupancy. A synchronous subagent holds its concurrency slot while awaiting the peer. Bounded by timeout; consider whether a slow peer fan-out can starve MaxConcurrentSubagents. v1: rely on the timeout + the existing one-pending-per-session guard (InvokeAgentExecutor rejects a second concurrent dispatch per session), so a subagent awaits one peer at a time. Parallel peer fan-out stays the wisp idiom / future work.
  • InputRequired multi-turn. A peer that replies InputRequired mid-call: v1 can treat as unsupported in the synchronous subagent path (return a degraded response) or reuse the trust-gated InputRequiredHandler. Decide during implementation; lean unsupported for v1.
  • Transport parity. Both HTTP and queue transports must funnel the terminal result to the TCS (the result handler is the single completion point for queue; HTTP dispatch already routes terminal state through the same handler/topic).
  • Recursion / fan-out. A subagent invoking a peer that itself spawns work is a new vector; bounded in-process by the subagent concurrency cap, but worth noting.

Out of scope

  • Suspend/resume of the subagent loop (the alternative architecture; unnecessary given synchronous-await).
  • Parallel A2A fan-out from a single subagent (one-at-a-time via the existing per-session guard; use wisps for parallelism).
  • Changing the primary's async/fold-back semantics.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions