From b704c5fc963d315c624b06d97a6a00d963e54cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sobczyk?= Date: Wed, 5 Aug 2026 05:50:11 -0700 Subject: [PATCH] fix(a2a): require explicit adk_type metadata to convert A2A DataParts `PartConverter` inferred the GenAI part type from the shape of an inbound `DataPart` payload: any map holding `name` + `args` became a `FunctionCall`, `name` + `response` a `FunctionResponse`, and likewise for executable code and code execution results. Conversion now requires the sender to label the part with the `adk_type` metadata key; unlabelled payloads are carried through as generic inline JSON. That stops a generic peer payload which merely happens to carry those keys from being silently reinterpreted as a control part, and matches the Python, Kotlin and Go converters, which have always keyed off the metadata alone. ADK-to-ADK traffic is unaffected, since the outbound helpers always set `adk_type`. Explicitly labelled parts are still converted - that path is required for relaying human-in-the-loop and long-running tool calls between agents - so this change alone does not prevent a peer from emitting a function call. The execution path behind it is closed by a follow-up change. PiperOrigin-RevId: 959607436 --- a2a/README.md | 58 +++++---- .../adk/a2a/converters/PartConverter.java | 45 +++++-- .../adk/a2a/converters/PartConverterTest.java | 121 +++++++++++++++--- 3 files changed, 176 insertions(+), 48 deletions(-) diff --git a/a2a/README.md b/a2a/README.md index 82f1a13ae..139fac1e3 100644 --- a/a2a/README.md +++ b/a2a/README.md @@ -18,6 +18,7 @@ projects that demonstrate how to expose that runtime over HTTP. library and exposes the JSON-RPC endpoint. ### High‑Level Picture + ```mermaid graph LR classDef client fill:#E8F0FE,stroke:#1A73E8,color:#202124; @@ -114,45 +115,52 @@ transport-agnostic `a2a/src/...` tree described above. All commands below assume you are in `google_adk`. 1. **Start the Spring webservice sample** (run in its own terminal) - ```bash - lsof -ti :8081 | xargs -r kill - ./mvnw -f contrib/samples/a2a_remote/pom.xml spring-boot:run \ + + ```bash + lsof -ti :8081 | xargs -r kill + ./mvnw -f contrib/samples/a2a_remote/pom.xml spring-boot:run \ -Dspring-boot.run.arguments=--server.port=8081 - ``` + ``` - Background option: - ```bash - nohup env GOOGLE_GENAI_USE_VERTEXAI=FALSE \ + Background option: + + ```bash + nohup env GOOGLE_GENAI_USE_VERTEXAI=FALSE \ GOOGLE_API_KEY=your_api_key \ ./mvnw -f contrib/samples/a2a_remote/pom.xml spring-boot:run \ -Dspring-boot.run.arguments=--server.port=8081 \ > /tmp/a2a_webservice.log 2>&1 & echo $! - ``` - The log can be found at /tmp/a2a_webservice.log. + ``` + + The log can be found at /tmp/a2a_webservice.log. 2. **Run the basic client sample (`a2a_basic`)** (from another terminal) - ```bash - GOOGLE_GENAI_USE_VERTEXAI=FALSE \ - GOOGLE_API_KEY=your_api_key \ - ./mvnw -f contrib/samples/a2a_basic/pom.xml exec:java \ + + ```bash + GOOGLE_GENAI_USE_VERTEXAI=FALSE \ + GOOGLE_API_KEY=your_api_key \ + ./mvnw -f contrib/samples/a2a_basic/pom.xml exec:java \ -Dexec.args="http://localhost:8081/a2a/remote" - ``` + ``` + + The client logs the outbound JSON-RPC payload and shows the remote agent’s + reply (for example, `4 is not a prime number.`). - The client logs the outbound JSON-RPC payload and shows the remote agent’s - reply (for example, `4 is not a prime number.`). + > The first run downloads dependencies from Maven Central. Configure a + > mirror in `~/.m2/settings.xml` if your environment restricts outbound + > traffic. - > The first run downloads dependencies from Maven Central. Configure a - > mirror in `~/.m2/settings.xml` if your environment restricts outbound traffic. + Background option: - Background option: - ```bash - nohup env GOOGLE_GENAI_USE_VERTEXAI=FALSE \ + ```bash + nohup env GOOGLE_GENAI_USE_VERTEXAI=FALSE \ GOOGLE_API_KEY=your_api_key \ ./mvnw -f contrib/samples/a2a_basic/pom.xml exec:java \ -Dexec.args="http://localhost:8081/a2a/remote" \ > /tmp/a2a_basic.log 2>&1 & echo $! - ``` - Tail `/tmp/a2a_basic.log` to observe subsequent turns. + ``` + + Tail `/tmp/a2a_basic.log` to observe subsequent turns. To build the runtime, Spring webservice, and both samples together, activate the opt-in Maven profile: @@ -204,7 +212,7 @@ Sample response: "args": { "nums": [6] }, "name": "checkPrime" }, - "metadata": { "type": "function_call" }, + "metadata": { "adk_type": "function_call" }, "kind": "data" }, { @@ -212,7 +220,7 @@ Sample response: "response": { "result": "No prime numbers found." }, "name": "checkPrime" }, - "metadata": { "type": "function_response" }, + "metadata": { "adk_type": "function_response" }, "kind": "data" }, { diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java b/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java index 94cf51524..a905081b0 100644 --- a/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java +++ b/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java @@ -154,8 +154,7 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d String metadataType = metadata.getOrDefault(A2AMetadataKey.TYPE.getType(), "").toString(); - if ((data.containsKey(NAME_KEY) && data.containsKey(ARGS_KEY)) - || metadataType.equals(A2ADataPartMetadataType.FUNCTION_CALL.getType())) { + if (metadataType.equals(A2ADataPartMetadataType.FUNCTION_CALL.getType())) { String functionName = String.valueOf(data.getOrDefault(NAME_KEY, "")); String functionId = String.valueOf(data.getOrDefault(ID_KEY, "")); Map args = coerceToMap(data.get(ARGS_KEY)); @@ -169,8 +168,7 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d return builder.build(); } - if ((data.containsKey(NAME_KEY) && data.containsKey(RESPONSE_KEY)) - || metadataType.equals(A2ADataPartMetadataType.FUNCTION_RESPONSE.getType())) { + if (metadataType.equals(A2ADataPartMetadataType.FUNCTION_RESPONSE.getType())) { String functionName = String.valueOf(data.getOrDefault(NAME_KEY, "")); String functionId = String.valueOf(data.getOrDefault(ID_KEY, "")); Map response = coerceToMap(data.get(RESPONSE_KEY)); @@ -188,8 +186,7 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d return builder.build(); } - if ((data.containsKey(CODE_KEY) && data.containsKey(LANGUAGE_KEY)) - || metadataType.equals(A2ADataPartMetadataType.EXECUTABLE_CODE.getType())) { + if (metadataType.equals(A2ADataPartMetadataType.EXECUTABLE_CODE.getType())) { String code = String.valueOf(data.getOrDefault(CODE_KEY, "")); String language = String.valueOf( @@ -204,8 +201,7 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d return builder.build(); } - if ((data.containsKey(OUTCOME_KEY) && data.containsKey(OUTPUT_KEY)) - || metadataType.equals(A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType())) { + if (metadataType.equals(A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType())) { String outcome = String.valueOf(data.getOrDefault(OUTCOME_KEY, Outcome.Known.OUTCOME_OK).toString()); String output = String.valueOf(data.getOrDefault(OUTPUT_KEY, "")); @@ -222,6 +218,8 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d return builder.build(); } + logIfUnlabelledControlPayload(data, metadataType); + try { String json = objectMapper.writeValueAsString(dataPart); String wrappedJson = A2A_DATA_PART_START_TAG + json + A2A_DATA_PART_END_TAG; @@ -239,6 +237,37 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d } } + /** + * Warns when a DataPart carries a payload shaped like a control part but no {@code adk_type} + * label, so it is about to be carried through as generic data. + * + *

Conversion used to be inferred from this shape. A sender still relying on that - typically a + * non-ADK peer - now silently gets an inline JSON blob instead of a function call or response, so + * name the cause rather than leaving someone to bisect the converter. + */ + private static void logIfUnlabelledControlPayload(Map data, String metadataType) { + if (!metadataType.isEmpty() || !logger.isWarnEnabled()) { + return; + } + String inferredType = null; + if (data.containsKey(NAME_KEY) && data.containsKey(ARGS_KEY)) { + inferredType = A2ADataPartMetadataType.FUNCTION_CALL.getType(); + } else if (data.containsKey(NAME_KEY) && data.containsKey(RESPONSE_KEY)) { + inferredType = A2ADataPartMetadataType.FUNCTION_RESPONSE.getType(); + } else if (data.containsKey(CODE_KEY) && data.containsKey(LANGUAGE_KEY)) { + inferredType = A2ADataPartMetadataType.EXECUTABLE_CODE.getType(); + } else if (data.containsKey(OUTCOME_KEY) && data.containsKey(OUTPUT_KEY)) { + inferredType = A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType(); + } + if (inferredType != null) { + logger.warn( + "A2A DataPart looks like a '{}' but carries no '{}' metadata; treating it as generic" + + " data. Senders must label control parts explicitly.", + inferredType, + A2AMetadataKey.TYPE.getType()); + } + } + /** * Converts an A2A Message to a Google GenAI Content object. * diff --git a/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java b/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java index 0242cdedf..03622c287 100644 --- a/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java +++ b/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java @@ -159,18 +159,28 @@ public void toGenaiPart_withDataPartFunctionCall_returnsGenaiFunctionCallPart() } @Test - public void toGenaiPart_withDataPartFunctionCallByNameAndArgs_returnsGenaiFunctionCallPart() { + public void toGenaiPart_withUnlabelledFunctionCallShapedDataPart_doesNotBuildFunctionCall() { ImmutableMap data = - ImmutableMap.of("name", "func", "id", "1", "args", ImmutableMap.of("param", "value")); + ImmutableMap.of("name", "local_tool", "id", "1", "args", ImmutableMap.of("param", "value")); DataPart dataPart = new DataPart(data, null); Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result.functionCall()).isPresent(); - FunctionCall functionCall = result.functionCall().get(); - assertThat(functionCall.name()).hasValue("func"); - assertThat(functionCall.id()).hasValue("1"); - assertThat(functionCall.args()).hasValue(ImmutableMap.of("param", "value")); + assertThat(result.functionCall()).isEmpty(); + assertThat(result.inlineData()).isPresent(); + } + + @Test + public void toGenaiPart_withUnrelatedMetadataTypeAndFunctionCallShape_doesNotBuildFunctionCall() { + ImmutableMap data = + ImmutableMap.of("name", "local_tool", "id", "1", "args", ImmutableMap.of("param", "value")); + DataPart dataPart = + new DataPart(data, ImmutableMap.of(A2AMetadataKey.TYPE.getType(), "something_else")); + + Part result = PartConverter.toGenaiPart(dataPart); + + assertThat(result.functionCall()).isEmpty(); + assertThat(result.inlineData()).isPresent(); } @Test @@ -194,19 +204,95 @@ public void toGenaiPart_withDataPartFunctionResponse_returnsGenaiFunctionRespons } @Test - public void - toGenaiPart_withDataPartFunctionResponseByNameAndResponse_returnsGenaiFunctionResponsePart() { + public void toGenaiPart_withUnlabelledFunctionResponseShapedDataPart_doesNotBuildResponse() { ImmutableMap data = ImmutableMap.of("name", "func", "id", "1", "response", ImmutableMap.of("result", "value")); DataPart dataPart = new DataPart(data, null); Part result = PartConverter.toGenaiPart(dataPart); + assertThat(result.functionResponse()).isEmpty(); + assertThat(result.inlineData()).isPresent(); + } + + // The four positive cases below deliberately use the literal wire strings rather than the enum + // constants. Inbound conversion and the outbound createDataPartFrom* helpers read the same enum, + // so an enum-based assertion moves in lockstep with the converter and could never fail. These + // literals are the contract shared with the Python, Kotlin and Go converters, which is what a + // typo would actually break. + @Test + public void toGenaiPart_withLabelledExecutableCode_returnsGenaiExecutableCodePart() { + DataPart dataPart = + new DataPart( + ImmutableMap.of("code", "print(1)", "language", "PYTHON"), + ImmutableMap.of("adk_type", "executable_code")); + + Part result = PartConverter.toGenaiPart(dataPart); + + assertThat(result.executableCode()).isPresent(); + assertThat(result.executableCode().get().code()).hasValue("print(1)"); + } + + @Test + public void toGenaiPart_withLabelledCodeExecutionResult_returnsGenaiCodeExecutionResultPart() { + DataPart dataPart = + new DataPart( + ImmutableMap.of("outcome", "OUTCOME_OK", "output", "done"), + ImmutableMap.of("adk_type", "code_execution_result")); + + Part result = PartConverter.toGenaiPart(dataPart); + + assertThat(result.codeExecutionResult()).isPresent(); + assertThat(result.codeExecutionResult().get().output()).hasValue("done"); + } + + @Test + public void toGenaiPart_withLabelledFunctionCall_returnsGenaiFunctionCallPart() { + DataPart dataPart = + new DataPart( + ImmutableMap.of("name", "func", "id", "1", "args", ImmutableMap.of("param", "value")), + ImmutableMap.of("adk_type", "function_call")); + + Part result = PartConverter.toGenaiPart(dataPart); + + assertThat(result.functionCall()).isPresent(); + assertThat(result.functionCall().get().name()).hasValue("func"); + } + + @Test + public void toGenaiPart_withLabelledFunctionResponse_returnsGenaiFunctionResponsePart() { + DataPart dataPart = + new DataPart( + ImmutableMap.of( + "name", "func", "id", "1", "response", ImmutableMap.of("result", "value")), + ImmutableMap.of("adk_type", "function_response")); + + Part result = PartConverter.toGenaiPart(dataPart); + assertThat(result.functionResponse()).isPresent(); - FunctionResponse functionResponse = result.functionResponse().get(); - assertThat(functionResponse.name()).hasValue("func"); - assertThat(functionResponse.id()).hasValue("1"); - assertThat(functionResponse.response()).hasValue(ImmutableMap.of("result", "value")); + assertThat(result.functionResponse().get().name()).hasValue("func"); + } + + @Test + public void toGenaiPart_withUnlabelledExecutableCodeShapedDataPart_doesNotBuildExecutableCode() { + ImmutableMap data = ImmutableMap.of("code", "print(1)", "language", "PYTHON"); + DataPart dataPart = new DataPart(data, null); + + Part result = PartConverter.toGenaiPart(dataPart); + + assertThat(result.executableCode()).isEmpty(); + assertThat(result.inlineData()).isPresent(); + } + + @Test + public void toGenaiPart_withUnlabelledCodeResultShapedDataPart_doesNotBuildCodeResult() { + ImmutableMap data = ImmutableMap.of("outcome", "OUTCOME_OK", "output", "done"); + DataPart dataPart = new DataPart(data, null); + + Part result = PartConverter.toGenaiPart(dataPart); + + assertThat(result.codeExecutionResult()).isEmpty(); + assertThat(result.inlineData()).isPresent(); } @Test @@ -378,7 +464,7 @@ public void fromGenaiPart_withFunctionResponsePart_returnsDataPart() { @Test public void toGenaiPart_dataPartWithEmptyStringCoercedToEmptyMap() { ImmutableMap data = ImmutableMap.of("name", "func", "id", "1", "args", ""); - DataPart dataPart = new DataPart(data, null); + DataPart dataPart = new DataPart(data, functionCallMetadata()); Part result = PartConverter.toGenaiPart(dataPart); @@ -389,7 +475,7 @@ public void toGenaiPart_dataPartWithEmptyStringCoercedToEmptyMap() { @Test public void toGenaiPart_dataPartWithNonMapCoercedToMap() { ImmutableMap data = ImmutableMap.of("name", "func", "id", "1", "args", 123); - DataPart dataPart = new DataPart(data, null); + DataPart dataPart = new DataPart(data, functionCallMetadata()); Part result = PartConverter.toGenaiPart(dataPart); @@ -463,4 +549,9 @@ public void fromGenaiPart_withDataPartInlineDataAndMetadata_returnsDataPartWithM assertThat(dataPart.getMetadata()) .containsExactly("metaKey", "metaValue", "partMetaKey", "partMetaValue"); } + + private static ImmutableMap functionCallMetadata() { + return ImmutableMap.of( + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_CALL.getType()); + } }