Fix: stop embedding the ADK runtime in every compiled agent file - #480
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: stop embedding the ADK runtime in every compiled agent file#480AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 1, 2026 16:03
AgentFile.load() compiles each agent with esbuild packages: 'bundle', so a four-line agent becomes a self-contained 5.9MB copy of the whole ADK runtime that Node then has to evaluate. Loading a directory of N agents therefore evaluates N private ADK copies: the AgentLoader discovery fixture measured 17.9s for listApps() on a fast Linux box, which is what makes the app_loader integration test time out on the slower macOS and Windows CI legs. Mark @google/adk and @google/adk-devtools external so the artifact imports the runtime from the node_modules symlink the loader already creates beside it. The compiled discovery artifact drops from 5,878,817 to 574 bytes and listApps() from 17.9s to 69ms.
Every beforeAll/afterAll in this file passed TEST_EXECUTION_TIMEOUT (40s) as its third argument, which lowers the budget below the integration project's deliberate hookTimeout of 120s -- chosen because a cold, network-bound fixture install has been measured at ~70s. Drop the argument from the four hooks that run npm install or tear down its node_modules tree so they inherit 120s; every it()'s own TEST_EXECUTION_TIMEOUT is untouched. Also pin the externalization behaviourally rather than by timing: the compiled discovery artifact must stay under 64KB. It is 981 bytes with the ADK left external and 5,902,465 bytes without, so the threshold has three orders of magnitude of margin and needs no flaky duration assertion.
AmaadMartin
force-pushed
the
fix/agent-loader-external-adk-runtime
branch
from
August 1, 2026 23:53
795a1e0 to
c9b10db
Compare
This was referenced Aug 2, 2026
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:
The
AgentLoaderdiscovery integration test intermittently fails withTest timed out in 40000mson themacos-latestandwindows-latestlegs ofthe
validationworkflow. It is not branch specific — it has reddened push runsagainst
mainwith the ubuntu leg green.The root cause is measured, not inferred. The failing test's first statement is
await loader.listApps(), which is the first call intopreloadAgents()and socalls
AgentFile.load()on every discovered candidate.AgentFile.load()runsesbuild.build()withpackages: 'bundle', so each four-line fixture iscompiled into a self-contained minified copy of the entire ADK runtime
(
@google/adkpulls in@google-cloud/storage, ten@opentelemetry/*packages,
@mikro-orm/*,express,winston,@google/genai,zod, …). Thatartifact is then
await import()ed into the test process. Four fixtures meansfour independent multi-megabyte ADK evaluations.
Measured on this branch, on a fast multi-core Linux workstation with warm
caches, no coverage instrumentation and no competing test files:
discovery/service_alpha/app.tsartifactlistApps()over the 4-fixture discovery treeshould discover apps vs agents…(in suite)17.9 s of a 40 s budget was being consumed on the fastest platform under ideal
conditions. CI runs
npm run test:coverage(V8 instrumentation) on 2–4 coremacOS/Windows runners with ~65 other integration/e2e files scheduled across
parallel forks, where a 2–3× slowdown is unremarkable. Nothing about the test is
non-deterministic except how much CPU it gets — hence the intermittency.
Separately, every
beforeAll/afterAllin the file passedTEST_EXECUTION_TIMEOUT(40000) as its third argument, which lowers the hookbudget below the integration project's deliberate
hookTimeout: 120000— avalue chosen (
vitest.config.ts:11-15) precisely because a cold, network-boundfixture install has been measured at ~70 s. So the install-heavy hooks were
capped at 40 s for no reason.
Solution:
Remove the work rather than widen the budget. No per-test timeout is raised
anywhere.
dev/src/utils/agent_loader.ts— add@google/adkand@google/adk-devtoolsto theexternalarray already passed toesbuild.build(). Runtime resolution is served by thenode_modulessymlinkthe loader already creates next to the output (
linkProjectNodeModules) —that helper exists precisely so bare specifiers resolve from the compiled
artifact's location. This is the entire production diff (5 lines, 2 of them
the literals). Beyond CI, a directory of N agents now evaluates one shared
ADK runtime instead of N private copies, so
adk web/adk runstartuptime and resident memory stop scaling linearly with agent count.
tests/integration/app_loader/app_loader_test.ts— drop the hard-coded40 s third argument from the four hooks that run
npm installor tear downits
node_modulestree, so they inherit the project's deliberate 120 shookTimeout. Everyit()'sTEST_EXECUTION_TIMEOUTis untouched, andTEST_EXECUTION_TIMEOUTis still referenced by the five per-test budgets.Why not the alternatives (all measured):
TEST_EXECUTION_TIMEOUTminifypackages: 'external'wholesaleadk deployships as a single copied file. Externalising only the two ADK packages is the minimal change that removes the duplication.preloadAgents()lazylistApps()must load to tell an App from a BaseAgent), changes/list-appssemantics, and breakscopyAgentFiles(), which callsgetFilePath()right afterlistAgents().Deliberately NOT in this PR. Both were in the original plan for this fix and
were cut during review. Each is a real change, but neither is needed to fix the
flake:
tests/integration/app_loader/discovery/package.jsonand itsnpm install. That fixture's install is slow (606 packages / ~423 MB /31,072 files, ~72 s cold) and the fixture never spawns a child process, so on
paper it is removable. But it is also the only fixture in this suite with a
project-local
node_modulesholding a real installed@google/adk— whichis exactly the resolution path this PR's
externalmarking now depends on,and the only realistic user layout (
getProjectNodeModulesDirlooks besidethe nearest
package.json; without it the walk reaches the monorepo root,which no user project has). Deleting it would also flip the fixture from CJS
to ESM via the root
"type": "module", losing CJS integration coverage in thesame PR that changes how the artifact resolves its imports. Keeping it means
the new assertion below runs against a project-local install producing a
.cjsartifact. The 72 s install is covered by the 120 s hook budget;removing it, with its CJS→ESM coverage trade-off, belongs in its own PR
(Test: drop the npm install from the AgentLoader discovery integration fixture #276). Consequence, stated plainly:
discovery/node_modulesis stillenumerated by
getDirFiles()on every discovery pass, and that is the ~1.9 swhich remains of the original 17.9 s.
fail-fast: falseon the CI OS matrix. The matrix does default tofail-fast, so one flaking leg still cancels the other two, but that is a
repo-wide CI policy affecting every job on every PR and nothing about marking
@google/adkexternal needs it. Fix: stabilize app_loader integration test timeouts and stop matrix fail-fast #235 already carries it as its own change.Breaking change analysis: no exported signature, option or return type
changes.
listApps()still returns["service_alpha","standalone_app"]andlistAgents()still returns the same four names (verified below).isApp()/isBaseAgent()are brand checks onSymbol.for(...), which uses thecross-realm global registry, so identification is unaffected by which copy
constructed the object — and this was already the situation, since today's
bundle embeds its own ADK copy distinct from the caller's.
adk deploy cloud_runstays safe: the deployed artifact now imports@google/adkrather than embedding it, and the generated image provides itthree ways over —
createPackageJson()hard-fails unless the project declaresREQUIRED_NPM_PACKAGES = ['@google/adk']; the generated Dockerfile doesCOPY node_modules /app/node_modulesthenRUN npm install --production; andthe artifact lands in
/app/agents/<appName>/, so Node's upward walk reaches/app/node_modules.Residual risk (low, disclosed):
getProjectNodeModulesDir()picks thenode_modulesbeside the nearest ancestorpackage.json, whereas Node's ownresolution walks every ancestor
node_modules. A layout that hoists@google/adkabove the nearestpackage.jsonand has no localnode_moduleswould now fail at import time instead of at build time. That surfaces as an
immediate, legible
ERR_MODULE_NOT_FOUNDat the existingawait import()site(already inside the
trythat raisesAgentFileLoadingError) rather thansilent misbehaviour, and no fixture or supported layout in this repo exhibits
it.
Collision check (run before any code was written;
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000, 378 open PRs, thengh pr diffon every plausibly adjacent one). Three open PRs overlap and a maintainer should
pick between them rather than merge blindly:
(stacked on Fix: only pass esbuild "external" when bundling is enabled in AgentFile.load() #275) is a broader alternative to change 1: it switches
packagesto'external'wholesale wheneverlinkProjectNodeModulessucceeded, adds an
inlineDependenciesoption so deploy artifacts stayself-contained, and adds an inline-rebuild fallback. This PR is the minimal
variant — two literals, no new option, no new code path. Not stacked on
Perf: externalize third-party packages when compiling agent files (~54x faster AgentFile.load) #285 deliberately: on top of it these two literals are a no-op, so the two
are alternatives, not increments.
several suites), and Test: drop the npm install from the AgentLoader discovery integration fixture #276 carries the discovery-fixture removal this PR
deliberately leaves out. Either landing first leaves the corresponding part of
this PR empty.
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.
New tests (both added alongside the existing ones; no existing test was edited,
skipped, weakened or deleted, and no fixture was changed):
dev/test/utils/agent_loader_test.ts— "marks the ADK packages external soeach agent does not embed a copy". Follows the sibling test's pattern of
inspecting
(esbuild.build as Mock).mock.calls[0][0].npx vitest run --project unit:dev dev/test/utils/agent_loader_test.ts→31 passed (30 before, all unmodified).
tests/integration/app_loader/app_loader_test.ts— "compiles an agentwithout embedding the ADK runtime". Pins the fix behaviourally rather than by
timing (a duration assertion would itself be flaky): the compiled artifact
must be under 64 KB. It runs against the unchanged
discoveryfixture, i.e. areal project-local
npm installof@google/adkproducing a.cjsartifact — the layout and module format a user project actually has. The
margin is three orders of magnitude — 981 bytes with the fix, 5,902,465 bytes
without — so the threshold is not fragile.
Mutation proof — each new test was run against the unfixed code and confirmed
to FAIL. The mutation was deleting the two
externalentries fromdev/src/utils/agent_loader.ts:AssertionError: expected [ 'sqlite3', 'better-sqlite3', …(11) ] to deeply equal ArrayContaining{…}AssertionError: expected 5902465 to be less than 65536The production diff appends two literals to an existing array and introduces no
new statement or branch, so new-line/branch coverage is satisfied by
construction and the repo-wide
coverage.thresholdscannot move.Full-file regression run (the gate that would catch a bad externalisation —
it exercises
adk runin a real child process, which executesdev/dist, sonpm run buildis mandatory after the source edit):All 7 pass, from clean fixtures:
app_ts/app_js/app_defaultCLI (real child process)should discover apps vs agents…should load App from directory entrypoint…should synthesize App when loadApp()…compiles an agent without embedding the ADK runtime(new)Also run:
npm run build(OK),npm run lint(clean),npm run format:check(clean).
Two honest notes about neighbouring checks:
npx vitest run --project integration tests/integration/agent_loader/agent_dirname_test.tsfails with
Hook timed out in 40000ms— and fails identically on theunmodified base commit (verified by stashing this diff and re-running). It
is the same hard-coded 40 s cap on an
npm installhook that this PR removesfrom
app_loader_test.ts; that file is left alone here to keep the difffocused, and Fix: align integration install hooks on the project-wide hook timeout #405 already covers it. Nothing in this diff can affect an
npm install.npm run ts:checkreports 281 pre-existingtscerrors — exactly 281 onthe unmodified base too (verified the same way).
ts:checkis not part ofthe
validationworkflow, which runs build,test:coverageand lint.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
with its own
npm installin place —listApps()returned["service_alpha","standalone_app"]andlistAgents()returned["service_alpha","service_beta","standalone_agent","standalone_app"],identical to before the change.
(
adk_agent_loader/<uuid>/). It isapp.cjs, 981 bytes, its directorylisting is
[ 'app.cjs', 'node_modules' ]withnode_modulesa symlink tothe fixture's own installed tree, and the body contains a literal
require("@google/adk")— i.e. it imports the runtime from the project'sreal install instead of inlining it.
adk web—node dev/dist/esm/cli_entrypoint.js web tests/integration/app_loader/discovery --port 8791, thencurl 'http://[::1]:8791/list-apps'returned["service_alpha","service_beta","standalone_agent","standalone_app"]in105 ms.
adk runchild-process path — covered by the three CLI fixtures above;additionally
npx @google/adk-devtools run agent.tsintests/integration/agent_loader/import_meta_urlansweredI'm stubby model response!, confirming__dirname/import.meta.urlrewriting still works with the ADK externalised.
npm run lint && npm run format:check— both clean.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.