google / google/adk-python

Feature request: let a background AgentTool/dispatched agent's own intermediate events be observed live (not just wait=false + poll for the final result)

Aperta
#7,094 1 commento 0 reazioni 1 assegnatario Rivendicata da @sanketpatil06 Vedi su GitHub
needs review tools
Lingua principale
Python
Stelle
21.5k
Fork
4k
Merge medio
1g 14h
PR unite (30g)
37

Descrizione

** Please make sure you read the contribution guide and file the issues in the right place. **
[Contribution guide.](https://google.github.io/adk-docs/contributing-guide/)

## 🔴 Required Information

### Is your feature request related to a specific problem?

Related to #4759 and the in-progress PR #6592 (`AgentDispatcherToolset`) — this is a narrower, additive request on top of those, not a replacement.

We want a chat orchestrator to invoke another agent **as a tool**, without blocking the orchestrator's own turn (same goal as #4759/#6592), **and** we want an external caller (e.g. a chat UI) to observe the background agent's own intermediate activity — its text, its own `function_call`/`function_response` events — **live, as they happen**, while it runs in its own isolated `Runner`/session.

We looked for a native way to do this in 2.7.1 and confirmed none of the existing primitives fit:

- `AgentTool` (`tools/agent_tool.py`): builds an isolated child `Runner`, but `await`s it fully inside `run_async` before returning the tool's `function_response`. Blocking, and the child's intermediate events never leave the wrapper — only the final merged text does.
- `mode='task'` sub-agents (`_TaskAgentTool`): not backgrounding — it's a human-in-the-loop hand-off. If the task agent doesn't finish in one internal pass, the **whole invocation** ends (`NodeInterruptedError` propagates to `Runner._drive_root_node`), and the user's *next* plain-text message gets silently rerouted to the paused task agent (`Runner._find_active_task_scope`, `_append_user_event`) instead of the orchestrator. The orchestrator doesn't keep working — it becomes a silent proxy until the task agent calls `finish_task`.
- `LongRunningFunctionTool` (`is_long_running`): a real "return now, deliver the `FunctionResponse` later via a fresh `run_async()` call" mechanism, and that later call *can* legitimately be triggered by a server-side callback rather than the end user (matching is purely by `function_call.id`). But marking the *dispatch* tool itself long-running makes `Event.is_final_response()` true immediately, so the orchestrator's turn ends right at the dispatch call — it can't even emit an acknowledgement in the same turn, let alone keep working.
- `response_scheduling` (`FunctionResponseScheduling`): the one truly automatic, no-new-client-message push mechanism we found — but it's implemented only in `handle_function_calls_live` (`flows/llm_flows/functions.py`), gated behind the Live/bidi (`run_live`/`LiveRequestQueue`) runtime. Not available to a standard request/response `Runner.run_async()` service, which is what most chat backends (including ours) use.

We looked at PR #6592 closely — it already covers "dispatch, don't block, poll/await for the final result, follow-up questions on the same child session, `on_complete` callback." That's great and we'd use it. But per its own "Remaining bounds" section, its completion signaling is deliberately **poll/callback only** — there's no way for something outside the tool call itself (e.g. our WS layer) to observe the child's *intermediate* text/tool_call/tool_response events as they're produced, only the final `get_agent_result`/`await_agent` payload.

### Describe the Solution You'd Like

On top of #6592's dispatcher (or as a variant of `AgentTool`), expose the background child's own ADK event stream to something outside the tool call — e.g. an optional `on_event` callback (sync, called once per child event, analogous to `on_complete` but per-event) alongside `on_complete`, so a host application can render live progress (a "sub-agent card" with its own nested activity) for a dispatched/background agent-as-tool the same way it already can for a foreground one.

Two things we'd want kept separate, since they answer different consumers:
1. The **orchestrator's own conversational context** should still only ever see one final message per dispatch (as it does today via `AgentTool`'s `function_response`, or via #6592's `get_agent_result`) — never the play-by-play.
2. An **external observer** (UI, logging, tracing) should be able to see the child's own intermediate events live, independent of whether/when the orchestrator's context is updated.

### Impact on your work

We're building this by hand today (isolated `Runner` + `InMemorySessionService`, a `ContextVar`-based side channel into our own event-processing loop, and a manual `session_service.append_event` for the single final message) specifically to drive a live "sub-agent activity" UI. It works, but every consumer of `AgentTool`/#6592 who wants observability into a background child's own steps will end up reinventing the same side channel unless it's part of the toolset/`AgentTool` contract itself.

### Willingness to contribute

No — happy to share our working approach as a reference if useful, but not proposing to drive the PR.

## 🟡 Recommended Information

### Describe Alternatives You've Considered

- Subclassing `AgentTool.run_async` to drive the child `Runner` by hand and forward events ourselves (what we did) — works, but touches several private APIs (`_get_input_schema`, `tool_context._invocation_context`, `PluginManager` internals) that aren't a documented extension point.
- `mode='single_turn'`: `event.branch` (`"@"`) gives free, correct correlation for concurrent invocations and the events *do* reach the parent's own stream — but it's fully inline/blocking (`tool_context.run_node` is `await`ed), so it doesn't help with not blocking the orchestrator's turn.

### Proposed API / Implementation

Sketch, additive to #6592's `AgentDispatcherToolset`:

```python
async def dispatch_agent(
self,
name: str,
instruction: str,
user_message: str,
wait: bool = False,
on_event: Callable[[str, Event], None] | None = None, # (dispatch_id, event)
on_complete: Callable[[str, str], None] | None = None, # (dispatch_id, result)
...
) -> dict:
...
```

`on_event` fires once per event the child's own isolated `Runner.run_async()` yields, same shape/timing as what a foreground `AgentTool`'s caller already sees inside `agent_tool.py`'s drain loop — just not thrown away.

Guida per i contributori

Apri la guida per i contributori

Valutazione

Questa issue non è ancora stata valutata.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.