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
18 changes: 18 additions & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,30 @@ import (
"log/slog"
"os"

"github.com/ludusrusso/wildgecu/pkg/agent/tools"
"github.com/ludusrusso/wildgecu/pkg/daemon"
"github.com/ludusrusso/wildgecu/x/config"
"github.com/ludusrusso/wildgecu/x/setup"

"github.com/spf13/cobra"
)

// buildToolsConfig translates the YAML tools section into the runtime
// tools.Config consumed by the agent.
func buildToolsConfig(in config.ToolsConfig) tools.Config {
return tools.Config{
Search: tools.SearchConfig{
MaxResults: in.Grep.MaxResults,
MaxFileSizeBytes: in.Grep.MaxFileSizeBytes,
},
Exec: tools.ExecConfig{
MaxTimeoutSeconds: in.Bash.MaxTimeoutSeconds,
HeadBytes: in.Bash.HeadBytes,
TailBytes: in.Bash.TailBytes,
},
}
}

func init() {
cmd := startCmd()
rootCmd.AddCommand(cmd)
Expand Down Expand Up @@ -85,5 +102,6 @@ func runDaemon() error {
Container: newContainer(),
ProviderNames: providerNames,
ModelAliases: appConfig.Models,
Tools: buildToolsConfig(appConfig.Tools),
})
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
Expand Down
23 changes: 20 additions & 3 deletions pkg/agent/CODE_AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,35 @@ You have dedicated file tools. **Always prefer them over bash for file I/O:**
- **`read_file`** — Read file content with line numbers. Always read a file before modifying it.
- **`write_file`** — Write full file content. Use for new files or complete rewrites.
- **`update_file`** — Replace an exact string in a file. Preferred for targeted edits — the old_string must be unique in the file. Always `read_file` first.
- **`multi_edit`** — Apply an ordered, atomic batch of `{old_string, new_string, replace_all?}` edits to a single file in one call. Either every edit applies or nothing changes on disk. Edits run sequentially so a later edit can target text produced by an earlier one. Prefer this over multiple `update_file` calls when editing the same file two or more times.

**Do NOT use bash for**: `cat`, `head`, `tail`, `ls`, `find`, `echo >`, or any file read/write operation.

## Bash
## Search

Use `bash` only for running commands: build, test, git, install, compile, lint — anything that is not file I/O. Bash runs in the working directory `{CWD}`.
You have dedicated search tools. **Prefer them over shelling out to `grep`/`rg`/`find`, and over recursing through `list_files` calls:**

- **`grep`** — Search file contents by regex across the workspace. Supports `path` to scope to a subtree, `glob` for filename filters (e.g. `*.go`), `case_insensitive`, `head_limit`, and three output modes:
- `content` (default) returns `{path, line, text}` entries.
- `files_with_matches` returns just the matching paths.
- `count` returns per-file match counts.
- **`glob`** — Find files by doublestar path pattern (e.g. `**/*_test.go`, `pkg/**/agent.go`). Supports `path` to scope to a subtree, `sort` (`mtime_desc` default — most recently modified first; `lex` for lexicographic), and `max_results` (default 1000). Prefer this over recursive `list_files`.

Both tools skip `.git`, `node_modules`, `vendor`, `dist`, `build`, `target`, and (for `grep`) binary files automatically.

## Bash and node

Use `bash` only for running commands: build, test, git, install, compile, lint — anything that is not file I/O. Bash runs in the working directory `{CWD}`. Use `node` to execute Node.js scripts in the same working directory.

Both tools accept a `timeout_seconds` arg to give long-running commands more time. Default is 30s; the hard cap is 600s (10 minutes). Asking for more than the cap returns an error rather than being silently clamped. When a command exceeds its budget the result has `timed_out: true` and `exit_code: -1`.

Stdout and stderr are each captured through a head+tail window. When output overflows, the result includes a `[... truncated N bytes from middle ...]` marker between the head and the tail (so the final lines of a build — which usually contain the actual error — survive). The full byte counts are reported in `stdout_total_bytes` and `stderr_total_bytes`.

## Workflow

1. Use `list_files` to understand the project structure before making changes.
2. Use `read_file` to understand existing code before editing.
3. Use `update_file` for targeted edits, or `write_file` for new files / complete rewrites.
3. Use `update_file` for a single targeted edit, `multi_edit` for two-or-more edits to the same file, or `write_file` for new files / complete rewrites.
4. Use `bash` to build, test, or run commands to verify your changes.

## Inform User
Expand Down
8 changes: 6 additions & 2 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Config struct {
ResolveProvider tools.ProviderResolver // nil when model override is not supported
MemoryModel string // optional alias/ref for the memory agent; empty = use Provider
ModelsInfo *tools.ModelInfo // nil when model info is not available
Tools tools.Config // tool configuration; zero values pick sensible defaults
Debug bool
}

Expand Down Expand Up @@ -55,7 +56,9 @@ func Prepare(ctx context.Context, cfg Config) (*session.Config, *debug.Logger, e
skillsDir := cfg.Home.SkillsDir()
registry := tool.NewRegistry()
registry.Add(tools.GeneralTools())
registry.Add(tools.ExecTools(cfg.Home.Dir()))
registry.Add(tools.ExecTools(cfg.Home.Dir(), cfg.Tools.Exec))
registry.Add(tools.SearchTools(cfg.Home.Dir(), cfg.Tools.Search))
registry.Add(tools.MultiEditTools(cfg.Home.Dir()))
registry.Add(tools.SkillTools(skillsDir))
registry.Add(tools.InformTools())
registry.Add(tools.TelegramTools(cfg.TelegramAuth))
Expand Down Expand Up @@ -139,8 +142,9 @@ func PrepareCode(ctx context.Context, cfg Config, workDir string) (*session.Conf
skillsDir := cfg.Home.SkillsDir()
registry := tool.NewRegistry()
registry.Add(tools.GeneralTools())
registry.Add(tools.ExecTools(workDir))
registry.Add(tools.ExecTools(workDir, cfg.Tools.Exec))
registry.Add(tools.FileTools(workDir))
registry.Add(tools.SearchTools(workDir, cfg.Tools.Search))
registry.Add(tools.SkillTools(skillsDir))
registry.Add(tools.InformTools())
registry.Add(tools.TelegramTools(cfg.TelegramAuth))
Expand Down
9 changes: 9 additions & 0 deletions pkg/agent/tools/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package tools

// Config aggregates per-tool configuration. Zero values pick sensible defaults.
type Config struct {
// Search configures grep (and future search tools).
Search SearchConfig
// Exec configures bash (and node, once #81 lands).
Exec ExecConfig
}
135 changes: 72 additions & 63 deletions pkg/agent/tools/exec.go
Original file line number Diff line number Diff line change
@@ -1,61 +1,80 @@
package tools

import (
"bytes"
"context"
"errors"
"os/exec"
"time"

"github.com/ludusrusso/wildgecu/pkg/exec/bounded"
"github.com/ludusrusso/wildgecu/pkg/provider/tool"
)

// ExecConfig configures the execution tools (bash, node). Zero values fall
// back to the bounded package's defaults.
type ExecConfig struct {
// MaxTimeoutSeconds is the hard ceiling on the per-call
// timeout_seconds argument. Zero = use bounded.DefaultMaxTimeout.
MaxTimeoutSeconds int
// HeadBytes is the size of the captured head window for
// stdout/stderr. Zero = use bounded.DefaultHeadBytes.
HeadBytes int
// TailBytes is the size of the captured tail window for
// stdout/stderr. Zero = use bounded.DefaultTailBytes.
TailBytes int
}

// ExecTools returns execution tools (bash, node) bound to workDir.
func ExecTools(workDir string) []tool.Tool {
return []tool.Tool{newBashTool(workDir), newNodeTool(workDir)}
func ExecTools(workDir string, cfg ExecConfig) []tool.Tool {
return []tool.Tool{newBashTool(workDir, cfg), newNodeTool(workDir, cfg)}
}

// boundedOpts builds bounded.Opts from cfg and a per-call timeout_seconds. A
// zero or negative timeoutSeconds picks the bounded.DefaultTimeout.
func (cfg ExecConfig) boundedOpts(workDir string, timeoutSeconds int) bounded.Opts {
opts := bounded.Opts{
HeadBytes: cfg.HeadBytes,
TailBytes: cfg.TailBytes,
WorkDir: workDir,
}
if cfg.MaxTimeoutSeconds > 0 {
opts.MaxTimeout = time.Duration(cfg.MaxTimeoutSeconds) * time.Second
}
if timeoutSeconds > 0 {
opts.Timeout = time.Duration(timeoutSeconds) * time.Second
}
return opts
}

// --- bash ---

type bashInput struct {
Command string `json:"command" description:"The bash command to execute"`
Command string `json:"command" description:"The bash command to execute"`
TimeoutSeconds int `json:"timeout_seconds,omitempty" description:"Per-call timeout in seconds. Default 30, hard-capped at 600."`
}

type bashOutput struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
TimedOut bool `json:"timed_out,omitempty"`
StdoutTotalBytes int `json:"stdout_total_bytes"`
StderrTotalBytes int `json:"stderr_total_bytes"`
}

func newBashTool(workDir string) tool.Tool {
return tool.NewTool("bash", "Execute a bash command and return its output",
func newBashTool(workDir string, cfg ExecConfig) tool.Tool {
return tool.NewTool("bash",
"Execute a bash command and return its output. Pass timeout_seconds (default 30, hard-capped at 600) to give long-running commands more time.",
func(ctx context.Context, in bashInput) (bashOutput, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "bash", "-c", in.Command)
cmd.Dir = workDir

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()

exitCode := 0
res, err := bounded.Run(ctx, "bash", []string{"-c", in.Command}, cfg.boundedOpts(workDir, in.TimeoutSeconds))
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
return bashOutput{}, err
}
return bashOutput{}, err
}

return bashOutput{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
Stdout: res.Stdout,
Stderr: res.Stderr,
ExitCode: res.ExitCode,
TimedOut: res.TimedOut,
StdoutTotalBytes: res.StdoutTotalBytes,
StderrTotalBytes: res.StderrTotalBytes,
}, nil
},
)
Expand All @@ -64,44 +83,34 @@ func newBashTool(workDir string) tool.Tool {
// --- node ---

type nodeInput struct {
Script string `json:"script" description:"The Node.js script to execute"`
Script string `json:"script" description:"The Node.js script to execute"`
TimeoutSeconds int `json:"timeout_seconds,omitempty" description:"Per-call timeout in seconds. Default 30, hard-capped at 600."`
}

type nodeOutput struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
TimedOut bool `json:"timed_out,omitempty"`
StdoutTotalBytes int `json:"stdout_total_bytes"`
StderrTotalBytes int `json:"stderr_total_bytes"`
}

func newNodeTool(workDir string) tool.Tool {
return tool.NewTool("node", "Execute a Node.js script and return its output",
func newNodeTool(workDir string, cfg ExecConfig) tool.Tool {
return tool.NewTool("node",
"Execute a Node.js script and return its output. Pass timeout_seconds (default 30, hard-capped at 600) to give long-running scripts more time.",
func(ctx context.Context, in nodeInput) (nodeOutput, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "node", "-e", in.Script)
cmd.Dir = workDir

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()

exitCode := 0
res, err := bounded.Run(ctx, "node", []string{"-e", in.Script}, cfg.boundedOpts(workDir, in.TimeoutSeconds))
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
return nodeOutput{}, err
}
return nodeOutput{}, err
}

return nodeOutput{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
Stdout: res.Stdout,
Stderr: res.Stderr,
ExitCode: res.ExitCode,
TimedOut: res.TimedOut,
StdoutTotalBytes: res.StdoutTotalBytes,
StderrTotalBytes: res.StderrTotalBytes,
}, nil
},
)
Expand Down
Loading
Loading