Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion adk/backend/agentkit/code_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,11 @@ try:

# Check for stderr
if result.stderr:
print(f"Error executing command: {{result.stderr}}", file=sys.stderr)
output_parts = []
if result.stdout:
output_parts.append(f"[stdout]:\n{{result.stdout.rstrip()}}")
output_parts.append(f"[stderr]:\n{{result.stderr.rstrip()}}")
print('\n'.join(output_parts), end='')
sys.exit(result.returncode if result.returncode != 0 else 1)

# Print stdout
Expand Down
9 changes: 3 additions & 6 deletions adk/backend/agentkit/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ func (s *SandboxTool) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequ
return nil, fmt.Errorf("failed to parse glob output: %w", err)
}
return files, nil

}

// Write creates file content.
Expand Down Expand Up @@ -582,12 +582,9 @@ func (s *SandboxTool) Execute(ctx context.Context, input *filesystem.ExecuteRequ
return nil, fmt.Errorf("failed to execute command script: %w", err)
}

if exitCode != nil && *exitCode != 0 {
return nil, fmt.Errorf("command exited with non-zero code %d: %s", *exitCode, output)
}

return &filesystem.ExecuteResponse{
Output: output,
Output: output,
ExitCode: exitCode,
}, nil
}

Expand Down
6 changes: 3 additions & 3 deletions adk/backend/agentkit/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,9 @@ func TestArkSandbox_FileSystemMethods(t *testing.T) {
w.WriteHeader(http.StatusOK)
w.Write(createMockResponse(t, false, "command failed", "Error", "1"))
}
_, err := s.Execute(context.Background(), &filesystem.ExecuteRequest{Command: "exit 1"})
require.Error(t, err)
assert.Contains(t, err.Error(), "command exited with non-zero code -1: command failed")
resp, err := s.Execute(context.Background(), &filesystem.ExecuteRequest{Command: "exit 1"})
require.Nil(t, err)
assert.Contains(t, resp.Output, "command failed")
})

t.Run("Execute: RunInBackendGround returns immediately", func(t *testing.T) {
Expand Down
92 changes: 72 additions & 20 deletions adk/backend/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,49 @@ func (s *Local) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteR
return sr, nil
}

func (s *Local) Execute(ctx context.Context, input *filesystem.ExecuteRequest) (result *filesystem.ExecuteResponse, err error) {
if input.Command == "" {
return nil, fmt.Errorf("command is required")
}

if err := s.validateCommand(input.Command); err != nil {
return nil, err
}

cmd := exec.CommandContext(ctx, "/bin/sh", "-c", input.Command)

var stdoutBuf, stderrBuf strings.Builder
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf

exitCode := 0
if err := cmd.Run(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode = exitError.ExitCode()
stdoutStr := stdoutBuf.String()
stderrStr := stderrBuf.String()
parts := []string{fmt.Sprintf("command exited with non-zero code %d", exitCode)}
if stdoutStr != "" {
parts = append(parts, "[stdout]:\n"+strings.TrimSuffix(stdoutStr, ""))
}
if stderrStr != "" {
parts = append(parts, "[stderr]:\n"+strings.TrimSuffix(stderrStr, ""))
}
return &filesystem.ExecuteResponse{
Output: strings.Join(parts, "\n"),
ExitCode: &exitCode,
}, nil
}
return nil, fmt.Errorf("failed to execute command: %w", err)
}

return &filesystem.ExecuteResponse{
Output: stdoutBuf.String(),
ExitCode: &exitCode,
}, nil
}

// initStreamingCmd creates command with stdout and stderr pipes.
func (s *Local) initStreamingCmd(ctx context.Context, command string) (*exec.Cmd, io.ReadCloser, io.ReadCloser, error) {
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
Expand Down Expand Up @@ -512,23 +555,27 @@ func (s *Local) readStderrAsync(stderr io.Reader) (*[]byte, <-chan error) {

// streamStdout streams stdout line by line to the writer.
func (s *Local) streamStdout(ctx context.Context, cmd *exec.Cmd, stdout io.Reader, w *schema.StreamWriter[*filesystem.ExecuteResponse]) (bool, error) {
scanner := bufio.NewScanner(stdout)
reader := bufio.NewReader(stdout)
hasOutput := false

for scanner.Scan() {
hasOutput = true
line := scanner.Text() + "\n"
select {
case <-ctx.Done():
_ = cmd.Process.Kill()
return hasOutput, ctx.Err()
default:
w.Send(&filesystem.ExecuteResponse{Output: line}, nil)
for {
line, err := reader.ReadString('\n')
if line != "" {
hasOutput = true
select {
case <-ctx.Done():
_ = cmd.Process.Kill()
return hasOutput, ctx.Err()
default:
w.Send(&filesystem.ExecuteResponse{Output: line}, nil)
}
}
if err != nil {
if err != io.EOF {
return hasOutput, fmt.Errorf("error reading stdout: %w", err)
}
break
}
}

if err := scanner.Err(); err != nil {
return hasOutput, fmt.Errorf("error reading stdout: %w", err)
}

return hasOutput, nil
Expand All @@ -537,16 +584,21 @@ func (s *Local) streamStdout(ctx context.Context, cmd *exec.Cmd, stdout io.Reade
// handleCmdCompletion handles command completion and sends final response.
func (s *Local) handleCmdCompletion(cmd *exec.Cmd, stderrData *[]byte, hasOutput bool, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
if err := cmd.Wait(); err != nil {
exitCode := 0
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode = exitError.ExitCode()
}
if len(*stderrData) > 0 {
w.Send(nil, fmt.Errorf("command exited with non-zero code %d: %s", exitCode, string(*stderrData)))
exitCode := exitError.ExitCode()
parts := []string{fmt.Sprintf("command exited with non-zero code %d", exitCode)}
if stderrStr := string(*stderrData); stderrStr != "" {
parts = append(parts, "[stderr]:\n"+stderrStr)
}
w.Send(&filesystem.ExecuteResponse{
Output: strings.Join(parts, "\n"),
ExitCode: &exitCode,
}, nil)
return
}
w.Send(nil, fmt.Errorf("command exited with non-zero code %d", exitCode))

w.Send(nil, fmt.Errorf("command failed: %w", err))
return
}

Expand Down
119 changes: 103 additions & 16 deletions adk/backend/local/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -595,21 +595,25 @@ func TestExecuteStreaming(t *testing.T) {
assert.NoError(t, err)

var hasOutput bool
var lastErr error
var lastResp *filesystem.ExecuteResponse
for {
resp, err := sr.Recv()
if err != nil {
lastErr = err
break
}
if resp != nil && resp.Output != "" {
hasOutput = true
if resp != nil {
if resp.Output != "" {
hasOutput = true
}
lastResp = resp
}
}

assert.True(t, hasOutput, "should receive output before error")
assert.Error(t, lastErr, "should receive error when command fails")
assert.Contains(t, lastErr.Error(), "non-zero code")
assert.True(t, hasOutput, "should receive output before final response")
assert.NotNil(t, lastResp)
assert.NotNil(t, lastResp.ExitCode)
assert.Equal(t, 1, *lastResp.ExitCode)
assert.Contains(t, lastResp.Output, "non-zero code")
})

t.Run("ExecuteStreaming with stderr output", func(t *testing.T) {
Expand All @@ -618,23 +622,28 @@ func TestExecuteStreaming(t *testing.T) {
assert.NoError(t, err)

var outputs []string
var lastErr error
var lastResp *filesystem.ExecuteResponse
for {
resp, err := sr.Recv()
if err != nil {
lastErr = err
break
}
if resp != nil && resp.Output != "" {
outputs = append(outputs, strings.TrimSpace(resp.Output))
if resp != nil {
if resp.Output != "" {
outputs = append(outputs, strings.TrimSpace(resp.Output))
}
lastResp = resp
}
}

assert.Len(t, outputs, 1)
assert.Equal(t, "stdout", outputs[0])
assert.Error(t, lastErr)
assert.Contains(t, lastErr.Error(), "stderr")
assert.Contains(t, lastErr.Error(), "non-zero code")
assert.NotNil(t, lastResp)
assert.NotNil(t, lastResp.ExitCode)
assert.Equal(t, 1, *lastResp.ExitCode)
assert.Contains(t, lastResp.Output, "non-zero code")
assert.Contains(t, lastResp.Output, "stderr")
// stdout is streamed separately
assert.True(t, len(outputs) >= 1)
assert.Contains(t, outputs, "stdout")
})

t.Run("ExecuteStreaming with empty command", func(t *testing.T) {
Expand Down Expand Up @@ -777,3 +786,81 @@ func TestExecuteStreaming(t *testing.T) {
assert.Less(t, elapsed, 2*time.Second, "background command should return immediately without waiting")
})
}

func TestExecute(t *testing.T) {
ctx := context.Background()
s, err := NewBackend(ctx, &Config{})
assert.NoError(t, err)

t.Run("simple echo", func(t *testing.T) {
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "echo hello"})
assert.NoError(t, err)
assert.Equal(t, "hello\n", resp.Output)
assert.NotNil(t, resp.ExitCode)
assert.Equal(t, 0, *resp.ExitCode)
})

t.Run("multi-line output", func(t *testing.T) {
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "echo line1 && echo line2 && echo line3"})
assert.NoError(t, err)
assert.Equal(t, "line1\nline2\nline3\n", resp.Output)
assert.Equal(t, 0, *resp.ExitCode)
})

t.Run("empty command", func(t *testing.T) {
_, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: ""})
assert.Error(t, err)
assert.Contains(t, err.Error(), "command is required")
})

t.Run("non-zero exit code", func(t *testing.T) {
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "exit 1"})
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.NotNil(t, resp.ExitCode)
assert.Equal(t, 1, *resp.ExitCode)
assert.Contains(t, resp.Output, "non-zero code")
})

t.Run("non-zero exit code with stderr", func(t *testing.T) {
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "echo fail >&2 && exit 2"})
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.NotNil(t, resp.ExitCode)
assert.Equal(t, 2, *resp.ExitCode)
assert.Contains(t, resp.Output, "non-zero code 2")
assert.Contains(t, resp.Output, "fail")
})

t.Run("command not found", func(t *testing.T) {
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "nonexistent_command_xyz"})
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.NotNil(t, resp.ExitCode)
assert.NotEqual(t, 0, *resp.ExitCode)
})

t.Run("context cancellation", func(t *testing.T) {
cancelCtx, cancel := context.WithCancel(ctx)
cancel()
_, err := s.Execute(cancelCtx, &filesystem.ExecuteRequest{Command: "sleep 10"})
assert.Error(t, err)
})

t.Run("context cancellation during execution", func(t *testing.T) {
cancelCtx, cancel := context.WithCancel(ctx)
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
resp, err := s.Execute(cancelCtx, &filesystem.ExecuteRequest{Command: "sleep 10"})
// Process killed by context cancellation produces an ExitError, returned as response
if err != nil {
// Non-ExitError case is also acceptable
return
}
assert.NotNil(t, resp)
assert.NotNil(t, resp.ExitCode)
assert.NotEqual(t, 0, *resp.ExitCode)
})
}
Loading