ag-ui-protocol / ag-ui-protocol/ag-ui
[Bug]: ADK middleware has no NodeTool awareness — nested Workflow silently restarts on HITL resume, output_schema leaks, NodeTool tracked as client-answerable
- Lenguaje dominante
- Python
- Estrellas
- 15.9k
- Forks
- 1.4k
- Merge medio
- 1 d 17 h
- PR fusionados (30 d)
- 163
Descripción
### Summary
`ag_ui_adk` has no awareness of ADK's **node-as-a-tool** topology (`NodeTool`, [google/adk-python#5581](https://github.com/google/adk-python/issues/5581), ADK >= 2.8), where a `Workflow` is attached to an `LlmAgent` via `tools=[...]` rather than `sub_agents=[...]`. A GitHub code search for `NodeTool` across this repo returns 0 hits.
Three separate mechanisms assume a Workflow can only be reached as the **root** or through `sub_agents` / `graph.nodes`. Each breaks when the Workflow sits one hop away behind a tool. The most serious one is a silent failure: **the user answers a HITL picker and nothing happens** — no exception, no error, no log.
This is effectively a regression of #1669 under a topology that issue did not anticipate, plus a third instance of the traversal-gap family already accepted in #1860/#1889 and #2036.
Line numbers are against current `main`, `integrations/adk-middleware/python/src/ag_ui_adk/adk_agent.py`.
### Topology
```python
# ADK >= 2.8: LlmAgent._pre_validate_tools auto-wraps any BaseNode into a NodeTool
root_agent = Agent(
name="coordinator",
model="gemini-3.5-flash",
tools=[hs_classifier_workflow], # a Workflow, as a tool — not a sub_agent
)
app = App(name="coordinator", root_agent=root_agent,
resumability_config=ResumabilityConfig(is_resumable=True))
```
`hs_classifier_workflow` emits a `RequestInput` (a HITL picker). The root is an `LlmAgent`; the Workflow is reachable only via `tools`.
---
### Bug 1 — HITL resume: `_root_agent_is_workflow()` gates on the root, so a nested Workflow is stranded
`_root_agent_is_workflow()` (line 459) is used at line 2911 as `and not self._root_agent_is_workflow()`.
Its own docstring states the mechanism precisely:
> The #1534 pre-append workaround for LlmAgent roots — which replaces `new_message` with an empty placeholder — strands Workflow roots because there's no `function_response` in the placeholder for the Workflow to resume from. See ag-ui#1669.
That reasoning applies verbatim to a Workflow **one level down**. With an `LlmAgent` root, `_root_agent_is_workflow()` returns `False`, the gate passes, the #1534 placeholder substitution runs, and the nested Workflow gets no `function_response` — so `Workflow._run_impl` finds nothing in `_extract_resume_inputs(new_message)` and restarts from `START` instead of rehydrating its `WAITING` node.
**Symptom:** the picker is answered, the response is posted, and the run silently restarts. Nothing raises.
I should flag that I filed #1669 and proposed the `not self._root_agent_is_workflow()` expression that PR #1746 implemented verbatim — so this is my own suggested fix being too narrow, not a maintainer oversight. The correct predicate is "is a Workflow **active / reachable** for this resume", not "is the root a Workflow". #1444 is an earlier instance of the same root-type-gating anti-pattern (`SequentialAgent` / `LoopAgent`), which suggests the general form is worth fixing rather than patching per-topology.
A per-run predicate keyed on the resume payload itself holds for any nesting depth, which is what we ended up using downstream.
### Bug 2 — `output_schema` JSON leaks into chat: the traversal skips `tools`
`_collect_output_schema_agent_names` (line 2339) recurses into `sub_agents` (2351) and `graph.nodes` (2355), but **never `tools`**. An `LlmAgent` with `output_schema` inside a Workflow attached as a NodeTool is therefore never collected, `EventTranslator` does not suppress it, and its structured JSON renders as a user-visible chat message — exactly what #1390 added this for.
This is the same argument accepted in **#1860 → #1889** (which added `graph.nodes`) and again in **#2036** (which found the identical gap in `_update_agent_tools_recursive` and `_shallow_copy_agent_tree`). `tools` is the next hop in that same family. Note `_shallow_copy_agent_tree` (line 2380) does a shallow `getattr(..., 'tools')` list rebuild but likewise does not recurse into tools.
### Bug 3 — NodeTool calls are tracked as client-answerable pending tool calls
`_add_pending_tool_call_with_context` registers a NodeTool invocation as a pending tool call awaiting a client answer. A NodeTool is executed **server-side by ADK**; the client never answers it. This interacts with the balance gate from #1935, which assumes function-call/response parity for client-answerable calls.
---
### Environment
- `ag-ui-adk` 0.7.0
- `google-adk` 2.8.0 (from `main` @ `b0180620f`, since the nested-HITL fix [google/adk-python#7043](https://github.com/google/adk-python/issues/7043) / `6d1451806` is not in any tagged release yet)
- Python 3.14, Vertex AI backend, CopilotKit frontend
### Why this is worth fixing generally
Node-as-a-tool is a first-class ADK feature as of 2.8, and it is the natural way to give one conversational agent several independently-invocable workflows — each call its own replay scope, with the user able to talk to the coordinator between steps. Any app using that shape hits all three bugs, and Bug 1 fails silently. #1849 (ADK 2.x support) looks like the right umbrella.
Happy to open a PR if the maintainers agree on the shape — particularly on whether Bug 1 should become a reachability check or a per-run predicate keyed on the resume payload.
Guía de contribución
Evaluación
Este issue todavía no se ha evaluado.