Skip to content

Feat: add Eventarc domain-specific publish tools (Part 2/2) - #534

Open
AmaadMartin wants to merge 2 commits into
feat/eventarc-toolset-part1from
feat/eventarc-toolset-part2
Open

Feat: add Eventarc domain-specific publish tools (Part 2/2)#534
AmaadMartin wants to merge 2 commits into
feat/eventarc-toolset-part1from
feat/eventarc-toolset-part2

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: Part 1 of this stack gives an agent the generic publish_message tool, where the model has to supply every CloudEvent attribute itself. adk-python also lets a toolset author lock attributes down per tool — fixing some by configuration, computing others from the payload, asking the model only for what it genuinely has to choose, and dropping the rest — via EventarcToolset.create_publish_tool. adk-js has no equivalent.

Solution: ports src/google/adk/integrations/eventarc/_domain_specific_publish.py.

  • core/src/integrations/eventarc/domain_specific_publish.ts — the MISSING and OMIT sentinels, the AgentProvided factory with its isAgentProvided type guard, the attribute binding types (AttributeBinding, OptionalAttributeBinding, CustomAttributeBinding, CloudEventAttributesBinding), and buildDomainSpecificTool.
  • EventarcToolset.createPublishTool(...), which builds such a tool and appends it to the toolset (parity with _eventarc_toolset.py:120).
const toolset = new EventarcToolset({toolConfig: {projectId: 'my-project'}});

// The model sees exactly one attribute parameter beyond the payload: `subject`.
// `type` is computed, `source` is fixed, and neither appears in the schema.
const publishOrderEvent = toolset.createPublishTool<{
  userId: string;
  action: string;
}>({
  name: 'publish_order_event',
  description: 'Publishes an order lifecycle event to the orders bus.',
  bus: 'projects/my-project/locations/us-central1/messageBuses/orders',
  ceAttributesBinding: {
    type: (payload) => `com.example.order.${payload.action}`,
    source: '//my-app/order-service',
    subject: AgentProvided({description: 'The order subject.'}),
    time: OMIT,
  },
  payloadSchema: z.object({userId: z.string(), action: z.string()}),
});

Stacked on feat/eventarc-toolset-part1 (the Eventarc publish tool, config and cached publisher client). Review that first; this PR's diff is against it.

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 plausibly adjacent PRs (#445, #323, #466) shows no overlap with core/src/integrations/eventarc/.

Design notes

  • MISSING is always treated exactly like undefined; TypeScript callers can just omit the field. It is kept because it is part of the public surface of the Python toolset this module ports, so an agent moved over from adk-python keeps working, and because it reads better than undefined when a binding is assembled from configuration. The equivalence is now stated once, in isUnspecified, instead of at four separate x === MISSING || x === undefined comparisons.
  • MISSING and OMIT are Symbol.for(...) entries in the global registry, and AgentProvided brands its result with a Symbol.for('google.adk.eventarc.agentProvided') signature property, following core/src/tools/base_tool.ts. Two copies of @google/adk in one runtime therefore still recognise each other's sentinels and bindings — the reason the repo forbids instanceof for type detection.
  • AgentProvided is exported as a callable factory rather than a class, so the reference call shape AgentProvided({description: '...'}) survives while narrowing goes through the exported isAgentProvided guard. The interface it returns is named AgentProvidedBinding because ESLint's no-redeclare rejects a same-named interface/function merge in this repo.
  • Reserved attributes are iterated in the fixed order type, source, datacontenttype, subject, time, specversion, id, so the generated FunctionDeclaration is deterministic. The reference iterates a Python set, so its ordering is not stable; this is a deliberate improvement, not a behavioural divergence.
  • TPayload is an explicit type parameter rather than being inferred from payloadSchema: inferring it through the dual zod-v3/v4 union is not worth the type gymnastics, and an explicit parameter keeps every (payload) => ... callback strongly typed without a cast. The single as TPayload in parsePayload is the one place runtime data becomes TPayload, and it is preceded by payloadSchema.parse(...) whenever the schema is a zod object.

Divergences from adk-python

# Python TypeScript Why
1 Parameter-name mangling: selfself_, clscls_, 123foo_123foo (_domain_specific_publish.py:157-166) Not ported: attribute names appear verbatim in the schema and in customAttributes The mangling exists only because Python function parameters must be valid identifiers. JS object keys are arbitrary strings. leaves custom attribute names unmangled in the declaration and round-trips custom attribute names unmangled pin this.
2 Custom-attribute key check k.isalnum() and k.islower() /^[a-z0-9]+$/ '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. Invisible to every ported case (self_, my-key, MyKey, event_data are rejected either way).
3 A mandatory attribute that resolves to None is simply not forwarded, so publish_message() raises TypeError: missing 1 required keyword-only argument Throws Mandatory CloudEvent attribute '<key>' cannot evaluate to null or undefined. Same failure, with a message that names the attribute. Reachable via AgentProvided({description, default: null}) on type/source.
4 @experimental(FeatureName.EVENTARC_TOOLSET) Bare @experimental adk-js's decorator takes no arguments.

A reference behaviour worth calling out: time: OMIT removes time from the call into publishMessage, which is exactly where adk-python stops — publish_message then applies its own default and stamps the current time. So time: OMIT does not produce an event without a timestamp; the same is true of specversion, which falls back to '1.0'. OMIT does drop attributes that have no default (subject, id, datacontenttype, and every custom attribute). This behaviour is matched deliberately rather than "fixed", because the emitted event is observable across the language boundary; the integration test asserts it explicitly and comments why. Users who want no timestamp at all should bind time: ''.

Not silently reduced: every item in the specification's binding truth table is implemented and tested.

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.

GitHub Actions does not run on this PR. The validation workflow is
pull_request: branches: [main], and this PR targets feat/eventarc-toolset-part1,
so no test job is triggered — the only check that appears is auto-assign.
Part 1 (#533) is green on run-tests for ubuntu, macOS and Windows. Everything
below was therefore run locally against the exact pushed commit
b9733acf25b4bdb95af75780442c0b486b06ab12:

npx vitest run --project unit:core core/test/integrations/eventarc core/test/utils/object_utils_test.ts
    Test Files  7 passed (7)          Tests  157 passed (157)
npx vitest run --project integration tests/integration/eventarc
    Test Files  1 passed (1)          Tests  2 passed (2)
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 stack

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.

core/test/integrations/eventarc/domain_specific_publish_test.ts ports tests/unittests/integrations/eventarc/test_domain_specific_publish.py: the build-time guard table, the generated declaration, MISSING vs OMIT (pinned both at schema level and at runtime, in the same file), runtime resolution with a payload, explicit-null fallback to the declared default, and the unmangled custom-attribute round trip.

tests/integration/eventarc/eventarc_toolset_test.ts wires an EventarcToolset into an LlmAgent, drives it with a mocked Gemini that issues a publish_order_event function call, and asserts the fully resolved CloudEvent reaching a mocked publisher (computed type, fixed source, model-supplied subject, fixed custom attribute, OMIT custom attribute absent) as well as the {status: 'ERROR'} result the model sees when the publish fails. It imports only from @google/adk, which also proves the new public surface resolves through the package entry point. No network I/O.

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
domain_specific_publish.ts 100 98.83 100 100
eventarc_toolset.ts 100 100 100 100
all five modules 100 99.21 100 100

The single uncovered branch in this file 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 deleted 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. Collapse OMIT into MISSING when computing whether a model parameter is required (binding.default === undefined || binding.default === MISSING || binding.default === OMIT) → exposes only the agent-provided attributes and the payload fails with expected [ 'type', 'time', 'event_data' ] to deeply equal [ 'type', 'event_data' ] and keeps MISSING and OMIT defaults from collapsing into each other fails with expected [ 'type', 'source', 'subject' ] to deeply equal [ 'type', 'source' ].
  2. Return the attribute instead of dropping it when a resolver yields OMIT → five tests fail, including resolves fixed, payload-derived and agent-provided attributes with expected { … } to not have property "time" and omits customAttributes entirely when every binding resolves to nothing.
  3. Make isUnspecified ignore the MISSING sentinel and test only for undefined → four cases fail, including keeps MISSING and OMIT defaults from collapsing into each other with expected [ 'type' ] to deeply equal [ 'type', 'source' ] and drops optional attributes bound to null or MISSING with expected { … } to not have property "id".
  4. Delete the new bus shadow guard → rejects a custom attribute that shadows the bus parameter fails with expected [Function] to throw an error.
  5. Make getTools ignore its tool filter → three cases fail, including the restored filters the generic tool out when the filter excludes it with expected [ 'publish_message' ] to deeply equal [].
  6. (Carried over from part 1, re-run here) drop the strict base64 pre-check, skip removePublisherClient on the publish-error path, remove the LRU eviction branch, stop closing the TTL-expired client, and validate time with Date.parse alone — each produces a failing test.

Testing the build-time guards. type, source and bus reject MISSING, OMIT, null and undefined at build time, but TypeScript already rejects those values, so the guards are unreachable from typed code and exist for JavaScript callers. The tests reach them through two small documented helpers (buildToolFromJs, buildToolFromJsBindings) that assemble the options object with Reflect.set from a Record<string, unknown> — no casts and no type suppressions.

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. gcloud eventarc message-buses create orders --location=us-central1, plus a pipeline/enrollment forwarding to a Cloud Run service you can watch.
  3. Build the publish_order_event tool exactly as in the snippet above, attach the toolset to an LlmAgent, and ask the agent to publish an order-created event for a user.
  4. Confirm the subscriber receives a CloudEvent with type = com.example.order.created (computed from the payload), source = //my-app/order-service (fixed), the subject the model chose, and the JSON payload as textData.
  5. Ask the agent to publish without giving it a subject and confirm the tool call fails with Agent did not provide mandatory attribute 'subject' rather than publishing a malformed event.
  6. Point bus at a message bus that does not exist and confirm the tool returns {status: 'ERROR'} rather than an unhandled rejection.

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.

@AmaadMartin
AmaadMartin force-pushed the feat/eventarc-toolset-part2 branch 2 times, most recently from 27935d8 to 9ad60c3 Compare August 2, 2026 17:16
Amaad Martin added 2 commits August 2, 2026 11:03
Ports _domain_specific_publish.py: the MISSING and OMIT sentinels, the
AgentProvided factory and its type guard, the CloudEvent attribute binding
types, and EventarcToolset.createPublishTool.

A binding declares, per CloudEvent attribute, whether the value is fixed by
configuration, computed from the payload, supplied by the model at call time,
or dropped; that declaration determines the parameter schema the model sees.
Reserved attributes are iterated in a fixed order so the generated declaration
is deterministic, which the Python reference does not guarantee.

Python mangles model-facing parameter names into valid identifiers; JavaScript
object keys are arbitrary strings, so attribute names appear verbatim and a
test pins that self, cls and 123foo round-trip unmangled.
…ttribute

Splitting the stack replaced two toolFilter cases from Part 1 rather than
adding the createPublishTool cases beside them, dropping coverage for
"filter matches nothing" and "predicate selects the generic tool". Both are
restored and the three new cases sit alongside them.

A custom attribute named 'bus' passed validation and then overwrote the bus
parameter in the generated declaration, pushing 'bus' into required twice; it
is now rejected like the CloudEvent attribute names.

Also folds resolveBus into resolveMandatory, which differed only in its error
string, drops the unused ReservedAttribute predicate, reuses the shared
isRecord and attribute-key pattern, and states the MISSING/undefined
equivalence once in isUnspecified instead of at four comparison sites.
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