Skip to content

feat: Add environment tools matching Python parity - #555

Open
AmaadMartin wants to merge 5 commits into
google:mainfrom
AmaadMartin:feat/js-environment-tools
Open

feat: Add environment tools matching Python parity#555
AmaadMartin wants to merge 5 commits into
google:mainfrom
AmaadMartin:feat/js-environment-tools

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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 — no existing issue tracks this work.

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

Problem:
The JS variant of ADK lacks parity with the adk-python environment tools, missing
necessary functionality for agent local file manipulation and shell execution.

Solution:
Added Node.js parity environment tools (ExecuteTool, ReadFileTool,
WriteFileTool, EditFileTool) and a bundled EnvironmentToolset in
core/src/tools/environment/. The toolset also injects the
ENVIRONMENT_INSTRUCTION into the LLM system requests for bounded contextual
usage.

One deliberate deviation from adk-python, please read:
ExecuteTool requires an explicit tool confirmation before it runs a command.
adk-python's execute tool has no such gate, but it also does not run anything
itself: it delegates to BaseEnvironment.execute(), so "run on the host" is one
backend an embedder selects. This port collapses that seam into a hardcoded
child_process implementation behind workingDir: string, which makes host
execution the only option — a model-authored string becomes arbitrary code
execution on the machine running the agent, and cwd is not a boundary. The gate
uses the same mechanism RunSkillInlineScriptTool already uses on main for
model-provided scripts. Restoring the BaseEnvironment seam would be the better
long-term answer and is a reasonable follow-up.

All four tool classes carry @experimental, matching adk-python and reflecting
that they became public API here via core/src/common.ts and core/src/index.ts.
Each tool exports its result type (ExecuteResult, ReadFileResult,
WriteFileResult, EditFileResult) rather than returning the base class's
Promise<unknown>.

There are no any, as any, eslint-disable, @ts-ignore or @ts-expect-error
uses anywhere in this diff, in src/ or in the tests.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Added core/test/tools/environment/tools_test.ts, covering boundaries across
reading, writing, editing and shell execution, plus the confirmation gate and
literal-text matching in EditFile. Local run:

$ npx vitest run --project unit:core core/test/tools/environment/tools_test.ts

 ✓ |unit:core| core/test/tools/environment/tools_test.ts (32 tests) 216ms

 Test Files  1 passed (1)
      Tests  32 passed (32)

Manual End-to-End (E2E) Tests:

  1. Build the workspace with npm run build.
  2. Run npx vitest run --project unit:core core/test/tools/environment/tools_test.ts
    to exercise the tools against a temporary working directory.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

resolveAndValidatePath performs a lexical containment check. It rejects ..
and absolute-path arguments, but path.resolve/path.relative never touch the
filesystem, so a symlink inside workingDir that points elsewhere still resolves
through. That is intentional — reading through symlinks is necessary for real
workspace layouts such as the package links npm creates under node_modules — and
the docstring says so explicitly. It is not a sandbox; callers who need one should
point the tools at an isolated filesystem.

This branch also carries one small, independent fix commit:
fix(dev): output agent bundle in project .adk_build_cache for hoisted dependencies,
which changes dev/src/utils/agent_loader.ts to emit the compiled agent bundle into
a project-local .adk_build_cache directory instead of the system temp directory, so
that hoisted node_modules dependencies resolve correctly. Happy to split this into a
separate PR if reviewers prefer.

@AmaadMartin
AmaadMartin force-pushed the feat/js-environment-tools branch 2 times, most recently from 5e87d4d to 4124503 Compare July 28, 2026 21:18
Amaad Martin added 3 commits July 28, 2026 18:53
Correctness:
- escapeRegExp escaped nothing: the character class closed early, so
  old_string was matched as a live regex. Metacharacters could match the
  wrong bytes and an unbalanced group threw out of runAsync instead of
  returning the {status: 'error'} contract.
- EditFile expanded $&, $` and $1 in new_string. Use a function
  replacement so the text is inserted literally.
- The truncation notice reported the original length as if it were the
  limit ("truncated to 10 chars" for 5 characters of content).

Types (no suppressions anywhere in the diff):
- Each tool declares and exports its result type (ExecuteResult,
  ReadFileResult, WriteFileResult, EditFileResult), narrowing the
  Promise<unknown> from BaseTool. Callers and tests are typed with no
  annotations.
- catch blocks bind unknown and narrow through toError /
  isFileNotFoundError instead of `catch (e: any)`; the exec failure path
  uses child_process.ExecException, which already declares killed, code,
  stdout and stderr.
- isValidLineNumber is a module-level type predicate, replacing an inline
  closure, a dead !isNaN check and two unchecked casts on unknown.

Security:
- Execute now passes through the standard tool-confirmation gate before
  it shells out, matching how RunSkillInlineScriptTool gates
  model-provided code. workingDir is not a boundary for a shell command,
  and these tools are public API.

Also: @experimental on all four tool classes (they are public API and
adk-python decorates all four), drop the leading underscore from the
description constant, and document that path containment is lexical and
does not resolve symlinks.
@AmaadMartin
AmaadMartin force-pushed the feat/js-environment-tools branch from 4124503 to c3360f4 Compare July 29, 2026 02:07

@kalenkevich kalenkevich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also add integration tests that will create/modify and execute real temp files

Comment thread core/src/tools/environment/utils.ts Outdated
* @param relativeOrAbsolutePath The path to check.
* @return The resolved absolute path.
*/
export function resolveAndValidatePath(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to file utils

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — resolveAndValidatePath now lives in core/src/utils/file_utils.ts alongside materializeFiles / guessMimeType, and core/src/tools/environment/utils.ts keeps only the non-filesystem helpers (truncate, toError, isFileNotFoundError).

ReadFileTool, WriteFileTool and EditFileTool import it from ../../utils/file_utils.js. Pure move: no behaviour change, no signature change, and the existing traversal test cases are unchanged (only the import path moved). It stays internal — file_utils.ts is not re-exported from index.ts/common.ts, so this does not widen the public surface.

Comment on lines +131 to +134
const {stdout, stderr} = await execAsync(command, {
cwd: this.workingDir,
timeout: this.executeTimeoutMs,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will it work with cmd and powershell?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — I checked it against the implementation instead of assuming, and the answer was split: cmd yes, powershell no. I've fixed the second half.

cmd — yes, and it always has. runAsync goes through promisify(exec), and exec sets options.shell = true, which Node's normalizeSpawnArguments resolves on Windows to process.env.comspec || 'cmd.exe', invoked as cmd.exe /d /s /c "<command>". So every command this tool runs on Windows already goes through cmd. The run-tests (windows-latest) job on this PR is the evidence: core/test/tools/environment/tools_test.ts runs there and its Execute cases (echo test && echo err >&2, echo out && echo err >&2 && exit 10) pass under cmd.exe — that CRLF normalizer in the test file exists precisely because cmd is what runs them.

powershell — no, it was unreachable. Node never picks PowerShell on its own; it needs an explicit shell, and ExecuteToolParams had no way to pass one. So rather than just answering, I fixed it: ExecuteToolParams.shell (and EnvironmentToolsetParams.shell, which forwards it) is now passed to exec. Node special-cases only cmd (/d /s /c, verbatim args); every other shell is spawned as <shell> -c <command>, and -c is PowerShell's documented short form of -Command, so powershell.exe, pwsh, bash and zsh all work. Leaving it unset keeps today's behaviour byte-for-byte.

New tests, each of which fails without the change:

  • POSIX: a stand-in shell script that echoes back $2 proves the configured shell — not /bin/sh — received the command, both on ExecuteTool directly and through EnvironmentToolset. With the forwarding removed both fail with expected 'hello' to be 'fake-shell ran: echo hello'.
  • Windows-only (it.skipIf(!IS_WINDOWS), so they really execute in the windows-latest job): echo %COMSPEC% under the default shell and under an explicit cmd.exe (%VAR% only expands in cmd); Write-Output (3 + 4)7 under powershell.exe (PowerShell syntax cmd cannot run); and exit 3 under powershell.exe surfacing as exit_code: 3.

One caveat worth flagging rather than hiding: ENVIRONMENT_INSTRUCTION tells the model to chain dependent commands with &&, and Windows PowerShell 5.1 has no && operator — it arrived in PowerShell 7. So pwsh is the right choice if you want PowerShell and the chaining guidance to hold; that trade-off is documented on the new option.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up with the actual CI result, since the point of the answer was evidence rather than assertion: powershell.exe is confirmed working, on the run-tests (windows-latest) job for e64640d.

core/test/tools/environment/tools_test.ts reports 37 tests | 2 skipped on Windows against 37 tests | 3 skipped on Ubuntu — the two POSIX-only cases are the ones skipped on Windows, so all three Windows shell cases ran and passed there: default shell → cmd.exe, explicit cmd.exe, and powershell.exe evaluating Write-Output (3 + 4) to 7.

Two notes on the first attempt, which failed on Windows before this:

  • Confirmed from PowerShell's own CLI parser that -c (what Node passes to any non-cmd shell) is the accepted short form of -Command: MatchSwitch(switchKey, "command", "c") in CommandLineParameterParser.cs, while -ConfigurationName needs at least -config, so there is no ambiguity.
  • The ExecuteTool > reports timeout case (pre-existing, unrelated to the shell option) tore down with EBUSY: resource busy or locked, rmdir on Windows: a command killed on timeout can still hold its cwd for a moment after the kill returns. The afterEach now passes maxRetries/retryDelay to fs.rm instead of failing teardown. I also dropped a second PowerShell case I had added — the existing cmd cases already cover nonzero exit codes — to keep the number of heavyweight shell spawns in the unit suite down.

Amaad Martin added 2 commits July 30, 2026 22:18
Addresses review feedback on the environment tools.

resolveAndValidatePath moves from tools/environment/utils.ts to
utils/file_utils.ts, where the rest of the shared filesystem helpers
live. Call sites in ReadFile, WriteFile and EditFile follow; no
behaviour change and every existing test case is kept.

ExecuteTool gains an optional `shell`, forwarded to child_process.exec
and plumbed through EnvironmentToolset. Previously the shell was
whatever exec defaults to -- /bin/sh on POSIX and %ComSpec% (cmd.exe)
on Windows -- so cmd worked but PowerShell was unreachable. Node only
special cases cmd (`/d /s /c`); any other shell is spawned as
`<shell> -c <command>`, which covers pwsh and powershell.exe, whose
`-c` is the short form of `-Command`.

Tests: a stand-in shell script proves the configured shell, not the
platform default, receives the command (both directly and through the
toolset), and Windows-only cases exercise cmd.exe by default, cmd.exe
selected explicitly, and powershell.exe for both success and a nonzero
exit code.
The Windows job hit EBUSY tearing down the temp dir for the timeout
case: a command killed on timeout can still hold its cwd for a moment
after the kill returns. fs.rm now retries instead of failing teardown.

Also drops the second PowerShell case (the cmd path already covers
nonzero exit codes) to keep the number of heavyweight shell spawns
down, and reports the whole tool result in the assertion message so a
shell failure that only reproduces on a CI agent is diagnosable.
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.

2 participants