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
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.
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
src/RockBot.Tools/ToolProfile.cs — remove a2a from ToolProfiles.Subagent; update comment.
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).
src/RockBot.A2A/A2ATaskResultHandler.cs — complete pending.Completion and short-circuit the WM-stash/synthesis path when set.
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.
Tests.
Tests
InvokeAgentExecutor synchronous path: dispatch → result completes TCS → tool returns the result text/data.
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).
Background
A2A invocation is currently denied to subagents (
ToolProfiles.Subagentdenies sourcea2a, added in #425). The original rationale — "results fold into the primary session, not the subagent's; silently useless here" — is now stale: with thea2aAwaiterdrain, the whiteboard-surfacing inSubagentResultHandler, 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 afterinvoke_agentreturns "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_agentis 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 — anda2aAwaiteralready 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 sameinvoke_agenttool serves both modes.Rendezvous
InvokeAgentExecutoralready tracks each dispatch as aPendingA2ATask(with aCts) inA2ATaskTracker, andA2ATaskResultHandleralready receives the terminal result for thatcorrelationId/taskId. Add aTaskCompletionSourcerendezvous so the synchronous caller gets the payload:PendingA2ATaskgainsTaskCompletionSource<AgentTaskResult>? Completion(set only in synchronous mode).InvokeAgentExecutor: whenresolver.IsSubagentSession(request.SessionId), create the TCS, dispatch as today, thenawaitthe TCS bounded by the subagent'sCancellationToken(and a configurable max wait, capped by the existing per-invocation A2A timeout). Return the result text (+ data key) as theToolInvokeResponsecontent instead of the "dispatched, STOP" message.A2ATaskResultHandler.HandleTerminalResultAsync: ifpending.Completionis 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"fromToolProfiles.Subagent's denied sources and update the now-stale comment.ToolProfileSnapshotTestswill fail until the snapshot is updated — the intended conscious-decision gate.Implementation order
src/RockBot.Tools/ToolProfile.cs— removea2afromToolProfiles.Subagent; update comment.src/RockBot.A2A/PendingA2ATask.cs— addTaskCompletionSource<AgentTaskResult>? Completion.src/RockBot.A2A/InvokeAgentExecutor.cs— synchronous branch keyed onISubagentSessionResolver.IsSubagentSession; await the TCS with the subagent CT + max-wait; return the result as tool content. InjectISubagentSessionResolver(already registered).src/RockBot.A2A/A2ATaskResultHandler.cs— completepending.Completionand short-circuit the WM-stash/synthesis path when set.invoke_agentdescription — 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.Tests
InvokeAgentExecutorsynchronous path: dispatch → result completes TCS → tool returns the result text/data.A2ATaskResultHandler: withCompletionset, 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).invoke_agentunchanged (regression).Risks / considerations
MaxConcurrentSubagents. v1: rely on the timeout + the existing one-pending-per-session guard (InvokeAgentExecutorrejects 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.InputRequiredmid-call: v1 can treat as unsupported in the synchronous subagent path (return a degraded response) or reuse the trust-gatedInputRequiredHandler. Decide during implementation; lean unsupported for v1.Out of scope
Related
ISubagentSessionResolver(mode selection) and remains the timeout/orphan safety net.a2adeny that this issue lifts for subagents.