Skip to content

RemoteA2aAgent(mode="task"): a remote that completes with only the finish_task FunctionResponse, as the docstring says, closes the delegation with an empty output #7746

Open

Description

@msteiner-google

Describe the Bug

The RemoteA2aAgent.mode docstring tells custom A2A servers how to close a task delegation:

must be manually implemented on custom A2A servers by returning a FunctionResponse named finish_task with a response containing a result key matching "Task completed."

A server that does exactly that, and sends its answer as text in the same final message, closes the delegation, but the caller gets an empty output. The coordinator receives {} for the delegation, and the remote's answer never reaches its model.

RemoteA2aAgent._run_async_impl takes the delegation output from the finish_task call args (_find_finish_task_args_from_history). When there is only the response, it falls back to event.output = {} and logs Could not find finish_task arguments ... Completing the task with an empty output. That fallback replaced a hang: on ADK 2.8.0 the same server froze the conversation, because the node finished with output is None. Now it no longer hangs, but the documented path still loses the answer, and only a warning in the logs shows it.

Sending the call and the response together, which ADK task-mode agents do, returns the answer correctly.

Steps to Reproduce

  1. pip install "google-adk[a2a]==2.13.0" "a2a-sdk[http-server]==1.2.3"
  2. Save the script below as repro.py. It serves a plain a2a-sdk AgentExecutor in process over httpx.ASGITransport and uses a scripted model, so it needs no network or credentials.
  3. python repro.py (response only, as documented), then python repro.py --control (call and response).

Expected Behavior

Either the documented completion signal returns the remote's answer as the delegation output (here, 17 * 23 = 391, the text in the final message), or the docstring requires the finish_task call as well and the missing call is reported as an error, not as a successful empty result.

Observed Behavior

user: 'what is 17*23?'
  coordinator: call math
  math: text '17 * 23 = 391'
  math: response finish_task={'result': 'Task completed.'}
  user: response math={}
  coordinator: text 'math said: {}'

Logged: Could not find finish_task arguments for isolation scope 'adk-...'. Completing the task with an empty output.

With --control, the remote also sends the finish_task call, and the answer arrives:

user: 'what is 17*23?'
  coordinator: call math
  math: text '17 * 23 = 391'
  math: call finish_task
  math: response finish_task={'result': 'Task completed.'}
  user: response math={'result': '17 * 23 = 391'}
  coordinator: text "math said: {'result': '17 * 23 = 391'}"

Version history of the response-only case, same script:

  • ADK 2.8.0: the delegation never closes, and the coordinator's model is not called again.
  • ADK 2.9.2 to 2.13.0: the delegation closes with {}.

Environment Details

  • ADK Library Version: 2.13.0
  • a2a-sdk: 1.2.3
  • Python: 3.14.3
  • OS: Linux
  • Model: none (scripted BaseLlm). The behavior does not depend on the model.

Minimal Reproduction Code

"""RemoteA2aAgent(mode="task") + a custom A2A server that signals completion
the way the `mode` docstring says: a FunctionResponse named finish_task."""

import asyncio
import sys
import warnings

import httpx
from a2a.helpers.proto_helpers import new_task, new_text_part
from a2a.server.agent_execution import AgentExecutor
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import (
    add_a2a_routes_to_fastapi,
    create_agent_card_routes,
    create_jsonrpc_routes,
)
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, TaskState
from fastapi import FastAPI
from google.adk.a2a.converters.part_converter import convert_genai_part_to_a2a_part
from google.adk.agents import LlmAgent
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types

warnings.filterwarnings("ignore")
peer_inputs, coordinator_calls = [], []


CONTROL = "--control" in sys.argv  # also send the finish_task call


class PlainExecutor(AgentExecutor):  # answers, then signals completion as documented
    async def execute(self, context, event_queue):
        text = context.get_user_input()
        peer_inputs.append(
            "<delegation from coordinator>" if "called tool `math`" in text else text
        )
        await event_queue.enqueue_event(
            new_task(
                context.task_id, context.context_id, TaskState.TASK_STATE_SUBMITTED
            )
        )
        updater = TaskUpdater(event_queue, context.task_id, context.context_id)
        parts = [new_text_part("17 * 23 = 391")]
        if CONTROL:
            call = types.FunctionCall(
                id="ft1", name="finish_task", args={"result": "17 * 23 = 391"}
            )
            parts.append(convert_genai_part_to_a2a_part(types.Part(function_call=call)))
        done = types.FunctionResponse(
            id="ft1", name="finish_task", response={"result": "Task completed."}
        )
        parts.append(convert_genai_part_to_a2a_part(types.Part(function_response=done)))
        msg = updater.new_agent_message(parts)
        await updater.complete(message=msg)

    async def cancel(self, context, event_queue): ...


class CoordinatorModel(BaseLlm):  # delegates to `math`, then reports what came back
    async def generate_content_async(self, llm_request, stream=False):
        last = llm_request.contents[-1].parts[-1]
        coordinator_calls.append(
            last.function_response.name if last.function_response else last.text
        )
        if last.function_response:
            part = types.Part(text=f"math said: {last.function_response.response}")
        else:
            part = types.Part(
                function_call=types.FunctionCall(name="math", args={"request": "17*23"})
            )
        yield LlmResponse(content=types.Content(role="model", parts=[part]))


async def main():
    card = AgentCard(
        name="math",
        description="math",
        version="1",
        capabilities=AgentCapabilities(streaming=True),
        default_input_modes=["text/plain"],
        default_output_modes=["text/plain"],
        supported_interfaces=[
            AgentInterface(url="http://peer.test/a2a", protocol_binding="JSONRPC")
        ],
    )
    peer = FastAPI()
    add_a2a_routes_to_fastapi(
        peer,
        agent_card_routes=create_agent_card_routes(card),
        jsonrpc_routes=create_jsonrpc_routes(
            DefaultRequestHandler(PlainExecutor(), InMemoryTaskStore(), card),
            rpc_url="/a2a",
        ),
    )
    client = httpx.AsyncClient(transport=httpx.ASGITransport(app=peer))

    math = RemoteA2aAgent(
        name="math",
        description="math",
        agent_card=card,
        mode="task",
        httpx_client=client,
    )
    coordinator = LlmAgent(
        name="coordinator", model=CoordinatorModel(model="fake"), sub_agents=[math]
    )
    runner = Runner(
        app_name="app",
        agent=coordinator,
        session_service=InMemorySessionService(),
        auto_create_session=True,
    )

    for text in ["what is 17*23?", "thanks! now say hi"]:
        msg = types.Content(role="user", parts=[types.Part(text=text)])
        events = await asyncio.wait_for(
            _collect(runner.run_async(user_id="u", session_id="s", new_message=msg)),
            timeout=20,
        )
        print(f"user: {text!r}")
        for e in events:
            for p in e.content.parts if e.content and e.content.parts else []:
                if p.text:
                    print(f"  {e.author}: text {p.text!r}")
                if p.function_call:
                    print(f"  {e.author}: call {p.function_call.name}")
                if p.function_response:
                    fr = p.function_response
                    print(f"  {e.author}: response {fr.name}={fr.response}")
    print("coordinator model called with:", coordinator_calls)
    print("plain peer received:", peer_inputs)


async def _collect(agen):
    return [e async for e in agen]


asyncio.run(main())

Suggested fix

In the response-only branch of RemoteA2aAgent._run_async_impl, use the remote's final text as the output, the same text the event stream already carries, instead of {}. That matches what #7248 asks for a remote that completes with no marker at all. Failing that, update the mode docstring to require the finish_task call with the output in its result argument, and raise instead of returning an empty output.

Additional context

I'm a Googler. Once this is confirmed, I'll file the fix internally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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