diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 7588679c0..f2c43d597 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.7.0"
+ ".": "1.7.1"
}
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 5d33d2172..000000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# AGENTS.md
-
-Validate changes by running `./mvnw test`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f717049c..a7cace1a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [1.7.1](https://github.com/google/adk-java/compare/v1.7.0...v1.7.1) (2026-07-28)
+
+
+### Bug Fixes
+
+* **codeexecutors:** add opt-in strict sandbox to ContainerCodeExecutor ([8049f7e](https://github.com/google/adk-java/commit/8049f7e5362ca654bf3706ea465f8d1021ee0346))
+* **core:** fallback to name when Agent description is missing ([233b83b](https://github.com/google/adk-java/commit/233b83bcacc39f7b6a1204a7644a1a5f13557a20))
+* **events:** accumulate endOfAgent in EventActions.merge to preserve parallel stop requests ([03b04fa](https://github.com/google/adk-java/commit/03b04fa2b17b8b9fc508add4d1013e83a4975cfe))
+* **mcp:** honor stdioServerParams in McpToolset.fromConfig ([cf71d7b](https://github.com/google/adk-java/commit/cf71d7bb07398d6fabd3a3ade24f31db98f9f36e))
+* preserve all parallel function calls on the live (BIDI) connection ([edc330d](https://github.com/google/adk-java/commit/edc330d760d8194058610907e717f13425717d8b))
+* **sessions:** apply afterTimestamp and numRecentEvents together in VertexAiSessionService ([24a4588](https://github.com/google/adk-java/commit/24a4588004228d6117d9ab4a45ce93be4c952d3c))
+* **sessions:** apply numRecentEvents and afterTimestamp together in InMemorySessionService ([4d19f7d](https://github.com/google/adk-java/commit/4d19f7d92becff955de12e2a58bc6bb23f14492d))
+
## [1.7.0](https://github.com/google/adk-java/compare/v1.6.0...v1.7.0) (2026-07-17)
diff --git a/README.md b/README.md
index 2a0a88415..4e96ca89e 100644
--- a/README.md
+++ b/README.md
@@ -50,13 +50,13 @@ If you're using Maven, add the following to your dependencies:
com.google.adkgoogle-adk
- 1.7.0
+ 1.7.1com.google.adkgoogle-adk-dev
- 1.7.0
+ 1.7.1
```
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/pom.xml b/a2a/pom.xml
index 494c17c15..e14e2f7dc 100644
--- a/a2a/pom.xml
+++ b/a2a/pom.xml
@@ -5,7 +5,7 @@
com.google.adkgoogle-adk-parent
- 1.7.1-SNAPSHOT
+ 1.7.2-SNAPSHOTgoogle-adk-a2a
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/main/java/com/google/adk/a2a/converters/ResponseConverter.java b/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
index b5733e9a9..412763fa2 100644
--- a/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
+++ b/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java
@@ -20,7 +20,7 @@
import static com.google.common.collect.Streams.zip;
import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.agents.InvocationContext;
import com.google.adk.events.Event;
@@ -61,6 +61,8 @@
public final class ResponseConverter {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Logger logger = LoggerFactory.getLogger(ResponseConverter.class);
+ private static final JavaType CUSTOM_METADATA_LIST_TYPE =
+ objectMapper.getTypeFactory().constructCollectionType(List.class, CustomMetadata.class);
private static final ImmutableSet PENDING_STATES =
ImmutableSet.of(TaskState.WORKING, TaskState.SUBMITTED);
@@ -71,6 +73,8 @@ private ResponseConverter() {}
* empty optional if the event should be ignored (e.g. if the event is not a final update for
* TaskArtifactUpdateEvent or if the message is empty for TaskStatusUpdateEvent).
*
+ *
Unparseable ADK metadata is logged and dropped; the rest of the event is still converted.
+ *
* @throws IllegalArgumentException if the event type is not supported.
*/
public static Optional clientEventToEvent(
@@ -93,6 +97,11 @@ private static boolean isPartial(@Nullable Map metadata) {
return Objects.equals(metadata.getOrDefault(A2AMetadataKey.PARTIAL.getType(), false), true);
}
+ private static boolean isLongRunning(@Nullable Map metadata) {
+ return metadata != null
+ && Objects.equals(metadata.get(A2AMetadataKey.IS_LONG_RUNNING.getType()), true);
+ }
+
/**
* Converts a A2A {@link TaskUpdateEvent} to an ADK {@link Event}, if applicable. Returns null if
* the event is not a final update for TaskArtifactUpdateEvent or if the message is empty for
@@ -184,7 +193,11 @@ public static Event messageToFailedEvent(Message message, InvocationContext invo
return builder.build();
}
- /** Converts an A2A message back to ADK events. */
+ /**
+ * Converts an A2A message back to ADK events.
+ *
+ *
Unparseable ADK metadata is logged and dropped; the rest of the event is still converted.
+ */
public static Event messageToEvent(Message message, InvocationContext invocationContext) {
return updateEventMetadata(
remoteAgentEventBuilder(invocationContext)
@@ -214,6 +227,8 @@ public static Event messageToEvent(
* Converts an A2A {@link Task} to an ADK {@link Event}. If the artifacts are present, the last
* artifact is used. If not, the status message is used. If not, the last history message is used.
* If none of these are present, an empty event is returned.
+ *
+ *
Unparseable ADK metadata is logged and dropped; the rest of the event is still converted.
*/
public static Event taskToEvent(Task task, InvocationContext invocationContext) {
ImmutableList.Builder genaiParts = ImmutableList.builder();
@@ -268,9 +283,8 @@ private static ImmutableSet getLongRunningToolIds(
if (!(part instanceof DataPart dataPart)) {
return Optional.empty();
}
- Object isLongRunning =
- dataPart.getMetadata().get(A2AMetadataKey.IS_LONG_RUNNING.getType());
- if (!Objects.equals(isLongRunning, true)) {
+ // A2A peers may omit metadata entirely, which deserializes to null.
+ if (!isLongRunning(dataPart.getMetadata())) {
return Optional.empty();
}
if (convertedPart.functionCall().isEmpty()) {
@@ -296,13 +310,13 @@ private static Event updateEventMetadata(
clientMetadata = ImmutableMap.of();
}
Event.Builder eventBuilder = event.toBuilder();
- Object groundingMetadata = clientMetadata.get(A2AMetadataKey.GROUNDING_METADATA.getType());
- // if groundingMetadata is null, parseMetadata will return null as well.
- eventBuilder.groundingMetadata(parseMetadata(groundingMetadata, GroundingMetadata.class));
- Object usageMetadata = clientMetadata.get(A2AMetadataKey.USAGE_METADATA.getType());
- // if usageMetadata is null, parseMetadata will return null as well.
+ eventBuilder.groundingMetadata(
+ parseMetadata(clientMetadata, A2AMetadataKey.GROUNDING_METADATA, GroundingMetadata.class));
eventBuilder.usageMetadata(
- parseMetadata(usageMetadata, GenerateContentResponseUsageMetadata.class));
+ parseMetadata(
+ clientMetadata,
+ A2AMetadataKey.USAGE_METADATA,
+ GenerateContentResponseUsageMetadata.class));
ImmutableList.Builder customMetadataList = ImmutableList.builder();
customMetadataList
@@ -316,32 +330,35 @@ private static Event updateEventMetadata(
.key(AdkMetadataKey.CONTEXT_ID.getType())
.stringValue(contextId)
.build());
- Object customMetadata = clientMetadata.get(A2AMetadataKey.CUSTOM_METADATA.getType());
- if (customMetadata != null) {
- customMetadataList.addAll(
- parseMetadata(customMetadata, new TypeReference>() {}));
+ List parsedCustomMetadata =
+ parseMetadata(clientMetadata, A2AMetadataKey.CUSTOM_METADATA, CUSTOM_METADATA_LIST_TYPE);
+ if (parsedCustomMetadata != null) {
+ customMetadataList.addAll(parsedCustomMetadata);
}
eventBuilder.customMetadata(customMetadataList.build());
- Object errorCode = clientMetadata.get(A2AMetadataKey.ERROR_CODE.getType());
- eventBuilder.errorCode(parseMetadata(errorCode, FinishReason.class));
+ eventBuilder.errorCode(
+ parseMetadata(clientMetadata, A2AMetadataKey.ERROR_CODE, FinishReason.class));
return eventBuilder.build();
}
- private static @Nullable T parseMetadata(@Nullable Object metadata, Class type) {
- try {
- if (metadata instanceof String jsonString) {
- return objectMapper.readValue(jsonString, type);
- } else {
- return objectMapper.convertValue(metadata, type);
- }
- } catch (IllegalArgumentException | JsonProcessingException e) {
- throw new IllegalArgumentException("Failed to parse metadata of type " + type, e);
- }
+ /**
+ * Reads {@code key} out of the peer-supplied {@code clientMetadata} and deserializes it.
+ *
+ *
Returns null when the key is absent, and also when its value cannot be parsed: metadata is
+ * peer-controlled, so a malformed value is logged and dropped rather than failing the whole
+ * conversion.
+ */
+ private static @Nullable T parseMetadata(
+ Map clientMetadata, A2AMetadataKey key, Class type) {
+ return parseMetadata(clientMetadata, key, objectMapper.getTypeFactory().constructType(type));
}
- private static @Nullable T parseMetadata(@Nullable Object metadata, TypeReference type) {
+ /** Overload of {@link #parseMetadata(Map, A2AMetadataKey, Class)} for generic target types. */
+ private static @Nullable T parseMetadata(
+ Map clientMetadata, A2AMetadataKey key, JavaType type) {
+ Object metadata = clientMetadata.get(key.getType());
try {
if (metadata instanceof String jsonString) {
return objectMapper.readValue(jsonString, type);
@@ -349,10 +366,27 @@ private static Event updateEventMetadata(
return objectMapper.convertValue(metadata, type);
}
} catch (IllegalArgumentException | JsonProcessingException e) {
- throw new IllegalArgumentException("Failed to parse metadata of type " + type.getType(), e);
+ logDroppedMetadata(key, e);
+ return null;
}
}
+ /**
+ * Reports a dropped metadata value.
+ *
+ *
The parser's message quotes the peer's bytes, so the warning carries only the key and the
+ * exception type. A peer that streams malformed metadata would otherwise be able to write
+ * arbitrary content and a stack trace into the log on every event. The full exception is
+ * available at debug level.
+ */
+ private static void logDroppedMetadata(A2AMetadataKey key, Exception e) {
+ logger.warn(
+ "Dropping unparseable A2A metadata for key {} ({})",
+ key.getType(),
+ e.getClass().getSimpleName());
+ logger.debug("Unparseable A2A metadata for key {}", key.getType(), e);
+ }
+
private static Event emptyEvent(InvocationContext invocationContext) {
Event.Builder builder =
Event.builder()
diff --git a/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java b/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java
index 57a0d9db2..618888c4c 100644
--- a/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java
+++ b/a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java
@@ -29,6 +29,7 @@
import com.google.adk.runner.Runner;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.Session;
+import com.google.common.base.Ascii;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.genai.types.Content;
@@ -66,6 +67,18 @@ public class AgentExecutor implements io.a2a.server.agentexecution.AgentExecutor
private static final Logger logger = LoggerFactory.getLogger(AgentExecutor.class);
private static final String USER_ID_PREFIX = "A2A_USER_";
private static final String A2A_METADATA_KEY = "a2a_metadata";
+
+ /**
+ * Env var that puts exception text back into the failure message sent to the peer.
+ *
+ *
Off by default, and meant for local debugging only: enabling it on a network-reachable
+ * deployment restores the disclosure {@link #failedMessage} exists to prevent.
+ */
+ private static final String DEBUG_ERRORS_ENV_VAR = "ADK_DEBUG_ERRORS";
+
+ /** Length of the correlation id, matching {@code new_error_id()} in adk-python. */
+ private static final int ERROR_ID_LENGTH = 12;
+
private final Map activeTasks = new ConcurrentHashMap<>();
private final Runner.Builder runnerBuilder;
private final AgentExecutorConfig agentExecutorConfig;
@@ -232,13 +245,7 @@ public void execute(RequestContext ctx, EventQueue eventQueue) {
.ignoreElements()
.materialize()
.flatMapCompletable(
- notification -> {
- Throwable error = notification.getError();
- if (error != null) {
- logger.error("Runner failed to execute", error);
- }
- return handleExecutionEnd(ctx, error, eventQueue);
- })
+ notification -> handleExecutionEnd(ctx, notification.getError(), eventQueue))
.doFinally(() -> cleanupTask(ctx.getTaskId()))
.subscribe(
() -> {},
@@ -250,7 +257,16 @@ public void execute(RequestContext ctx, EventQueue eventQueue) {
private Completable handleExecutionEnd(
RequestContext ctx, Throwable error, EventQueue eventQueue) {
TaskState state = error != null ? TaskState.FAILED : TaskState.COMPLETED;
- Message message = error != null ? failedMessage(ctx, error) : null;
+ Message message = null;
+ if (error != null) {
+ // The peer is not trusted with the throwable: exception text routinely names absolute
+ // filesystem paths, class and module locations, configuration values and echoed request
+ // payloads, none of which the caller needs and all of which are useful reconnaissance. It is
+ // logged here in full under a short opaque id; the peer gets only that id.
+ String errorId = newErrorId();
+ logger.error("Runner failed to execute [error_id={}]", errorId, error);
+ message = failedMessage(ctx, error, errorId);
+ }
TaskStatusUpdateEvent initialEvent =
new TaskStatusUpdateEvent.Builder()
.taskId(ctx.getTaskId())
@@ -304,16 +320,66 @@ private Maybe prepareSession(
}));
}
- private static Message failedMessage(RequestContext context, Throwable e) {
+ /**
+ * Builds the failure message handed back to the remote peer.
+ *
+ *
It carries {@code errorId} rather than {@code e.getMessage()}, so an operator handed the id
+ * can find the real stack trace in the log while the peer learns nothing about the host. Set
+ * {@code ADK_DEBUG_ERRORS=1} to put the exception text back into the response while debugging
+ * locally.
+ */
+ private static Message failedMessage(RequestContext context, Throwable e, String errorId) {
return new Message.Builder()
.messageId(UUID.randomUUID().toString())
.contextId(context.getContextId())
.taskId(context.getTaskId())
.role(Message.Role.AGENT)
- .parts(ImmutableList.of(new TextPart(e.getMessage())))
+ .parts(ImmutableList.of(new TextPart(failureText(e, errorId, debugErrorsEnabled()))))
.build();
}
+ /**
+ * Returns a short opaque id tying the peer's failure message to the logged throwable.
+ *
+ *
{@value #ERROR_ID_LENGTH} hex characters, the same shape as {@code new_error_id()} in
+ * adk-python, so an operator sees the same kind of id whichever runtime produced it.
+ */
+ private static String newErrorId() {
+ return UUID.randomUUID().toString().replace("-", "").substring(0, ERROR_ID_LENGTH);
+ }
+
+ /**
+ * Returns the failure text that is safe to hand to the remote peer.
+ *
+ * @param includeDetail whether to append the exception type and message; see {@link
+ * #DEBUG_ERRORS_ENV_VAR}.
+ */
+ static String failureText(Throwable e, String errorId, boolean includeDetail) {
+ String text = "Agent execution failed. (error_id: " + errorId + ")";
+ if (includeDetail) {
+ text = text + ": " + e.getClass().getName() + ": " + e.getMessage();
+ }
+ return text;
+ }
+
+ private static boolean debugErrorsEnabled() {
+ return debugErrorsEnabled(System.getenv(DEBUG_ERRORS_ENV_VAR));
+ }
+
+ /**
+ * Returns whether {@code value}, as read from {@link #DEBUG_ERRORS_ENV_VAR}, turns the detail
+ * back on. Unset or unrecognized means off, matching {@code is_env_enabled} in adk-python.
+ *
+ *
Split from the env lookup so both outcomes are testable: {@code System.getenv} cannot be set
+ * from a test in-process.
+ */
+ static boolean debugErrorsEnabled(String value) {
+ if (value == null) {
+ return false;
+ }
+ return value.equals("1") || Ascii.equalsIgnoreCase(value, "true");
+ }
+
// Processor that will process all events related to the one runner invocation.
private static class EventProcessor {
private final String runArtifactId;
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());
+ }
}
diff --git a/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java b/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java
index 20f2d3c10..9b854b616 100644
--- a/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java
+++ b/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java
@@ -17,8 +17,8 @@
package com.google.adk.a2a.converters;
import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining;
-import static org.junit.Assert.assertThrows;
import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.InvocationContext;
@@ -47,6 +47,7 @@
import io.a2a.spec.TaskStatusUpdateEvent;
import io.a2a.spec.TextPart;
import io.reactivex.rxjava3.core.Flowable;
+import java.util.List;
import java.util.Optional;
import org.junit.Before;
import org.junit.Test;
@@ -212,6 +213,92 @@ public void taskToEvent_withCustomMetadata_returnsEvent() {
.inOrder();
}
+ @Test
+ public void taskToEvent_withMalformedMetadata_dropsFieldsAndConverts() {
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(new TextPart("Status message")))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null);
+ Task task =
+ testTask()
+ .status(status)
+ .artifacts(null)
+ .metadata(
+ ImmutableMap.of(
+ A2AMetadataKey.GROUNDING_METADATA.getType(), "not-valid-json",
+ A2AMetadataKey.USAGE_METADATA.getType(), "not-valid-json",
+ A2AMetadataKey.CUSTOM_METADATA.getType(), "not-valid-json",
+ A2AMetadataKey.ERROR_CODE.getType(), "not-valid-json"))
+ .build();
+
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+
+ assertThat(event.content().get().parts().get().get(0).text()).hasValue("Status message");
+ assertThat(event.groundingMetadata()).isEmpty();
+ assertThat(event.usageMetadata()).isEmpty();
+ assertThat(event.errorCode()).isEmpty();
+ assertThat(event.customMetadata().get())
+ .containsExactly(
+ CustomMetadata.builder().key("a2a:task_id").stringValue("task-1").build(),
+ CustomMetadata.builder().key("a2a:context_id").stringValue("context-1").build());
+ }
+
+ @Test
+ public void taskToEvent_withUnrecognizedMetadataField_dropsField() {
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(new TextPart("Status message")))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null);
+ Task task =
+ testTask()
+ .status(status)
+ .artifacts(null)
+ .metadata(
+ ImmutableMap.of(
+ // A nested object takes the convertValue branch rather than readValue. The
+ // genai builders reject unknown fields, so snake_case fails to convert.
+ A2AMetadataKey.GROUNDING_METADATA.getType(),
+ ImmutableMap.of("web_search_queries", ImmutableList.of("test-query"))))
+ .build();
+
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+
+ assertThat(event.groundingMetadata()).isEmpty();
+ assertThat(event.content().get().parts().get().get(0).text()).hasValue("Status message");
+ }
+
+ @Test
+ public void taskToEvent_withOneMalformedMetadataField_keepsTheValidFields() {
+ GroundingMetadata groundingMetadata =
+ GroundingMetadata.builder().webSearchQueries("test-query").build();
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(new TextPart("Status message")))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null);
+ Task task =
+ testTask()
+ .status(status)
+ .artifacts(null)
+ .metadata(
+ ImmutableMap.of(
+ A2AMetadataKey.GROUNDING_METADATA.getType(),
+ groundingMetadata.toJson(),
+ A2AMetadataKey.USAGE_METADATA.getType(),
+ "not-valid-json"))
+ .build();
+
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+
+ assertThat(event.groundingMetadata()).hasValue(groundingMetadata);
+ assertThat(event.usageMetadata()).isEmpty();
+ }
+
@Test
public void messageToEvent_withMissingTaskId_returnsEvent() {
Message a2aMessage =
@@ -272,6 +359,95 @@ public void taskToEvent_withInputRequired_parsesLongRunningToolIds() {
assertThat(event.longRunningToolIds().get()).containsExactly("call_123", "msg_123");
}
+ @Test
+ public void taskToEvent_withDataPartWithoutMetadata_fallsBackToInlineJson() {
+ DataPart dataPart =
+ new DataPart(
+ ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of()));
+ DataPart statusDataPart =
+ new DataPart(
+ ImmutableMap.of("name", "messageTool", "id", "msg_123", "args", ImmutableMap.of()));
+ Message statusMessage =
+ new Message.Builder()
+ .role(Message.Role.AGENT)
+ .parts(ImmutableList.of(statusDataPart))
+ .build();
+ TaskStatus status = new TaskStatus(TaskState.INPUT_REQUIRED, statusMessage, null);
+ Artifact artifact =
+ new Artifact.Builder().artifactId("artifact-1").parts(ImmutableList.of(dataPart)).build();
+ Task task = testTask().status(status).artifacts(ImmutableList.of(artifact)).build();
+
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+
+ assertThat(event.longRunningToolIds().get()).isEmpty();
+ List parts = event.content().get().parts().get();
+ assertThat(parts).hasSize(2);
+ assertThat(parts.get(0).functionCall()).isEmpty();
+ assertThat(inlineJson(parts.get(0))).contains("call_123");
+ assertThat(parts.get(1).functionCall()).isEmpty();
+ assertThat(inlineJson(parts.get(1))).contains("msg_123");
+ }
+
+ @Test
+ public void artifactToEvent_withDataPartWithoutMetadata_fallsBackToInlineJson() {
+ DataPart dataPart =
+ new DataPart(
+ ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of()));
+ Artifact artifact =
+ new Artifact.Builder().artifactId("artifact-1").parts(ImmutableList.of(dataPart)).build();
+
+ Event event = ResponseConverter.artifactToEvent(artifact, invocationContext);
+
+ assertThat(event.longRunningToolIds().get()).isEmpty();
+ List parts = event.content().get().parts().get();
+ assertThat(parts).hasSize(1);
+ assertThat(parts.get(0).functionCall()).isEmpty();
+ assertThat(inlineJson(parts.get(0))).contains("call_123");
+ }
+
+ /**
+ * {@return the wrapped JSON payload of a part that {@link PartConverter} carried through as
+ * generic data}
+ *
+ *
A DataPart with no {@code adk_type} metadata is not converted into a function call, even
+ * when its data is shaped like one; it is serialized into an inline JSON blob instead.
+ */
+ private static String inlineJson(com.google.genai.types.Part part) {
+ assertThat(part.inlineData()).isPresent();
+ assertThat(part.inlineData().get().mimeType()).hasValue("text/plain");
+ return new String(part.inlineData().get().data().get(), UTF_8);
+ }
+
+ @Test
+ public void taskToEvent_withMixedMetadataParts_keepsLongRunningId() {
+ DataPart noMetadataPart =
+ new DataPart(
+ ImmutableMap.of("name", "plainTool", "id", "call_plain", "args", ImmutableMap.of()));
+ DataPart longRunningPart =
+ new DataPart(
+ ImmutableMap.of("name", "lrTool", "id", "call_lr", "args", ImmutableMap.of()),
+ ImmutableMap.of(
+ A2AMetadataKey.TYPE.getType(),
+ "function_call",
+ A2AMetadataKey.IS_LONG_RUNNING.getType(),
+ true));
+ Artifact artifact =
+ new Artifact.Builder()
+ .artifactId("artifact-1")
+ .parts(ImmutableList.of(noMetadataPart, longRunningPart))
+ .build();
+ Task task =
+ testTask()
+ .status(new TaskStatus(TaskState.INPUT_REQUIRED, null, null))
+ .artifacts(ImmutableList.of(artifact))
+ .build();
+
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+
+ assertThat(event.longRunningToolIds().get()).containsExactly("call_lr");
+ assertThat(event.content().get().parts().get()).hasSize(2);
+ }
+
@Test
public void taskToEvent_withFailedState_setsErrorCode() {
Message statusMessage =
@@ -452,7 +628,7 @@ public void clientEventToEvent_withFailedTaskStatusUpdateEvent_returnsErrorEvent
}
@Test
- public void taskToEvent_withInvalidMetadata_throwsException() {
+ public void taskToEvent_withInvalidMetadata_dropsFieldInsteadOfThrowing() {
Message statusMessage =
new Message.Builder()
.role(Message.Role.AGENT)
@@ -467,12 +643,10 @@ public void taskToEvent_withInvalidMetadata_throwsException() {
ImmutableMap.of(A2AMetadataKey.GROUNDING_METADATA.getType(), "{ invalid json ]"))
.build();
- IllegalArgumentException exception =
- assertThrows(
- IllegalArgumentException.class,
- () -> ResponseConverter.taskToEvent(task, invocationContext));
- assertThat(exception).hasMessageThat().contains("Failed to parse metadata");
- assertThat(exception).hasMessageThat().contains("GroundingMetadata");
+ Event event = ResponseConverter.taskToEvent(task, invocationContext);
+
+ assertThat(event.groundingMetadata()).isEmpty();
+ assertThat(event.content().get().parts().get().get(0).text()).hasValue("Status message");
}
@Test
diff --git a/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java b/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java
index 99b12286e..68cb196f1 100644
--- a/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java
+++ b/a2a/src/test/java/com/google/adk/a2a/executor/AgentExecutorTest.java
@@ -63,6 +63,10 @@
@RunWith(JUnit4.class)
public final class AgentExecutorTest {
+ /** A throwable message shaped like the ones that leak host detail. */
+ private static final String SECRET_ERROR =
+ "Runner error: /home/victim/.config/adk/credentials.json (No such file)";
+
private EventQueue eventQueue;
private List