diff --git a/.github/workflows/analyze-releases-for-adk-docs-updates.yml b/.github/workflows/analyze-releases-for-adk-docs-updates.yml
new file mode 100644
index 000000000..e0645360f
--- /dev/null
+++ b/.github/workflows/analyze-releases-for-adk-docs-updates.yml
@@ -0,0 +1,83 @@
+name: Analyze New Release for ADK Docs Updates
+
+on:
+ # Runs on every new release.
+ release:
+ types: [published]
+ # Manual trigger for testing and retrying.
+ workflow_dispatch:
+ inputs:
+ start_tag:
+ description: 'Older release tag (base), e.g. v0.1.0'
+ required: false
+ type: string
+ end_tag:
+ description: 'Newer release tag (head), e.g. v0.2.0'
+ required: false
+ type: string
+ dry_run:
+ description: 'Dry run: preview only. Set to false to actually create the issue and PRs.'
+ required: false
+ default: true
+ type: boolean
+
+jobs:
+ analyze-new-release-for-adk-docs-updates:
+ runs-on: ubuntu-latest
+ # These permissions apply to this repo's GITHUB_TOKEN (used only for checkout).
+ # The agent writes issues, branches and pull requests to the docs repo via the
+ # ADK_TRIAGE_AGENT PAT, which must have issues + pull-requests + contents write there.
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Java
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Cache Maven packages
+ uses: actions/cache@v5
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - name: Run the ADK Docs Release Analyzer
+ env:
+ GITHUB_TOKEN: ${{ secrets.ADK_TRIAGE_AGENT }}
+ GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY_FOR_DOCS_AGENTS }}
+ GOOGLE_GENAI_USE_VERTEXAI: '0'
+ DOC_OWNER: 'google'
+ CODE_OWNER: 'google'
+ DOC_REPO: 'adk-docs'
+ CODE_REPO: 'adk-java'
+ ANALYZER_START_TAG: ${{ github.event.inputs.start_tag }}
+ ANALYZER_END_TAG: ${{ github.event.inputs.end_tag }}
+ # Defaults to dry-run (preview only). Flip to false via manual dispatch
+ # to actually create the issue and PRs.
+ ANALYZER_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [[ "${ANALYZER_DRY_RUN}" == "false" ]]; then
+ args="--no-dry-run"
+ else
+ args="--dry-run"
+ fi
+ if [[ -n "${ANALYZER_START_TAG:-}" ]]; then
+ args="${args} --start-tag ${ANALYZER_START_TAG}"
+ fi
+ if [[ -n "${ANALYZER_END_TAG:-}" ]]; then
+ args="${args} --end-tag ${ANALYZER_END_TAG}"
+ fi
+ # Install ADK libs + sample, then run exec:java scoped to this module
+ # (exec:java with -am would also run on the parent, which has no mainClass).
+ ./mvnw -q -pl contrib/samples/github/adkreleasedocs -am install -DskipTests
+ ./mvnw -q -pl contrib/samples/github/adkreleasedocs exec:java \
+ -Dexec.args="${args}"
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 eb1d2f112..94cf51524 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
@@ -15,6 +15,7 @@
*/
package com.google.adk.a2a.converters;
+import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -39,6 +40,7 @@
import io.a2a.spec.FileWithUri;
import io.a2a.spec.Message;
import io.a2a.spec.TextPart;
+import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -66,6 +68,9 @@ public final class PartConverter {
public static final String PARTIAL_ARGS_KEY = "partialArgs";
public static final String SCHEDULING_KEY = "scheduling";
public static final String PARTS_KEY = "parts";
+ public static final String A2A_DATA_PART_START_TAG = "";
+ public static final String A2A_DATA_PART_END_TAG = "";
+ public static final String A2A_DATA_PART_TEXT_MIME_TYPE = "text/plain";
public static Optional toTextPart(io.a2a.spec.Part> part) {
if (part instanceof TextPart textPart) {
@@ -76,9 +81,7 @@ public static Optional toTextPart(io.a2a.spec.Part> part) {
/** Convert an A2A JSON part into a Google GenAI part representation. */
public static com.google.genai.types.Part toGenaiPart(io.a2a.spec.Part> a2aPart) {
- if (a2aPart == null) {
- throw new IllegalArgumentException("A2A part cannot be null");
- }
+ checkNotNull(a2aPart, "A2A part cannot be null");
if (a2aPart instanceof TextPart textPart) {
com.google.genai.types.Part.Builder partBuilder =
@@ -220,9 +223,13 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d
}
try {
- String json = objectMapper.writeValueAsString(data);
+ String json = objectMapper.writeValueAsString(dataPart);
+ String wrappedJson = A2A_DATA_PART_START_TAG + json + A2A_DATA_PART_END_TAG;
+ byte[] bytes = wrappedJson.getBytes(StandardCharsets.UTF_8);
com.google.genai.types.Part.Builder builder =
- com.google.genai.types.Part.builder().text(json);
+ com.google.genai.types.Part.builder()
+ .inlineData(
+ Blob.builder().data(bytes).mimeType(A2A_DATA_PART_TEXT_MIME_TYPE).build());
if (!metadata.isEmpty()) {
builder.partMetadata(metadata);
}
@@ -334,6 +341,53 @@ private static DataPart createDataPartFromExecutableCode(
return new DataPart(data.buildOrThrow(), metadata.buildOrThrow());
}
+ /**
+ * {@return true if the given Blob contains inlineData that represents a serialized A2A DataPart,
+ * false otherwise}
+ *
+ *
A DataPart in inlineData is expected to have a "text/plain" MIME type and the content
+ * wrapped in {@link #A2A_DATA_PART_START_TAG} and {@link #A2A_DATA_PART_END_TAG}.
+ *
+ * @param blob The Blob to check.
+ */
+ private static boolean isDataPartInlineData(Blob blob) {
+ String mimeType = blob.mimeType().orElse("");
+ if (!mimeType.equals(A2A_DATA_PART_TEXT_MIME_TYPE)) {
+ return false;
+ }
+ byte[] data = blob.data().orElse(null);
+ if (data == null) {
+ return false;
+ }
+ String str = new String(data, StandardCharsets.UTF_8);
+ return str.startsWith(A2A_DATA_PART_START_TAG) && str.endsWith(A2A_DATA_PART_END_TAG);
+ }
+
+ private static DataPart inlineDataToA2ADataPart(
+ Blob blob, ImmutableMap metadata) {
+ byte[] data = blob.data().orElse(null);
+ if (data == null) {
+ throw new IllegalArgumentException("Blob data cannot be null");
+ }
+ String str = new String(data, StandardCharsets.UTF_8);
+ String jsonContent =
+ str.substring(
+ A2A_DATA_PART_START_TAG.length(), str.length() - A2A_DATA_PART_END_TAG.length());
+ try {
+ DataPart deserialized = objectMapper.readValue(jsonContent, DataPart.class);
+
+ ImmutableMap.Builder mergedMetadata = ImmutableMap.builder();
+ if (deserialized.getMetadata() != null) {
+ mergedMetadata.putAll(deserialized.getMetadata());
+ }
+ mergedMetadata.putAll(metadata);
+
+ return new DataPart(deserialized.getData(), mergedMetadata.buildKeepingLast());
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Failed to parse DataPart payload from inlineData", e);
+ }
+ }
+
private PartConverter() {}
/** Convert a GenAI part into the A2A JSON representation. */
@@ -352,6 +406,10 @@ public static io.a2a.spec.Part> fromGenaiPart(Part part, boolean isPartial) {
return new TextPart(part.text().get(), metadata.buildKeepingLast());
}
+ if (part.inlineData().isPresent() && isDataPartInlineData(part.inlineData().get())) {
+ return inlineDataToA2ADataPart(part.inlineData().get(), metadata.buildOrThrow());
+ }
+
if (part.fileData().isPresent() || part.inlineData().isPresent()) {
return filePartToA2A(part, metadata);
}
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 96f6add1c..709599a51 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
@@ -31,7 +31,7 @@ public class PartConverterTest {
@Test
public void toGenaiPart_withNullPart_throwsException() {
- assertThrows(IllegalArgumentException.class, () -> PartConverter.toGenaiPart(null));
+ assertThrows(NullPointerException.class, () -> PartConverter.toGenaiPart(null));
}
@Test
@@ -194,13 +194,18 @@ public void toGenaiPart_withDataPartFunctionResponse_returnsGenaiFunctionRespons
}
@Test
- public void toGenaiPart_withOtherDataPart_returnsGenaiTextPartWithJson() {
+ public void toGenaiPart_withOtherDataPart_returnsGenaiInlineDataPartWithWrappedJson() {
ImmutableMap data = ImmutableMap.of("key", "value");
DataPart dataPart = new DataPart(data, null);
Part result = PartConverter.toGenaiPart(dataPart);
- assertThat(result.text()).hasValue("{\"key\":\"value\"}");
+ assertThat(result.inlineData()).isPresent();
+ Blob blob = result.inlineData().get();
+ assertThat(blob.mimeType()).hasValue("text/plain");
+ String expectedContent =
+ "{\"data\":{\"key\":\"value\"},\"kind\":\"data\"}";
+ assertThat(new String(blob.data().get(), UTF_8)).isEqualTo(expectedContent);
}
@Test
@@ -405,4 +410,41 @@ public void fromGenaiPart_withPartMetadata_propagatesMetadata() {
assertThat(result.getMetadata()).containsExactly("key", "value");
}
+
+ @Test
+ public void fromGenaiPart_withDataPartInlineData_returnsDataPart() {
+ String wrappedJson =
+ "{\"data\":{\"key\":\"value\"},\"kind\":\"data\"}";
+ Part part =
+ Part.builder()
+ .inlineData(
+ Blob.builder().mimeType("text/plain").data(wrappedJson.getBytes(UTF_8)).build())
+ .build();
+
+ io.a2a.spec.Part> result = PartConverter.fromGenaiPart(part, false);
+
+ assertThat(result).isInstanceOf(DataPart.class);
+ DataPart dataPart = (DataPart) result;
+ assertThat(dataPart.getData()).containsExactly("key", "value");
+ }
+
+ @Test
+ public void fromGenaiPart_withDataPartInlineDataAndMetadata_returnsDataPartWithMergedMetadata() {
+ String wrappedJson =
+ "{\"data\":{\"key\":\"value\"},\"metadata\":{\"metaKey\":\"metaValue\"},\"kind\":\"data\"}";
+ Part part =
+ Part.builder()
+ .inlineData(
+ Blob.builder().mimeType("text/plain").data(wrappedJson.getBytes(UTF_8)).build())
+ .partMetadata(ImmutableMap.of("partMetaKey", "partMetaValue"))
+ .build();
+
+ io.a2a.spec.Part> result = PartConverter.fromGenaiPart(part, false);
+
+ assertThat(result).isInstanceOf(DataPart.class);
+ DataPart dataPart = (DataPart) result;
+ assertThat(dataPart.getData()).containsExactly("key", "value");
+ assertThat(dataPart.getMetadata())
+ .containsExactly("metaKey", "metaValue", "partMetaKey", "partMetaValue");
+ }
}
diff --git a/contrib/samples/github/GitHubTools.java b/contrib/samples/github/GitHubTools.java
new file mode 100644
index 000000000..bb17f1086
--- /dev/null
+++ b/contrib/samples/github/GitHubTools.java
@@ -0,0 +1,518 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.example.github;
+
+import com.google.adk.tools.Annotations.Schema;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.kohsuke.github.GHCommit;
+import org.kohsuke.github.GHCompare;
+import org.kohsuke.github.GHContent;
+import org.kohsuke.github.GHContentBuilder;
+import org.kohsuke.github.GHException;
+import org.kohsuke.github.GHFileNotFoundException;
+import org.kohsuke.github.GHIssue;
+import org.kohsuke.github.GHIssueState;
+import org.kohsuke.github.GHLabel;
+import org.kohsuke.github.GHPullRequest;
+import org.kohsuke.github.GHRelease;
+import org.kohsuke.github.GHRepository;
+import org.kohsuke.github.GitHub;
+import org.kohsuke.github.GitHubBuilder;
+
+/**
+ * Reusable GitHub function tools backed by the {@code org.kohsuke:github-api} client. Each returns
+ * a {@code Map} with a {@code "status"} of {@code "success"} or {@code "error"}. Reads {@code
+ * GITHUB_TOKEN} from the environment; callers set {@link #dryRun} to gate writes.
+ *
+ *
Defense in depth against prompt injection: the agent reads untrusted GitHub content (diffs,
+ * file contents, issue/PR titles) and could be steered into harmful writes. Independently of the
+ * prompt, the write tools (a) only target {@link #writeRepoOwner}/{@link #writeRepoName} when set,
+ * (b) only modify Markdown files under {@code docs/}, and (c) are capped per run.
+ */
+public final class GitHubTools {
+
+ /**
+ * When true, {@code create_issue}/{@code create_pull_request} return a preview instead of
+ * writing.
+ */
+ public static boolean dryRun = true;
+
+ /**
+ * When both are set, {@code create_issue}/{@code create_pull_request} refuse to write to any
+ * other repository, regardless of the owner/repo the model passes. Set by the entry point to the
+ * docs repository so untrusted content cannot redirect writes elsewhere.
+ */
+ public static String writeRepoOwner = null;
+
+ public static String writeRepoName = null;
+
+ private static final int MAX_SEARCH_RESULTS = 50;
+ private static final String DOCS_UPDATES_LABEL = "docs updates";
+ private static final String STATUS_KEY = "status";
+ private static final String STATUS_SUCCESS = "success";
+ private static final String STATUS_ERROR = "error";
+ private static final String STATUS_DRY_RUN = "dry_run";
+
+ /** Only Markdown files under {@code docs/} (excluding api-reference) may be written by a PR. */
+ private static final String DOCS_PATH_PREFIX = "docs/";
+
+ private static final String API_REFERENCE_PREFIX = "docs/api-reference/";
+
+ /** Per-run write caps to bound spam/abuse if the agent is hijacked. */
+ private static final int MAX_ISSUES_PER_RUN = 1;
+
+ private static final int MAX_PULL_REQUESTS_PER_RUN = 20;
+ private static int issuesCreated = 0;
+ private static int pullRequestsCreated = 0;
+
+ private GitHubTools() {}
+
+ @Schema(
+ name = "list_releases",
+ description =
+ "Lists releases for a repository (most recent first), returning each release's tag_name,"
+ + " name and published_at.")
+ public static Map listReleases(
+ @Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
+ @Schema(name = "repo_name", description = "The repository name.") String repoName) {
+ try {
+ GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
+ List