Feat: add Eventarc domain-specific publish tools (Part 2/2) - #534
Open
AmaadMartin wants to merge 2 commits into
Open
Feat: add Eventarc domain-specific publish tools (Part 2/2)#534AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
AmaadMartin
force-pushed
the
feat/eventarc-toolset-part2
branch
2 times, most recently
from
August 2, 2026 17:16
27935d8 to
9ad60c3
Compare
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.
AmaadMartin
force-pushed
the
feat/eventarc-toolset-part2
branch
from
August 2, 2026 18:07
9ad60c3 to
b9733ac
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: #issue_number
Problem: Part 1 of this stack gives an agent the generic
publish_messagetool, where the model has to supply every CloudEvent attribute itself.adk-pythonalso 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 — viaEventarcToolset.create_publish_tool.adk-jshas no equivalent.Solution: ports
src/google/adk/integrations/eventarc/_domain_specific_publish.py.core/src/integrations/eventarc/domain_specific_publish.ts— theMISSINGandOMITsentinels, theAgentProvidedfactory with itsisAgentProvidedtype guard, the attribute binding types (AttributeBinding,OptionalAttributeBinding,CustomAttributeBinding,CloudEventAttributesBinding), andbuildDomainSpecificTool.EventarcToolset.createPublishTool(...), which builds such a tool and appends it to the toolset (parity with_eventarc_toolset.py:120).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 1000returned 428 open PRs; none mentions Eventarc, CloudEvents or publishing, andgh pr diff --name-onlyon the plausibly adjacent PRs (#445, #323, #466) shows no overlap withcore/src/integrations/eventarc/.Design notes
MISSINGis always treated exactly likeundefined; 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 fromadk-pythonkeeps working, and because it reads better thanundefinedwhen a binding is assembled from configuration. The equivalence is now stated once, inisUnspecified, instead of at four separatex === MISSING || x === undefinedcomparisons.MISSINGandOMITareSymbol.for(...)entries in the global registry, andAgentProvidedbrands its result with aSymbol.for('google.adk.eventarc.agentProvided')signature property, followingcore/src/tools/base_tool.ts. Two copies of@google/adkin one runtime therefore still recognise each other's sentinels and bindings — the reason the repo forbidsinstanceoffor type detection.AgentProvidedis exported as a callable factory rather than a class, so the reference call shapeAgentProvided({description: '...'})survives while narrowing goes through the exportedisAgentProvidedguard. The interface it returns is namedAgentProvidedBindingbecause ESLint'sno-redeclarerejects a same-named interface/function merge in this repo.type, source, datacontenttype, subject, time, specversion, id, so the generatedFunctionDeclarationis deterministic. The reference iterates a Pythonset, so its ordering is not stable; this is a deliberate improvement, not a behavioural divergence.TPayloadis an explicit type parameter rather than being inferred frompayloadSchema: 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 singleas TPayloadinparsePayloadis the one place runtime data becomesTPayload, and it is preceded bypayloadSchema.parse(...)whenever the schema is a zod object.Divergences from
adk-pythonself→self_,cls→cls_,123foo→_123foo(_domain_specific_publish.py:157-166)customAttributesleaves custom attribute names unmangled in the declarationandround-trips custom attribute names unmangledpin this.k.isalnum() and k.islower()/^[a-z0-9]+$/'123'.islower()isFalsein 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_dataare rejected either way).Noneis simply not forwarded, sopublish_message()raisesTypeError: missing 1 required keyword-only argumentMandatory CloudEvent attribute '<key>' cannot evaluate to null or undefined.AgentProvided({description, default: null})ontype/source.@experimental(FeatureName.EVENTARC_TOOLSET)@experimentaladk-js's decorator takes no arguments.A reference behaviour worth calling out:
time: OMITremovestimefrom the call intopublishMessage, which is exactly whereadk-pythonstops —publish_messagethen applies its own default and stamps the current time. Sotime: OMITdoes not produce an event without a timestamp; the same is true ofspecversion, which falls back to'1.0'.OMITdoes 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 bindtime: ''.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
validationworkflow ispull_request: branches: [main], and this PR targetsfeat/eventarc-toolset-part1,so no test job is triggered — the only check that appears is
auto-assign.Part 1 (#533) is green on
run-testsfor ubuntu, macOS and Windows. Everythingbelow was therefore run locally against the exact pushed commit
b9733acf25b4bdb95af75780442c0b486b06ab12:npm run ts:checkis red onmainas well; the error set is byte-identicalwith and without this branch, and no file in this diff appears in it.
core/test/integrations/eventarc/domain_specific_publish_test.tsportstests/unittests/integrations/eventarc/test_domain_specific_publish.py: the build-time guard table, the generated declaration,MISSINGvsOMIT(pinned both at schema level and at runtime, in the same file), runtime resolution with a payload, explicit-nullfallback to the declared default, and the unmangled custom-attribute round trip.tests/integration/eventarc/eventarc_toolset_test.tswires anEventarcToolsetinto anLlmAgent, drives it with a mocked Gemini that issues apublish_order_eventfunction call, and asserts the fully resolved CloudEvent reaching a mocked publisher (computedtype, fixedsource, model-suppliedsubject, fixed custom attribute,OMITcustom 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/**':domain_specific_publish.tseventarc_toolset.tsThe single uncovered branch in this file is the
isRecord(input) ? input : {}fallback in the tool callback.FunctionTooltypes the callback argument asunknownbut always passes itsRecord<string, unknown>argument bag, so theelsearm is unreachable; a narrower callback parameter is rejected bystrictFunctionTypes. The guard is kept rather than deleted because the value isunknownat the type level.Proving the tests can fail. Each new test was run against mutated source and observed to fail:
OMITintoMISSINGwhen computing whether a model parameter is required (binding.default === undefined || binding.default === MISSING || binding.default === OMIT) →exposes only the agent-provided attributes and the payloadfails withexpected [ 'type', 'time', 'event_data' ] to deeply equal [ 'type', 'event_data' ]andkeeps MISSING and OMIT defaults from collapsing into each otherfails withexpected [ 'type', 'source', 'subject' ] to deeply equal [ 'type', 'source' ].OMIT→ five tests fail, includingresolves fixed, payload-derived and agent-provided attributeswithexpected { … } to not have property "time"andomits customAttributes entirely when every binding resolves to nothing.isUnspecifiedignore theMISSINGsentinel and test only forundefined→ four cases fail, includingkeeps MISSING and OMIT defaults from collapsing into each otherwithexpected [ 'type' ] to deeply equal [ 'type', 'source' ]anddrops optional attributes bound to null or MISSINGwithexpected { … } to not have property "id".busshadow guard →rejects a custom attribute that shadows the bus parameterfails withexpected [Function] to throw an error.getToolsignore its tool filter → three cases fail, including the restoredfilters the generic tool out when the filter excludes itwithexpected [ 'publish_message' ] to deeply equal [].removePublisherClienton the publish-error path, remove the LRU eviction branch, stop closing the TTL-expired client, and validatetimewithDate.parsealone — each produces a failing test.Testing the build-time guards.
type,sourceandbusrejectMISSING,OMIT,nullandundefinedat 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 withReflect.setfrom aRecord<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:npm i @google-cloud/eventarc-publishingin the consuming project and authenticate with Application Default Credentials (gcloud auth application-default login).gcloud eventarc message-buses create orders --location=us-central1, plus a pipeline/enrollment forwarding to a Cloud Run service you can watch.publish_order_eventtool exactly as in the snippet above, attach the toolset to anLlmAgent, and ask the agent to publish an order-created event for a user.type = com.example.order.created(computed from the payload),source = //my-app/order-service(fixed), thesubjectthe model chose, and the JSON payload astextData.Agent did not provide mandatory attribute 'subject'rather than publishing a malformed event.busat 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.