From b138ea3a8fc41dc33abb9fc0372458eda8bb2d66 Mon Sep 17 00:00:00 2001 From: zbl94 Date: Mon, 20 Jul 2026 21:47:18 +0000 Subject: [PATCH] interactions: make WorkDir authoritative for run_command The interactions harness executed run_command relative to the process's ambient working directory, which is not reliably the agent's workspace: with no Cwd argument the command ran wherever the process cwd happened to be, and a model-supplied Cwd (including "/") was honored blindly. As a result tool calls often ran from "/" instead of the configured workspace (#332). Make the workspace directory authoritative in the executor rather than relying on ambient process state: - Add AntigravityInteractionsConfig.WorkDir (defaults from AX_HARNESS_WORKDIR). - run_command now resolves its directory via resolveRunDir: no Cwd -> WorkDir; relative Cwd -> joined under WorkDir; absolute Cwd -> honored as-is. Empty WorkDir preserves the previous process-cwd behavior. - Add WorkspaceSystemInstruction to orient the agent about its working directory so it emits workspace-relative paths, and wire it in cmd/ax. Fixes #332 --- cmd/ax/harness.go | 11 ++- cmd/ax/internal/cliutil/cliutil.go | 15 +++- .../antigravityinteractions.go | 10 +++ .../antigravityinteractions_tools.go | 34 ++++++-- .../antigravityinteractions_tools_test.go | 82 +++++++++++++++++++ .../antigravityinteractions/workspace.go | 37 +++++++++ 6 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 internal/harness/antigravityinteractions/workspace.go diff --git a/cmd/ax/harness.go b/cmd/ax/harness.go index 1edf14db..6ecf3ace 100644 --- a/cmd/ax/harness.go +++ b/cmd/ax/harness.go @@ -177,9 +177,16 @@ func runAntigravityInteractionsHarness(ctx context.Context) error { return err } + // WorkDir is the agent's working directory. It is authoritative for built-in + // env tools (see AntigravityInteractionsConfig.WorkDir) so their execution + // does not depend on the process cwd. Empty falls back to the process cwd. + workDir := os.Getenv("AX_HARNESS_WORKDIR") + cfg := antigravityinteractions.AntigravityInteractionsConfig{ - Agent: agent, - StateDir: stateDir, + Agent: agent, + StateDir: stateDir, + WorkDir: workDir, + SystemInstruction: antigravityinteractions.JoinSystemInstruction(hc.SystemInstruction, antigravityinteractions.WorkspaceSystemInstruction(workDir)), } return antigravityinteractions.Serve(ctx, cfg, harnessHost, harnessPort, harnessReadyzPort) } diff --git a/cmd/ax/internal/cliutil/cliutil.go b/cmd/ax/internal/cliutil/cliutil.go index a5637d34..7a91f008 100644 --- a/cmd/ax/internal/cliutil/cliutil.go +++ b/cmd/ax/internal/cliutil/cliutil.go @@ -126,12 +126,23 @@ func NewControllerFromConfig(ctx context.Context, cfg *Config) (*controller.Cont if sErr != nil { return nil, fmt.Errorf("antigravity-interactions harness: %w", sErr) } + // WorkDir is the agent's working directory, authoritative for built-in env + // tools (see AntigravityInteractionsConfig.WorkDir). Mirrors the substrate + // path (cmd/ax harness): sourced from AX_HARNESS_WORKDIR, empty falls back + // to the process cwd. + workDir := os.Getenv("AX_HARNESS_WORKDIR") // skillsPointer was built once, up front, from the top-level skills - // config (see above). Append it to any configured system instruction. + // config (see above). Append it, plus the workspace pointer, to any + // configured system instruction. + systemInstruction := antigravityinteractions.JoinSystemInstruction( + antigravityinteractions.JoinSystemInstruction(aiCfg.SystemInstruction, skillsPointer), + antigravityinteractions.WorkspaceSystemInstruction(workDir), + ) antigravityInteractionsHarness, err = antigravityinteractions.New(antigravityinteractions.AntigravityInteractionsConfig{ Agent: agent, - SystemInstruction: antigravityinteractions.JoinSystemInstruction(aiCfg.SystemInstruction, skillsPointer), + SystemInstruction: systemInstruction, StateDir: stateDir, + WorkDir: workDir, }) } else { antigravityInteractionsHarness, err = substrate.New(config.AntigravityInteractionsHarnessID, "", "", config.AntigravityInteractionsTemplate, 80) diff --git a/internal/harness/antigravityinteractions/antigravityinteractions.go b/internal/harness/antigravityinteractions/antigravityinteractions.go index 56c9f3fb..ce9cc00d 100644 --- a/internal/harness/antigravityinteractions/antigravityinteractions.go +++ b/internal/harness/antigravityinteractions/antigravityinteractions.go @@ -116,6 +116,13 @@ type AntigravityInteractionsConfig struct { // --- Optional --- + // WorkDir is the working directory the agent operates in. When set, it is the + // authoritative base for built-in env tools: run_command executes there (and + // resolves a relative Cwd against it). This makes tool execution independent + // of the process's ambient cwd. Empty means "use the process cwd" (the + // previous behavior). Defaults from AX_HARNESS_WORKDIR. + WorkDir string + // SystemInstruction, if set, is sent as the interaction's system_instruction // (a free-form system prompt prepended to the agent's own instructions). It // is sent on every turn of the interaction loop so it persists across them. @@ -143,6 +150,9 @@ func (c *AntigravityInteractionsConfig) withDefaults() { if c.MaxTurns == 0 { c.MaxTurns = 100 } + if c.WorkDir == "" { + c.WorkDir = os.Getenv("AX_HARNESS_WORKDIR") + } } // cloudProject returns the Cloud project id from GOOGLE_CLOUD_PROJECT. diff --git a/internal/harness/antigravityinteractions/antigravityinteractions_tools.go b/internal/harness/antigravityinteractions/antigravityinteractions_tools.go index ad4b979f..d3541c0a 100644 --- a/internal/harness/antigravityinteractions/antigravityinteractions_tools.go +++ b/internal/harness/antigravityinteractions/antigravityinteractions_tools.go @@ -44,7 +44,7 @@ func (h *AntigravityInteractionsHarness) executeTool(ctx context.Context, call c case "view_file": return execViewFile(call) case "run_command": - return execRunCommand(ctx, call) + return execRunCommand(ctx, call, h.cfg.WorkDir) case "list_dir", "list_directory": return execListDir(call) case "move": @@ -229,7 +229,7 @@ func applyByteWindow(content string, offset int) string { // command (e.g. `find /`, or `ping` without a count) cannot wedge the harness. const runCommandTimeout = 60 * time.Second -func execRunCommand(ctx context.Context, call capturedToolCall) any { +func execRunCommand(ctx context.Context, call capturedToolCall, workDir string) any { cmdLine := stringArg(call.arguments, "CommandLine") if cmdLine == "" { return map[string]any{"error": "run_command: missing required argument 'CommandLine'"} @@ -240,9 +240,11 @@ func execRunCommand(ctx context.Context, call capturedToolCall) any { defer cancel() cmd := exec.CommandContext(runCtx, "/bin/sh", "-c", cmdLine) - if cwd := stringArg(call.arguments, "Cwd"); cwd != "" { - cmd.Dir = cwd - } + // Resolve the working directory. workDir is authoritative so execution does + // not depend on the process's ambient cwd. A model-supplied Cwd is honored + // relative to workDir if relative, or as-is if absolute; without one, the + // command runs in workDir. + cmd.Dir = resolveRunDir(workDir, stringArg(call.arguments, "Cwd")) out, err := cmd.CombinedOutput() @@ -266,6 +268,28 @@ func execRunCommand(ctx context.Context, call capturedToolCall) any { return map[string]any{"Output": string(out), "ExitCode": exitCode} } +// resolveRunDir picks the directory run_command executes in. +// +// - No cwd arg: run in workDir (or the process cwd if workDir is empty). +// - Absolute cwd: honored as-is (the agent asked for a specific location). +// - Relative cwd: resolved against workDir, keeping the agent scoped to its +// workspace rather than the process's ambient cwd. +// +// Returning "" makes exec use the process's current directory, matching the +// prior behavior when no working directory is configured. +func resolveRunDir(workDir, cwd string) string { + switch { + case cwd == "": + return workDir + case filepath.IsAbs(cwd): + return cwd + case workDir == "": + return cwd + default: + return filepath.Join(workDir, cwd) + } +} + func execListDir(call capturedToolCall) any { dir := stringArg(call.arguments, "DirectoryPath") if dir == "" { diff --git a/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go b/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go index e38f3a36..38bc9ce7 100644 --- a/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go +++ b/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go @@ -15,6 +15,7 @@ package antigravityinteractions import ( + "context" "fmt" "os" "path/filepath" @@ -341,3 +342,84 @@ func TestIntArgOK(t *testing.T) { } } } + +func TestResolveRunDir(t *testing.T) { + cases := []struct { + name string + workDir string + cwd string + want string + }{ + {"no cwd -> workDir", "/workspace", "", "/workspace"}, + {"relative cwd joined to workDir", "/workspace", "sub/dir", "/workspace/sub/dir"}, + {"absolute cwd honored as-is", "/workspace", "/etc", "/etc"}, + {"no workDir, no cwd -> empty (process cwd)", "", "", ""}, + {"no workDir, relative cwd -> cwd", "", "sub", "sub"}, + {"no workDir, absolute cwd -> cwd", "", "/etc", "/etc"}, + } + for _, c := range cases { + if got := resolveRunDir(c.workDir, c.cwd); got != c.want { + t.Errorf("%s: resolveRunDir(%q,%q) = %q, want %q", c.name, c.workDir, c.cwd, got, c.want) + } + } +} + +func TestExecRunCommand_RunsInWorkDir(t *testing.T) { + // Create a workspace with a marker file; `ls` from workDir (no Cwd arg) must + // list it, proving execution is scoped to workDir and not the process cwd. + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "marker.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + t.Run("no cwd runs in workDir", func(t *testing.T) { + res := execRunCommand(context.Background(), + capturedToolCall{arguments: map[string]any{"CommandLine": "ls"}}, + work).(map[string]any) + if code := res["ExitCode"]; code != 0 { + t.Fatalf("ExitCode = %v, want 0 (output: %v)", code, res["Output"]) + } + if out, _ := res["Output"].(string); !strings.Contains(out, "marker.txt") { + t.Errorf("Output = %q, want it to list marker.txt (ran in wrong dir)", out) + } + }) + + t.Run("pwd reports workDir", func(t *testing.T) { + res := execRunCommand(context.Background(), + capturedToolCall{arguments: map[string]any{"CommandLine": "pwd"}}, + work).(map[string]any) + out, _ := res["Output"].(string) + // macOS /tmp is a symlink to /private/tmp, so compare by suffix/resolved. + if got := strings.TrimSpace(out); got != work && !strings.HasSuffix(got, work) { + resolved, _ := filepath.EvalSymlinks(work) + if got != resolved { + t.Errorf("pwd = %q, want %q", got, work) + } + } + }) + + t.Run("relative cwd resolves under workDir", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(work, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "sub", "inner.txt"), []byte("y"), 0o644); err != nil { + t.Fatal(err) + } + res := execRunCommand(context.Background(), + capturedToolCall{arguments: map[string]any{"CommandLine": "ls", "Cwd": "sub"}}, + work).(map[string]any) + if out, _ := res["Output"].(string); !strings.Contains(out, "inner.txt") { + t.Errorf("Output = %q, want it to list inner.txt (relative Cwd not resolved under workDir)", out) + } + }) +} + +func TestWorkspaceSystemInstruction(t *testing.T) { + if got := WorkspaceSystemInstruction(""); got != "" { + t.Errorf("empty workDir = %q, want empty", got) + } + got := WorkspaceSystemInstruction("/workspace") + if !strings.Contains(got, "/workspace") { + t.Errorf("instruction = %q, want it to mention the working directory", got) + } +} diff --git a/internal/harness/antigravityinteractions/workspace.go b/internal/harness/antigravityinteractions/workspace.go new file mode 100644 index 00000000..744f0165 --- /dev/null +++ b/internal/harness/antigravityinteractions/workspace.go @@ -0,0 +1,37 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package antigravityinteractions + +import ( + "fmt" + "strings" +) + +// WorkspaceSystemInstruction builds a system-instruction snippet that orients +// the agent about its working directory. +// +// This complements the executor making WorkDir authoritative (see +// AntigravityInteractionsConfig.WorkDir): the executor guarantees commands run +// in the right place, while this tells the agent so it emits sensible paths +// (relative to the workspace, not the process root "/"). Returns "" when +// workDir is empty. +func WorkspaceSystemInstruction(workDir string) string { + if strings.TrimSpace(workDir) == "" { + return "" + } + return fmt.Sprintf("Your working directory is %s. Run commands and resolve "+ + "relative paths there unless you have a specific reason to use an "+ + "absolute path elsewhere.", workDir) +}