Skip to content

Feat: port the Eventarc publish tool from adk-python (Part 1/2) - #533

Open
AmaadMartin wants to merge 4 commits into
mainfrom
feat/eventarc-toolset-part1
Open

Feat: port the Eventarc publish tool from adk-python (Part 1/2)#533
AmaadMartin wants to merge 4 commits into
mainfrom
feat/eventarc-toolset-part1

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:
    Problem: An ADK agent has no way to emit a CloudEvent into an existing Eventarc Advanced event-driven pipeline instead of calling downstream services directly. adk-python ships google.adk.integrations.eventarc; a case-insensitive search for eventarc across core/src, dev/src and integrations/src of adk-js returns zero hits.

Solution: Part 1 of 2 ports the generic publish path from adk-python's src/google/adk/integrations/eventarc/:

  • core/src/integrations/eventarc/config.tsEventarcToolConfig, EventarcCredentialsConfig and their default resolution (ported from _config.py).
  • core/src/integrations/eventarc/client.ts — the LRU + TTL cached PublisherClient (ported from _client.py, CACHE_MAX_SIZE = 10, CACHE_TTL_MS = 30 min).
  • core/src/integrations/eventarc/message_tool.tspublishMessage plus the generic publish_message tool: validation, base64 decoding, content-type inference, CloudEvent assembly and W3C trace-context injection (ported from _message_tool.py).
  • core/src/integrations/eventarc/eventarc_toolset.tsEventarcToolset (ported from _eventarc_toolset.py), which is also the module's public barrel.
  • One export line in core/src/index.ts.
  • core/src/utils/object_utils.ts — the isRecord predicate, which has no Eventarc content and is used by both parts of the stack, so it lives in shared utils rather than being copied per feature file. It is deliberately not added to the package barrel: relocating an internal helper does not make it public API.

Part 2 (stacked on this branch) adds _domain_specific_publish.py: the MISSING/OMIT sentinels, AgentProvided, the CloudEvent attribute bindings and EventarcToolset.createPublishTool. The work is split because the full port is ~3,800 lines; each part is independently complete and reviewable.

Collision check: gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 returned 428 open PRs; none mentions Eventarc, CloudEvents or publishing, and gh pr diff --name-only on the three plausibly adjacent PRs (#445 engines, #323 eslint no-extraneous-dependencies, #466 Firestore session service) shows no overlap with core/src/integrations/eventarc/. #445 and #323 also touch core/package.json, but different fields (engines and devDependencies respectively), so this branches from main rather than stacking on them.

Why @google-cloud/eventarc-publishing is an optional peer dependency: the package pulls in google-gax and @grpc/grpc-js. Making it a hard dependency of @google/adk would impose that tree on every ADK JS user for an integration most will not use, and it would diverge from adk-python, where google-cloud-eventarc-publishing>=0.10,<1 appears only in the all, gcp and test extras. It is therefore declared as devDependencies (so CI can typecheck and test it) plus an optional peerDependencies entry, matching the @mikro-orm/* driver precedent in core/src/sessions/db/operations.ts, and loaded with await import(). peerDependenciesMeta did not exist in core/package.json before; adding it is intentional.

Why the root overrides entry: google-gax@5 pins google-auth-library to the exact version 10.5.0, so npm installs a second copy next to the ^10.3.0 copy @google/adk already depends on. Because ClientOptions.authClient is typed against that copy's client classes, an AuthClient from the copy ADK depends on is not assignable to it even though it is the same class at runtime. The fix is to stop duplicating the package rather than to cast:

"overrides": {"google-gax": {"google-auth-library": "^10.7.0"}}

This is scoped to google-gax, which is itself new in this PR (it is absent from main's lockfile), so no pre-existing dependency resolution changes: the lockfile diff is 237 pure insertions with zero deletions.

Divergences from adk-python (local convention wins for what never leaves the process; parity wins for anything observable across the boundary — model-facing parameter names, result keys, status strings, error text and defaults):

# Python TypeScript Why
1 publish_timeout: float = 15.0 (seconds) publishTimeoutMs?: number, default 15_000 gax CallOptions.timeout is milliseconds and every other timeout in adk-js is *Ms. Same effective 15 s.
2 Pydantic ValidationError on EventarcToolConfig(project_id=123) Not ported Config is a plain interface, as every other adk-js options type is; the compiler enforces it. config_test.ts covers default resolution instead.
3 _get_credential_id inspects google.auth internals (_source_credentials, _credential_access_boundary, _credential_source, _subject_token_supplier) Derives an id from what google-auth-library exposes (email, targetPrincipal, audience, a SHA-256 of the refresh token), falling back to a WeakMap per-object id Those Python internals have no TypeScript counterpart; inventing them would be fabrication rather than parity. Downscoped and pluggable credentials therefore do not share a cached channel, which is safe (they get their own).
4 Cache key includes os.getpid() Omitted; the key is projectId | scopes | credentialId A Node child process starts with a fresh module registry, so the component carries no information. scopes was added because two configs with the same auth client but different scopes must not share a channel.
5 get_publisher_client(user_agent=...) Parameter not ported Nothing in the reference ever passes it, so it would ship as config with no reader. The user agent is the constant adk-eventarc-tool google-adk/<version>, sent as gax libName/libVersion.
6 TTL-expired clients are popped without being closed Expired and LRU-evicted clients are close()d Leaking a gRPC channel in a long-running agent is a defect; client_test.ts pins both.
7 custom_attributes must be a dict; key check is k.isalnum() and k.islower() custom_attributes must be an object; key check is /^[a-z0-9]+$/ "dict" is Python vocabulary. '123'.islower() is False in Python, so an all-digit key is rejected there despite the error message and the CloudEvents spec both allowing digits; the TS check follows the stated contract. Invisible to every ported case.
8 json.dumps emits '{"foo": "bar"}'; str(True) is 'True' JSON.stringify emits '{"foo":"bar"}'; String(true) is 'true' Language defaults. The ported assertions use the JS forms.
9 data is typed Any through signature introspection Declared Type.STRING with a description telling the model to JSON-encode structured payloads genai Schema has no "any" type. publishMessage() still accepts unknown and performs the full type introspection, so every content-type branch is reachable and tested.
10 opentelemetry guarded by try/except ImportError Imported directly @opentelemetry/api@1.9.0 is a hard dependency of core. With no propagator registered the carrier is simply empty, which is the same observable outcome, and message_tool_test.ts pins both cases.
11 @experimental(FeatureName.EVENTARC_TOOLSET) Bare @experimental adk-js's experimental decorator takes no arguments, and its FeatureRegistry is an unrelated runtime kill-switch.
12 error_details is repr(e) error_details is the error message repr has no JS equivalent; the message is what the model can act on.

Not silently reduced: nothing in the specification for this part was skipped. The only intentional omissions are rows 3, 4 and 5 above, each of which is a Python-internal detail with no TypeScript counterpart.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

npx vitest run --project unit:core core/test/integrations/eventarc core/test/utils/object_utils_test.ts
    Test Files  6 passed (6)          Tests  111 passed (111)
npm run build                    exit 0
npm run lint                     exit 0, 0 problems
npm run format:check             no file from this diff reported
npm run docs:check               exit 0 (typedoc --treatWarningsAsErrors)
bash scripts/check_license.sh    all files have the correct license header
npm run ts:check                 41 files with errors, none of them touched by this PR

npm run ts:check is red on main as well; the error set is byte-identical
with and without this branch, and no file in this diff appears in it.

CI on this PR is green: run-tests passes on ubuntu-latest, macos-latest and
windows-latest. The Windows job is flaky on two pre-existing tests that this
diff does not touch, and needed re-runs: webui_test.ts could not bind an
ephemeral port (listen EACCES: permission denied ::1:49723), and
unsafe_local_code_executor_test.ts > should execute shell code and return stdout hits its 5 s budget spawning PowerShell. ubuntu and macOS have passed
on every run. For what it is worth, the Windows job on this branch takes 8m44s
against 8m59s for a concurrent PR that does not add these dependencies, so the
larger install is not what is pushing that test over its budget.

Coverage of the new code, measured with
npx vitest run --project unit:core core/test/integrations/eventarc --coverage.enabled --coverage.include='core/src/integrations/eventarc/**':

File % Stmts % Branch % Funcs % Lines
client.ts 100 100 100 100
config.ts 100 100 100 100
message_tool.ts 100 99.11 100 100
eventarc_toolset.ts 100 100 100 100

The single uncovered branch is the isRecord(input) ? input : {} fallback in the tool callback. FunctionTool types the callback argument as unknown but always passes its Record<string, unknown> argument bag, so the else arm is unreachable; a narrower callback parameter is rejected by strictFunctionTypes. The guard is kept rather than removed because the value is unknown at the type level.

Proving the tests can fail. Each new test was run against mutated source and observed to fail:

  1. Delete the strict base64 pre-check and rely on Buffer.from(s, 'base64') (which silently discards invalid characters) → rejects a payload outside the base64 alphabet and rejects a base64 payload with a truncated final group fail with expected ERROR but the publish succeeded.
  2. Validate time with Date.parse alone, dropping the RFC 3339 regex → rejects date without a time component fails with expected ERROR but the publish succeeded (Date.parse('2026-06-03') is happy).
  3. Skip removePublisherClient on the publish-error path → evicts the cached client when the publish call fails fails with expected "spy" to be called with arguments: [ { …(2) } ].
  4. Remove the LRU eviction branch from getPublisherClientevicts and closes the least recently used client when full fails with expected [] to have a length of 1 but got +0.
  5. Stop closing the client dropped by TTL expiry → rebuilds the client once the TTL has elapsed fails with expected [] to have a length of 1 but got +0.
  6. Put subject on the CloudEvent instead of in its attributes → puts the subject in the attributes rather than on the event fails with expected undefined to be 'orders/42'.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

A real end-to-end run needs a GCP project, an Eventarc Advanced message bus and roles/eventarc.publisher, so it cannot run in CI. To verify manually:

  1. npm i @google-cloud/eventarc-publishing in the consuming project and authenticate with Application Default Credentials (gcloud auth application-default login).
  2. Create a bus and a subscriber:
    gcloud eventarc message-buses create orders --location=us-central1, then a pipeline/enrollment that forwards to a Cloud Run service you can watch.
  3. Run a small agent whose tools include new EventarcToolset({toolConfig: {projectId: process.env.GOOGLE_CLOUD_PROJECT}}) and ask it to publish a message to
    projects/$GOOGLE_CLOUD_PROJECT/locations/us-central1/messageBuses/orders.
  4. Confirm the tool returns {status: 'SUCCESS', message_id: ...} and that the subscriber receives a CloudEvent whose id matches message_id.
  5. Repeat with a bus name that does not exist and confirm the tool returns {status: 'ERROR', error_details: ...} rather than throwing an unhandled rejection, and that the next call rebuilds the channel.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 3 commits August 2, 2026 09:54
Adds core/src/integrations/eventarc with the EventarcToolset and its generic
publish_message tool, the CloudEvent assembly and validation ported from
adk-python's _message_tool.py, and the LRU/TTL-cached publisher client from
_client.py.

@google-cloud/eventarc-publishing is an optional peer dependency, mirroring the
optional extra in adk-python; it is loaded with a dynamic import so users who
do not publish events never pull in google-gax.

google-gax pins google-auth-library to an exact patch version, which npm would
otherwise install as a second copy whose AuthClient type is distinct from the
one @google/adk depends on. A narrowly scoped override keeps a single copy so
the credentials config can be typed without a cast.
The entries added for @google-cloud/eventarc-publishing were written by a
client configured against a mirror, so their resolved URLs pointed somewhere
CI cannot authenticate to. The integrity hashes are unchanged and were each
verified against registry.npmjs.org.
Both references are type-only, so TypeScript already elides them, but marking
them explicitly means a later value use becomes a compile error instead of a
silent hard dependency on an optional peer.
Moves the isRecord predicate, which has no Eventarc content, to
core/src/utils/object_utils.ts so the Eventarc modules stop each carrying a
copy, and exports the extension-attribute key pattern once from the Eventarc
config.

Also drops the Python spelling from the base64 error: the value the model
sends is `true`, not `True`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant