Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 33 additions & 25 deletions a2a/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -204,15 +212,15 @@ Sample response:
"args": { "nums": [6] },
"name": "checkPrime"
},
"metadata": { "type": "function_call" },
"metadata": { "adk_type": "function_call" },
"kind": "data"
},
{
"data": {
"response": { "result": "No prime numbers found." },
"name": "checkPrime"
},
"metadata": { "type": "function_response" },
"metadata": { "adk_type": "function_response" },
"kind": "data"
},
{
Expand Down
45 changes: 37 additions & 8 deletions a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> args = coerceToMap(data.get(ARGS_KEY));
Expand All @@ -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<String, Object> response = coerceToMap(data.get(RESPONSE_KEY));
Expand All @@ -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(
Expand All @@ -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, ""));
Expand All @@ -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;
Expand All @@ -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.
*
* <p>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<String, Object> 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.
*
Expand Down
121 changes: 106 additions & 15 deletions a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,28 @@ public void toGenaiPart_withDataPartFunctionCall_returnsGenaiFunctionCallPart()
}

@Test
public void toGenaiPart_withDataPartFunctionCallByNameAndArgs_returnsGenaiFunctionCallPart() {
public void toGenaiPart_withUnlabelledFunctionCallShapedDataPart_doesNotBuildFunctionCall() {
ImmutableMap<String, Object> 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<String, Object> 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
Expand All @@ -194,19 +204,95 @@ public void toGenaiPart_withDataPartFunctionResponse_returnsGenaiFunctionRespons
}

@Test
public void
toGenaiPart_withDataPartFunctionResponseByNameAndResponse_returnsGenaiFunctionResponsePart() {
public void toGenaiPart_withUnlabelledFunctionResponseShapedDataPart_doesNotBuildResponse() {
ImmutableMap<String, Object> 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<String, Object> 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<String, Object> 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
Expand Down Expand Up @@ -378,7 +464,7 @@ public void fromGenaiPart_withFunctionResponsePart_returnsDataPart() {
@Test
public void toGenaiPart_dataPartWithEmptyStringCoercedToEmptyMap() {
ImmutableMap<String, Object> 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);

Expand All @@ -389,7 +475,7 @@ public void toGenaiPart_dataPartWithEmptyStringCoercedToEmptyMap() {
@Test
public void toGenaiPart_dataPartWithNonMapCoercedToMap() {
ImmutableMap<String, Object> 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);

Expand Down Expand Up @@ -463,4 +549,9 @@ public void fromGenaiPart_withDataPartInlineDataAndMetadata_returnsDataPartWithM
assertThat(dataPart.getMetadata())
.containsExactly("metaKey", "metaValue", "partMetaKey", "partMetaValue");
}

private static ImmutableMap<String, Object> functionCallMetadata() {
return ImmutableMap.of(
A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_CALL.getType());
}
}
Loading