Skip to content

Fix: build the skill script wrapper from the matched resource key - #634

Open
AmaadMartin wants to merge 3 commits into
mainfrom
fix/skill-script-wrapper-path-normalization
Open

Fix: build the skill script wrapper from the matched resource key#634
AmaadMartin wants to merge 3 commits into
mainfrom
fix/skill-script-wrapper-path-normalization

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 4, 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):
    N/A

  2. Or, if no issue exists, describe the change:

Problem: run_skill_script builds a wrapper that points at a file it never ships.

RunSkillScriptTool.runAsync() resolves the requested script with a lenient two-step lookup: it strips a leading scripts/ and looks the remainder up in skill.resources.scripts, falling back to the raw path. The skill loaders (loadSkillFromDir, loadSkillFromZipBuffer) key that record by bare relative name ('setup.js', 'sub/util.js'), so a prefix-less request like script_path: 'setup.js' resolves on the first lookup and execution proceeds.

But the file actually handed to the executor is materialized by getSkillResourceFiles() as `scripts/${resourceName}`, while buildWrapperCode() was given the caller's raw scriptPath. For a prefix-less request the emitted wrapper is require('./setup.js'); and the only matching entry in inputFiles is named scripts/setup.js. The wrapper references a path that does not exist in the execution working directory, so the run reaches the sandbox and dies with an opaque module/file-not-found error (Cannot find module './setup.js', No such file or directory, PowerShell ObjectNotFound), wrapped into EXECUTION_ERROR — instead of the tool's own actionable SCRIPT_NOT_FOUND, and at the cost of a full sandbox round trip. All six wrapper languages are affected because every branch interpolates scriptPath relative to ./.

Solution: capture the resource key that actually matched, and derive the wrapper path from it.

const scripts = skill.resources?.scripts;
const relScriptPath = scriptPath.startsWith('scripts/')
  ? scriptPath.substring('scripts/'.length)
  : scriptPath;
// The key that actually matched, so the wrapper path equals the name
// getSkillResourceFiles() materializes the script under.
const resourceName = scripts?.[relScriptPath] ? relScriptPath : scriptPath;
const script = scripts?.[resourceName];

// ...and at the call site:
code: buildWrapperCode(`scripts/${resourceName}`, language),

The two-step lookup collapses into a single key selection, so the source change is +6/-5 with no reassignment.

Invariant this establishes: for any successfully resolved script, the path embedded in the wrapper is exactly equal to the name of one of the Files in codeExecutionInput.inputFiles. That holds by constructiongetSkillResourceFiles() emits `scripts/${resourceName}` for every key in resources.scripts, and resourceName here is by construction one of those keys. Deriving from the matched key (rather than re-prefixing scriptPath) is also what makes the pathological scripts/scripts/x.js case correct for free: a resource key that itself begins with scripts/ resolves via the fallback and canonicalizes to scripts/scripts/x.js, matching its File.name.

Consequences: the wrapper becomes a function of the resolved script rather than of the caller's spelling, so 'setup.js' and 'scripts/setup.js' are indistinguishable downstream of the lookup.

Deliberately not changed (each is in flight separately; touching it here would conflict and muddy both reviews):

  • buildWrapperCode()'s body is byte-for-byte untouched, including the doubled-separator scriptPath.replace(/\//g, '\\\\') on the PowerShell/cmd branches. New assertions in those areas are therefore written spelling-agnostically (equality between the two input spellings) rather than against a hardcoded backslash literal.
  • No error-code enum is introduced.
  • getSkillResourceFiles() output, inputFiles, language, args, outputFiles, and every errorCode and error message string are unchanged. SCRIPT_NOT_FOUND and EXECUTION_ERROR still echo the caller's original scriptPath, so the error reflects what was actually asked for.
  • Language derivation (path.extname) is unaffected by the prefix, so that line is left alone.

Not a breaking change. Prefixed script_path — the documented form, and the form every pre-existing test uses — produces a byte-identical wrapper before and after. Prefix-less script_path always failed at execution time before, so no caller can depend on the old wrapper string; the only observable change is failure -> success.

adk-python parity: this closes a real cross-language divergence. src/google/adk/tools/skill_toolset.py already normalizes before emitting the wrapper in _build_wrapper_code() (if not file_path.startswith("scripts/"): file_path = f"scripts/{file_path}"), materializes scripts under the same prefix, and repeats the normalization on the local-environment path. adk-js was the only side missing it. Where the two conventions could conflict, parity wins for the observable wire value (the canonical scripts/ prefix) and local JS/TS convention wins for internals (naming, module-private helpers).

Collision check (run before implementing): gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 returned 532 open PRs; the plausibly adjacent ones were diffed. #481 and #356 touch the same file but different hunks — #481 changes only the PowerShell/cmd separator inside buildWrapperCode, #356 only replaces the error-code string literals with an enum. Neither performs any script_path normalization, and no other open PR does. This branch is based on current main; no stacking was needed.

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.

Seven new cases were added to core/test/tools/skills/run_skill_script_tool_test.ts; none of the eight pre-existing cases was modified, renamed, or removed (they remain the regression signal for the prefixed path). The existing MockCodeExecutor / createMockContext() / mockSkill harness is reused; two small separate fixtures were added for the nested and scripts/-prefixed-key shapes so mockSkill (and the inputFiles?.length assertion that depends on its exact size) is left undisturbed.

  1. Prefix-less 'setup.js' yields require('./scripts/setup.js');.
  2. 'setup.js' and 'scripts/setup.js' yield the same wrapper (spelling-agnostic, so it survives the separate separator fix).
  3. The path extracted from the wrapper equals 'scripts/setup.js' and is present in inputFiles.map(f => f.name) — asserting the relationship, not two independent literals.
  4. A second language: 'run.sh' and 'scripts/run.sh' both yield source ./scripts/run.sh "$@".
  5. A nested key ('sub/util.js'): both spellings yield require('./scripts/sub/util.js');, present in inputFiles.
  6. The fallback lookup: a resource key literally named 'scripts/nested.js' canonicalizes to scripts/scripts/nested.js, again asserted against an actual inputFiles name.
  7. SCRIPT_NOT_FOUND is still returned for 'nope.js' and 'scripts/nope.js', each echoing the caller's original spelling, with executeCode never called.
npx vitest run --project unit:core core/test/tools/skills/run_skill_script_tool_test.ts
  Tests  15 passed (15)

Proof the tests can fail (mutation testing — coverage alone is not proof):

Mutation A — revert the one-line change, buildWrapperCode(canonicalScriptPath, language) -> buildWrapperCode(scriptPath, language). Cases 1-6 fail:

× builds the canonical wrapper for a script path without the scripts/ prefix
  → expected 'require(\'./setup.js\');' to be 'require(\'./scripts/setup.js\');'
× builds the same wrapper with and without the scripts/ prefix
  → expected 'require(\'./setup.js\');' to be 'require(\'./scripts/setup.js\');'
× references an input file name from the wrapper of a prefix-less script path
  → expected 'setup.js' to be 'scripts/setup.js'
× builds the same shell wrapper with and without the scripts/ prefix
  → expected 'source ./run.sh "$@"' to be 'source ./scripts/run.sh "$@"'
× builds the canonical wrapper for a nested script path in either spelling
  → expected 'require(\'./sub/util.js\');' to be 'require(\'./scripts/sub/util.js\');'
× builds the wrapper from the resource key matched by the fallback lookup
  → expected 'scripts/nested.js' to be 'scripts/scripts/nested.js'
Tests  6 failed | 9 passed (15)

Mutation B — keep the scripts/ prefixing but derive it from the stripped request rather than the matched key (buildWrapperCode(`scripts/${relScriptPath}`, …)), leaving the lookup itself intact. This isolates the "prefix the matched key" half of the fix, and only case 6 fails — exactly what it pins:

× builds the wrapper from the resource key matched by the fallback lookup
  → expected 'scripts/nested.js' to be 'scripts/scripts/nested.js'
Tests  1 failed | 14 passed (15)

Case 7 (SCRIPT_NOT_FOUND) passes both before and after the fix — it is a regression guard on the unchanged error strings and the "do not over-normalize into a false positive" side of the change, not a bug pin, and is not presented as proof.

Coverage: every line and branch added by this change is executed (cases 1-5 take the first-lookup path, case 6 takes the fallback path). Measured with --coverage.provider=v8 restricted to the changed file, the only uncovered lines/branches in run_skill_script_tool.ts are pre-existing and untouched by this PR (the _getDeclaration body, the registry-error catch, the TypeScript/Python/PowerShell/cmd wrapper branches, and the content-shape fallbacks in getSkillResourceFiles).

Manual End-to-End (E2E) Tests:

An automated real-executor test was added to tests/integration/tools/run_skill_script_tool_test.ts (one new case, inserted next to the existing prefixed-JS case; nothing else in that file was changed). It drives RunSkillScriptTool through a real UnsafeLocalCodeExecutor with script_path: 'hello.js' — no mocks, actual node subprocess — and asserts the script's stdout. This is the sanity check described above, automated rather than left as prose.

npx vitest run --project integration tests/integration/tools/run_skill_script_tool_test.ts
  Tests  9 passed | 4 skipped (13)     # 4 skipped = Windows-only PowerShell/cmd cases on Linux

With Mutation A applied it fails exactly as the bug predicts — the script never runs at all:

× successfully executes a real JavaScript skill script requested without the scripts/ prefix
  → expected '' to contain 'hello from skill js'

To reproduce by hand instead: load a skill directory containing scripts/setup.js, invoke run_skill_script with script_path: 'setup.js' against a real code executor, and confirm the script runs rather than failing with Cannot find module './setup.js'.

Other local verification on the pushed commit:

npx eslint <the three changed files>          # clean
npx prettier --check <the three changed files> # clean
npm run lint                                   # clean
npm run format:check                           # clean
npm run build                                  # succeeds
npm run ts:check                               # 281 errors, identical count with and
                                               # without this branch's changes; all
                                               # pre-existing (e.g. the `as File` cast at
                                               # run_skill_script_tool_test.ts:209 on main).
                                               # None are attributable to this diff.

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 4, 2026 05:25
A script_path without the scripts/ prefix resolves through the tool's
lenient lookup, but the wrapper was built from the caller's raw spelling
while getSkillResourceFiles materializes every script as
scripts/<name>. The emitted wrapper therefore referenced a path absent
from inputFiles and the run died inside the sandbox with an opaque
module-not-found error.

Capture the resource key that actually matched and derive the wrapper
path from it, so the path is by construction equal to a File name handed
to the executor. Mirrors _build_wrapper_code in adk-python's
src/google/adk/tools/skill_toolset.py.
Pins the failure end-to-end: before the fix the wrapper required
./hello.js while only scripts/hello.js was materialized, so the script
never ran and stdout was empty.
Collapses the two-step lookup and the deferred canonical-path const into
a single key selection, and inlines the wrapper path at its only use.
Behaviour is unchanged, including the fallback for a resource key that
itself begins with scripts/.
AmaadMartin pushed a commit that referenced this pull request Aug 12, 2026
…oc snippets (#634)

* docs(workflow): add runnable ports of the graph-workflow doc snippets

The Python snippets on https://adk.dev/graphs/ have no TypeScript counterpart,
and they are fragments: they reference helpers they never define (`condition()`,
`task_A_node`, …), so they cannot be run as written even in Python. A TS reader
has nothing to copy from and no way to check that the concept behaves the way
the page claims.

Adds 26 runnable ports, one directory per snippet, grouped by the docs page it
comes from so a directory maps 1:1 to a section anchor on adk.dev:

  graphs/         get_started, process_pipeline
  routes/         sequence, branches, function_node, fan_out_join,
                  loop_escalation, nested_workflow
  data_handling/  node_output, routing_output, schemas, session_state,
                  structured_access, structured_output, user_message
  dynamic/        get_started, nodes, custom_run_ids, data_handling,
                  human_input, loop_route, parallel_route, sequence_route
  human_input/    get_started, initial_prompt, payload_and_schema

Each fills in the undefined helpers with the smallest plausible implementation
and says so in its header. Where TypeScript genuinely diverges from the Python
API the file comments say why, so a reader porting from the docs is not left
guessing — for example Python's `Event(message=...)` has no TS equivalent, and
a graph's validating schema belongs on the node wrapping an agent rather than
on the agent itself.

18 of the 26 run with no API key, which keeps the concepts (routing, loops,
fan-out/join, dynamic dispatch, human-in-the-loop) explorable offline.

* ci(samples): type-check samples/ in CI

samples/ is not an npm workspace, so "npm run build" never compiled it, and
the lint job uses tseslint's non-type-aware recommended config. That left the
sample sources backing the docs pages with nothing in CI that would catch a
renamed type or a removed export as the @experimental workflow API moves.

Add samples/tsconfig.json (the same extends-the-root pattern core, dev and
integrations use), a "ts:check:samples" script, and a validation.yaml step
that runs it after the build. Scoped to samples rather than the existing
repo-wide "ts:check", which currently reports 288 pre-existing errors across
44 test files.

* docs(workflow): correct two wrong claims in the sample comments

Both were review findings, and both were wrong about the framework rather
than about the samples.

The dynamic HITL sample said the `rerun_on_resume=False` handoff -- "do not
re-run on resume; complete with the human's reply as my output" -- was
implemented for static graph nodes only, so its leaf used a re-entry form
instead: a stable `interruptId` plus a `ctx.resumeInputs[id]` lookup that
returns the reply on the second pass. #635 added that handoff for dynamic
`ctx.runNode` children (`dynamic_node_scheduler.ts:134`, `resumeHandoff`), so
the claim went stale in the same branch that now carries the sample. The leaf
is the doc's `rerun_on_resume=False` one-liner again, which is both the
faithful port and four fewer concepts to explain.

The node_output sample cautioned that a node may emit only ONE event carrying
`output`. Nothing enforces that: `node_runner.ts:234` assigns
`child.output = event.output` for every event, so the last one silently wins
and the successor never sees the rest. That is worth stating precisely,
because the Python page gives two accounts and neither is what happens here --
each `yield` "adds to a list of data objects on the Event" under Node output,
and two yields carrying `Event.output` are "a runtime error" under the
structured-data caution. Recorded as a Python-to-TypeScript difference in the
README rather than only in the sample.

Verified both by running them, not by reading: a node yielding two `output`
events hands the successor the second and raises nothing, and the reworked
HITL leaf pauses on turn 1 and resolves "yes" to "Approved" on turn 2.

* docs(workflow): stop coercing inputs that are already typed as strings

Review finding: the samples were split on how they treat the workflow input.
Eleven files wrapped it in `String(...)`; eight called `.trim()` or
`.toUpperCase()` straight on a parameter already declared `string`.

`extractWorkflowInput` (`workflow_agent.ts:187`) returns the message text for a
text-only turn and the raw `Content` for anything else, so neither form is
sound for a non-text turn -- but they fail differently. `String()` turns a
`Content` into `"[object Object]"` and carries it happily through the graph;
the bare call throws where the mistake is. Keep the one that fails loudly, and
say so in the README so a reader copying a sample knows what it assumes.

Coercion stays where the value genuinely is untyped: `ctx.runNode(...).output`
and a `ctx.resumeInputs[id]` reply are both `unknown`, and the samples that
read them keep converting explicitly at the point of use.

* test(workflow): execute the docs samples instead of only compiling them

Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all
read `samples/`, so a syntax, style, license or type error in these 26 files
fails CI. Nothing ran them, which left the failure they are most exposed to
uncovered: a `WorkflowAgent` validates its graph in its constructor, so a
rename or a semantics change in the `@experimental` workflow API can turn a
sample into a load-time error that still type-checks -- and #635, #637 and
#647 all moved that API while this branch was open.

Every sample is now constructed, and the 18 that call no model are also run
end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness
in `offline` mode, which installs the record/replay model over an empty
response set, so an "offline" sample that starts calling a model throws rather
than reaching the network. The 8 model-backed samples are constructed only:
driving them means a checked-in fixture each, and what they add over the
sibling `tests/integration/workflows/` set is prompt wording, not graph shape.

One table drives it, and a guard test asserts the table matches the
directories on disk -- otherwise a new sample silently gets no coverage, which
is the hole this closes.

Checked against all three failures it is meant to catch, rather than assuming
a passing suite means a working one: a duplicate node name (constructor
validation) fails the sample's case, an unregistered new directory fails the
guard, and an LlmAgent spliced into an offline graph fails on the missing
fixture.

* docs(workflow): restore the state-based counter now that #636 fixed the read

The third stale claim of this review, and the same shape as the other two: a
sample working around a framework bug that has since been fixed on main.

The session_state port carried a "do not read-modify-write ONE key from several
nodes" gotcha, and routed `attempts` along the edges as node output to avoid
it. #636 landed that fix — node reads are now served from a per-invocation
write overlay — so the warning describes a bug that no longer exists and the
workaround is no longer buying anything.

`attempts` goes back to being a state key that one node initializes, another
increments and a third reads, which is what the Python snippet does and what
its inline comment claims it prints. Confirmed against both sides of the fix
rather than assuming: reverting #636's `node_context.ts` makes the third node
read 0, and with it in place the sample prints `attempts state: 1` — the
snippet's own documented output.

Drops the README gotcha section with it, and keeps the surviving half of the
advice — prefer an edge when only the next node needs the value — as guidance
in the sample rather than as a warning about a defect.

* ci(samples): keep samples resolving @google/adk through node_modules

The samples config inherits the root one, so once #648 adds the
`@google/adk` -> `core/src` aliases there, `npm run ts:check:samples`
would start checking the samples against the workspace sources instead of
the published types — the one thing a sample should not do, since a user's
project resolves the package through `node_modules`.

`"paths": {}` pins that, the same reset `core`, `dev` and `integrations`
already carry. No-op against the root config as it stands today: the check
resolves to `core/dist/types/index.d.ts` and passes either way.

* ci(samples): keep the repo-wide type check out of samples/

Fallout from rebasing onto #648, which landed the repo-wide `ts:check` while
this branch was open. The root config names no `include` and excludes only
`node_modules` and `**/dist`, so `tsc --noEmit` now picks up all 26 sample
files — and resolves their `@google/adk` imports through the root `paths`
aliases, against `core/src`.

That is the one resolution a sample must not use, which is the whole point of
the `"paths": {}` reset in `samples/tsconfig.json`: a sample is a consumer of
the published package, so it has to resolve the way a user's project does,
through `node_modules` and against the built types. With both checks running,
the scoped one did that and the repo-wide one quietly did the opposite over the
same files.

Excluding `samples` from the root config leaves one owner. Verified on both
sides: `tsc --noEmit --listFiles` now reports 0 files under `samples/` and
still passes, while `tsc -p samples --listFiles` reports all 26 and resolves
`@google/adk` to `core/dist/types/index.d.ts`.

The `validation.yaml` collision #648 was warned about resolved as both steps,
not one: `ts:check` for the repo, `ts:check:samples` for the samples. The
zizmor hardening on that file (`permissions`, `persist-credentials`, the three
SHA pins) came in with #648, so that commit dropped out of this branch as
already upstream.
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