Fix: build the skill script wrapper from the matched resource key - #634
Open
AmaadMartin wants to merge 3 commits into
Open
Fix: build the skill script wrapper from the matched resource key#634AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
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/.
This was referenced Aug 6, 2026
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.
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
Link to an existing issue (if applicable):
N/A
Or, if no issue exists, describe the change:
Problem:
run_skill_scriptbuilds 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 leadingscripts/and looks the remainder up inskill.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 likescript_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}`, whilebuildWrapperCode()was given the caller's rawscriptPath. For a prefix-less request the emitted wrapper isrequire('./setup.js');and the only matching entry ininputFilesis namedscripts/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, PowerShellObjectNotFound), wrapped intoEXECUTION_ERROR— instead of the tool's own actionableSCRIPT_NOT_FOUND, and at the cost of a full sandbox round trip. All six wrapper languages are affected because every branch interpolatesscriptPathrelative to./.Solution: capture the resource key that actually matched, and derive the wrapper path from it.
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
nameof one of theFiles incodeExecutionInput.inputFiles. That holds by construction —getSkillResourceFiles()emits`scripts/${resourceName}`for every key inresources.scripts, andresourceNamehere is by construction one of those keys. Deriving from the matched key (rather than re-prefixingscriptPath) is also what makes the pathologicalscripts/scripts/x.jscase correct for free: a resource key that itself begins withscripts/resolves via the fallback and canonicalizes toscripts/scripts/x.js, matching itsFile.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-separatorscriptPath.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.getSkillResourceFiles()output,inputFiles,language,args,outputFiles, and everyerrorCodeand error message string are unchanged.SCRIPT_NOT_FOUNDandEXECUTION_ERRORstill echo the caller's originalscriptPath, so the error reflects what was actually asked for.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-lessscript_pathalways 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.pyalready 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 canonicalscripts/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 1000returned 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 insidebuildWrapperCode, #356 only replaces the error-code string literals with an enum. Neither performs anyscript_pathnormalization, and no other open PR does. This branch is based on currentmain; 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 existingMockCodeExecutor/createMockContext()/mockSkillharness is reused; two small separate fixtures were added for the nested andscripts/-prefixed-key shapes somockSkill(and theinputFiles?.lengthassertion that depends on its exact size) is left undisturbed.'setup.js'yieldsrequire('./scripts/setup.js');.'setup.js'and'scripts/setup.js'yield the same wrapper (spelling-agnostic, so it survives the separate separator fix).'scripts/setup.js'and is present ininputFiles.map(f => f.name)— asserting the relationship, not two independent literals.'run.sh'and'scripts/run.sh'both yieldsource ./scripts/run.sh "$@".'sub/util.js'): both spellings yieldrequire('./scripts/sub/util.js');, present ininputFiles.'scripts/nested.js'canonicalizes toscripts/scripts/nested.js, again asserted against an actualinputFilesname.SCRIPT_NOT_FOUNDis still returned for'nope.js'and'scripts/nope.js', each echoing the caller's original spelling, withexecuteCodenever called.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: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: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=v8restricted to the changed file, the only uncovered lines/branches inrun_skill_script_tool.tsare pre-existing and untouched by this PR (the_getDeclarationbody, the registry-error catch, the TypeScript/Python/PowerShell/cmd wrapper branches, and the content-shape fallbacks ingetSkillResourceFiles).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 drivesRunSkillScriptToolthrough a realUnsafeLocalCodeExecutorwithscript_path: 'hello.js'— no mocks, actualnodesubprocess — and asserts the script's stdout. This is the sanity check described above, automated rather than left as prose.With Mutation A applied it fails exactly as the bug predicts — the script never runs at all:
To reproduce by hand instead: load a skill directory containing
scripts/setup.js, invokerun_skill_scriptwithscript_path: 'setup.js'against a real code executor, and confirm the script runs rather than failing withCannot find module './setup.js'.Other local verification on the pushed commit:
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.