first commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// This file implements background bash execution: a BashJobStore holding the
|
||||
// commands launched with run_in_background, and the BashJob state each one
|
||||
// carries. A background job is detached from the turn context (which is canceled
|
||||
// when the turn ends) and runs under its own cancelable context until it exits or
|
||||
// kill_bash stops it. Its combined stdout/stderr accumulates in a buffer that
|
||||
// bash_output drains incrementally, mirroring Claude Code's background shells +
|
||||
// BashOutput/KillShell.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BashJobStatus is the lifecycle state of a background bash job.
|
||||
type BashJobStatus string
|
||||
|
||||
const (
|
||||
// BashRunning means the command is still executing.
|
||||
BashRunning BashJobStatus = "running"
|
||||
// BashExited means the command finished (successfully or not) or was killed.
|
||||
BashExited BashJobStatus = "exited"
|
||||
)
|
||||
|
||||
// BashJob is a single background command: its identity, growing combined output,
|
||||
// and terminal status. All fields are guarded by mu so the running command's
|
||||
// writer, bash_output reads, and kill_bash can touch it concurrently.
|
||||
type BashJob struct {
|
||||
// ID is the stable handle (e.g. "bash_1") bash_output/kill_bash address.
|
||||
ID string
|
||||
// Command is the shell command line, kept for listing/display.
|
||||
Command string
|
||||
// StartedAt is when the command was launched.
|
||||
StartedAt time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
cursor int // bytes of buf already returned by bash_output
|
||||
status BashJobStatus
|
||||
exitCode int
|
||||
errMsg string
|
||||
finished time.Time
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// jobWriter adapts a BashJob to io.Writer so it can be a command's Stdout/Stderr;
|
||||
// each write appends to the job's combined buffer under its lock.
|
||||
type jobWriter struct{ job *BashJob }
|
||||
|
||||
func (w jobWriter) Write(p []byte) (int, error) {
|
||||
w.job.mu.Lock()
|
||||
w.job.buf.Write(p)
|
||||
w.job.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// writer returns an io.Writer that appends to the job's output buffer.
|
||||
func (j *BashJob) writer() jobWriter { return jobWriter{job: j} }
|
||||
|
||||
// finish records the command's terminal state from the error returned by
|
||||
// cmd.Wait (nil = success). It is idempotent-safe to call once per job.
|
||||
func (j *BashJob) finish(exitCode int, errMsg string) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
j.status = BashExited
|
||||
j.exitCode = exitCode
|
||||
j.errMsg = errMsg
|
||||
j.finished = time.Now()
|
||||
}
|
||||
|
||||
// kill cancels the job's context (terminating the process) and marks it exited if
|
||||
// it was still running. It reports whether the job was running when called.
|
||||
func (j *BashJob) kill() bool {
|
||||
j.mu.Lock()
|
||||
running := j.status == BashRunning
|
||||
j.mu.Unlock()
|
||||
if j.cancel != nil {
|
||||
j.cancel()
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
// readNew returns the output accumulated since the last read and advances the
|
||||
// cursor, so successive bash_output calls stream the command's output without
|
||||
// repeating what was already seen.
|
||||
func (j *BashJob) readNew() string {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
all := j.buf.Bytes()
|
||||
if j.cursor > len(all) {
|
||||
j.cursor = len(all)
|
||||
}
|
||||
out := string(all[j.cursor:])
|
||||
j.cursor = len(all)
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshot returns the job's current status fields for reporting without exposing
|
||||
// the mutex-guarded internals.
|
||||
func (j *BashJob) snapshot() (status BashJobStatus, exitCode int, errMsg string) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
return j.status, j.exitCode, j.errMsg
|
||||
}
|
||||
|
||||
// BashJobStore holds a session's background bash jobs. A single store is shared by
|
||||
// the bash, bash_output and kill_bash tools so a job launched by one is visible to
|
||||
// the others. It is safe for concurrent use.
|
||||
type BashJobStore struct {
|
||||
mu sync.Mutex
|
||||
jobs map[string]*BashJob
|
||||
seq int
|
||||
}
|
||||
|
||||
// NewBashJobStore returns an empty store.
|
||||
func NewBashJobStore() *BashJobStore {
|
||||
return &BashJobStore{jobs: map[string]*BashJob{}}
|
||||
}
|
||||
|
||||
// create registers a new running job for command with its cancel func, assigning
|
||||
// a readable sequential id.
|
||||
func (s *BashJobStore) create(command string, cancel context.CancelFunc) *BashJob {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.seq++
|
||||
job := &BashJob{
|
||||
ID: fmt.Sprintf("bash_%d", s.seq),
|
||||
Command: command,
|
||||
StartedAt: time.Now(),
|
||||
status: BashRunning,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.jobs[job.ID] = job
|
||||
return job
|
||||
}
|
||||
|
||||
// Get returns the job with the given id, or (nil, false).
|
||||
func (s *BashJobStore) Get(id string) (*BashJob, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
j, ok := s.jobs[id]
|
||||
return j, ok
|
||||
}
|
||||
|
||||
// List returns the jobs in creation order.
|
||||
func (s *BashJobStore) List() []*BashJob {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]*BashJob, 0, len(s.jobs))
|
||||
for i := 1; i <= s.seq; i++ {
|
||||
if j, ok := s.jobs[fmt.Sprintf("bash_%d", i)]; ok {
|
||||
out = append(out, j)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KillAll cancels every still-running job. It is intended for session shutdown so
|
||||
// background processes are not orphaned.
|
||||
func (s *BashJobStore) KillAll() {
|
||||
for _, j := range s.List() {
|
||||
j.kill()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Tests for background bash execution: launching a detached job, draining its
|
||||
// output incrementally via bash_output, and terminating a long-running job with
|
||||
// kill_bash. These exercise the shared BashJobStore wiring end to end.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runBGTool(t *testing.T, tool agentcore.AgentTool, args map[string]any) (agentcore.AgentToolResult, error) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
return tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
}
|
||||
|
||||
// A background command returns immediately with a bash_id, then bash_output
|
||||
// drains its output and reports it exited.
|
||||
func TestBashBackgroundRunAndOutput(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
jobs := NewBashJobStore()
|
||||
bash := &BashTool{Jobs: jobs}
|
||||
out := &BashOutputTool{Jobs: jobs}
|
||||
|
||||
res, gerr := runBGTool(t, bash, map[string]any{"command": "echo bg-hello", "run_in_background": true})
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected error: %v", gerr)
|
||||
}
|
||||
details, ok := res.Details.(map[string]any)
|
||||
if !ok || details["background"] != true {
|
||||
t.Fatalf("expected background details, got %+v", res.Details)
|
||||
}
|
||||
id, _ := details["bash_id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("no bash_id returned")
|
||||
}
|
||||
|
||||
// Poll bash_output until the job exits and produced its line.
|
||||
var text string
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
r, err := runBGTool(t, out, map[string]any{"bash_id": id})
|
||||
if err != nil {
|
||||
t.Fatalf("bash_output: %v", err)
|
||||
}
|
||||
text += resultText(r)
|
||||
d, _ := r.Details.(map[string]any)
|
||||
if d["status"] == string(BashExited) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(text, "bg-hello") {
|
||||
t.Errorf("output = %q, want to contain bg-hello", text)
|
||||
}
|
||||
if !strings.Contains(text, "exited") {
|
||||
t.Errorf("status never reported exited: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// kill_bash terminates a long-running background job and it stops running.
|
||||
func TestBashBackgroundKill(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
jobs := NewBashJobStore()
|
||||
bash := &BashTool{Jobs: jobs}
|
||||
kill := &BashKillTool{Jobs: jobs}
|
||||
out := &BashOutputTool{Jobs: jobs}
|
||||
|
||||
res, gerr := runBGTool(t, bash, map[string]any{"command": "sleep 30", "run_in_background": true})
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected error: %v", gerr)
|
||||
}
|
||||
id := res.Details.(map[string]any)["bash_id"].(string)
|
||||
|
||||
kr, err := runBGTool(t, kill, map[string]any{"bash_id": id})
|
||||
if err != nil {
|
||||
t.Fatalf("kill_bash: %v", err)
|
||||
}
|
||||
if killed, _ := kr.Details.(map[string]any)["killed"].(bool); !killed {
|
||||
t.Errorf("expected killed=true, got %+v", kr.Details)
|
||||
}
|
||||
|
||||
// After the kill, the job should report exited within a short window.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
r, _ := runBGTool(t, out, map[string]any{"bash_id": id})
|
||||
if r.Details.(map[string]any)["status"] == string(BashExited) {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Errorf("job still running after kill")
|
||||
}
|
||||
|
||||
// bash_output and kill_bash report a clear error for an unknown id.
|
||||
func TestBashControlUnknownID(t *testing.T) {
|
||||
jobs := NewBashJobStore()
|
||||
out := &BashOutputTool{Jobs: jobs}
|
||||
kill := &BashKillTool{Jobs: jobs}
|
||||
|
||||
r, _ := runBGTool(t, out, map[string]any{"bash_id": "bash_99"})
|
||||
if !strings.Contains(resultText(r), "no background command") {
|
||||
t.Errorf("bash_output on unknown id should error, got %q", resultText(r))
|
||||
}
|
||||
r, _ = runBGTool(t, kill, map[string]any{"bash_id": "bash_99"})
|
||||
if !strings.Contains(resultText(r), "no background command") {
|
||||
t.Errorf("kill_bash on unknown id should error, got %q", resultText(r))
|
||||
}
|
||||
}
|
||||
|
||||
// run_in_background without a store wired reports it is unavailable.
|
||||
func TestBashBackgroundNoStore(t *testing.T) {
|
||||
bash := &BashTool{}
|
||||
r, err := runBGTool(t, bash, map[string]any{"command": "echo x", "run_in_background": true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(resultText(r), "not available") {
|
||||
t.Errorf("expected unavailable message when no store is wired, got %q", resultText(r))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// This file implements the two companion tools for background bash jobs:
|
||||
// bash_output drains a job's new output and reports its status, and kill_bash
|
||||
// terminates a running job. Both address a job by the bash_id returned from a
|
||||
// `bash` call with run_in_background=true, sharing the same BashJobStore so a
|
||||
// job launched by the bash tool is visible here. This mirrors Claude Code's
|
||||
// BashOutput/KillShell tools.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// BashOutputTool reads the output a background job has produced since the last
|
||||
// read and reports whether it is still running or has exited. Jobs is the shared
|
||||
// store the bash tool populates.
|
||||
type BashOutputTool struct {
|
||||
Jobs *BashJobStore
|
||||
}
|
||||
|
||||
// bashOutputArgs is the decoded argument shape for BashOutputTool.
|
||||
type bashOutputArgs struct {
|
||||
// BashID is the job handle returned by a background `bash` call.
|
||||
BashID string `json:"bash_id"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *BashOutputTool) Name() string { return "bash_output" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *BashOutputTool) Description() string {
|
||||
return "Read new output from a background command started with bash " +
|
||||
"run_in_background=true, addressed by its bash_id. Returns output " +
|
||||
"accumulated since the last read plus the command's status (running or " +
|
||||
"exited, with exit code). Call repeatedly to stream a long job's output."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *BashOutputTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bash_id": {"type": "string", "description": "The bash_id returned by a background bash call."}
|
||||
},
|
||||
"required": ["bash_id"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Reading output has no side effects.
|
||||
func (t *BashOutputTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It returns the job's new output and status.
|
||||
func (t *BashOutputTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[bashOutputArgs](args, "bash_output")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if t.Jobs == nil {
|
||||
return errorResult("bash_output: background jobs are not available in this environment"), nil
|
||||
}
|
||||
job, ok := t.Jobs.Get(a.BashID)
|
||||
if !ok {
|
||||
return errorResult(fmt.Sprintf("bash_output: no background command with id %q", a.BashID)), nil
|
||||
}
|
||||
|
||||
out := truncateBashOutput(job.readNew())
|
||||
status, exitCode, errMsg := job.snapshot()
|
||||
|
||||
var statusLine string
|
||||
if status == BashRunning {
|
||||
statusLine = fmt.Sprintf("[%s: running]", a.BashID)
|
||||
} else if errMsg != "" && exitCode != 0 {
|
||||
statusLine = fmt.Sprintf("[%s: exited code %d: %s]", a.BashID, exitCode, errMsg)
|
||||
} else {
|
||||
statusLine = fmt.Sprintf("[%s: exited code %d]", a.BashID, exitCode)
|
||||
}
|
||||
|
||||
text := statusLine
|
||||
if out != "" {
|
||||
text = out + "\n" + statusLine
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
|
||||
Details: map[string]any{"bash_id": a.BashID, "status": string(status), "exitCode": exitCode},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BashKillTool terminates a running background job by canceling its context.
|
||||
// Jobs is the shared store the bash tool populates.
|
||||
type BashKillTool struct {
|
||||
Jobs *BashJobStore
|
||||
}
|
||||
|
||||
// bashKillArgs is the decoded argument shape for BashKillTool.
|
||||
type bashKillArgs struct {
|
||||
// BashID is the job handle returned by a background `bash` call.
|
||||
BashID string `json:"bash_id"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *BashKillTool) Name() string { return "kill_bash" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *BashKillTool) Description() string {
|
||||
return "Terminate a background command started with bash " +
|
||||
"run_in_background=true, addressed by its bash_id. The command's " +
|
||||
"process is killed; already-exited jobs report that they were not running."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *BashKillTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bash_id": {"type": "string", "description": "The bash_id returned by a background bash call."}
|
||||
},
|
||||
"required": ["bash_id"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Killing a process is a side effect.
|
||||
func (t *BashKillTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It kills the job and reports whether it had been
|
||||
// running.
|
||||
func (t *BashKillTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[bashKillArgs](args, "kill_bash")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if t.Jobs == nil {
|
||||
return errorResult("kill_bash: background jobs are not available in this environment"), nil
|
||||
}
|
||||
job, ok := t.Jobs.Get(a.BashID)
|
||||
if !ok {
|
||||
return errorResult(fmt.Sprintf("kill_bash: no background command with id %q", a.BashID)), nil
|
||||
}
|
||||
|
||||
if job.kill() {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("killed background command %s", a.BashID))},
|
||||
Details: map[string]any{"bash_id": a.BashID, "killed": true},
|
||||
}, nil
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("background command %s was not running", a.BashID))},
|
||||
Details: map[string]any{"bash_id": a.BashID, "killed": false},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// This file implements the bash tool (US-018): run a shell command, streaming
|
||||
// stdout/stderr back as tool_execution_update partials, honoring a timeout and
|
||||
// context cancellation (which kills the child process group). A non-zero exit
|
||||
// is surfaced as an error (isError) whose message carries the captured output.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// bashDefaultTimeout bounds a command that does not specify one.
|
||||
const bashDefaultTimeout = 2 * time.Minute
|
||||
|
||||
// bashMaxTimeout caps any requested timeout.
|
||||
const bashMaxTimeout = 10 * time.Minute
|
||||
|
||||
// bashMaxOutputBytes caps how many bytes of combined stdout/stderr the bash tool
|
||||
// returns to the model. A single command can emit megabytes (build logs, a big
|
||||
// cat), which — unlike the timeout cap — would otherwise flow into context whole
|
||||
// and blow the window. Output past this size is truncated to a head + tail
|
||||
// preview (see truncateBashOutput), mirroring search's searchMaxResults/"[truncated
|
||||
// …]" convention. This is the tool's own inner cap; a later executor-layer budget
|
||||
// may impose a stricter outer limit.
|
||||
const bashMaxOutputBytes = 30_000
|
||||
|
||||
// truncateBashOutput caps s at bashMaxOutputBytes using the shared
|
||||
// truncateToBudget idiom (head + "[truncated N bytes]" marker + tail, cut on
|
||||
// UTF-8 rune boundaries). It is the bash tool's own inner cap; the executor
|
||||
// layer applies a separate, uniform outer budget afterward.
|
||||
func truncateBashOutput(s string) string {
|
||||
return truncateToBudget(s, bashMaxOutputBytes)
|
||||
}
|
||||
|
||||
// trimUTF8Prefix drops trailing bytes of s that form an incomplete rune, so the
|
||||
// returned prefix ends on a rune boundary.
|
||||
func trimUTF8Prefix(s string) string {
|
||||
for len(s) > 0 {
|
||||
if r, size := utf8.DecodeLastRuneInString(s); r == utf8.RuneError && size <= 1 {
|
||||
s = s[:len(s)-1]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// trimUTF8Suffix drops leading bytes of s that form an incomplete rune, so the
|
||||
// returned suffix starts on a rune boundary.
|
||||
func trimUTF8Suffix(s string) string {
|
||||
for len(s) > 0 {
|
||||
if r, size := utf8.DecodeRuneInString(s); r == utf8.RuneError && size <= 1 {
|
||||
s = s[1:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BashTool runs shell commands. Dir bounds the working directory (empty = the
|
||||
// process CWD). Shell selects the interpreter (empty = "bash -c").
|
||||
type BashTool struct {
|
||||
// Dir is the working directory for commands. Empty uses the process CWD.
|
||||
Dir string
|
||||
// Shell is the interpreter path. Empty defaults to "bash".
|
||||
Shell string
|
||||
// Jobs holds background jobs launched with run_in_background. When nil,
|
||||
// run_in_background is rejected (the front-end did not wire a store).
|
||||
Jobs *BashJobStore
|
||||
}
|
||||
|
||||
// bashToolArgs is the decoded argument shape for BashTool.
|
||||
type bashToolArgs struct {
|
||||
// Command is the shell command line to run.
|
||||
Command string `json:"command"`
|
||||
// TimeoutMs optionally overrides the default timeout (milliseconds).
|
||||
TimeoutMs int `json:"timeout_ms,omitempty"`
|
||||
// RunInBackground detaches the command from the turn: it keeps running after
|
||||
// Execute returns, and its output is drained later via bash_output. A
|
||||
// background command has no default timeout (so dev servers/watchers run
|
||||
// indefinitely); timeout_ms still caps it if given.
|
||||
RunInBackground bool `json:"run_in_background,omitempty"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *BashTool) Name() string { return "bash" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *BashTool) Description() string {
|
||||
return "Run a shell command, streaming stdout/stderr. Supports a timeout " +
|
||||
"and cancellation. A non-zero exit code is reported as an error. " +
|
||||
"Set run_in_background=true for long-running commands (dev servers, " +
|
||||
"watchers): it returns immediately with a bash_id you drain with " +
|
||||
"bash_output and stop with kill_bash. " +
|
||||
"On Windows the command runs under bash if available (Git Bash/WSL), " +
|
||||
"else PowerShell, else cmd — prefer portable commands."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *BashTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Shell command line to run."},
|
||||
"timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (capped at 10 minutes). Ignored in background unless set.", "minimum": 0},
|
||||
"run_in_background": {"type": "boolean", "description": "Run detached and return immediately with a bash_id; drain output with bash_output, stop with kill_bash."}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Commands can have side effects → sequential.
|
||||
func (t *BashTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// shellLookPath resolves a program on PATH. It is a package var so tests can
|
||||
// simulate a Windows box with or without bash installed.
|
||||
var shellLookPath = exec.LookPath
|
||||
|
||||
// resolveShell picks the interpreter and the flag that makes it read the command
|
||||
// from the next argument. An explicit shell (BashTool.Shell) is always honored as
|
||||
// a POSIX-style "<shell> -c <command>".
|
||||
//
|
||||
// On Windows with no explicit shell, the naive "bash -c" hardcode fails on stock
|
||||
// machines that have no bash on PATH — the model then retries bash blindly and
|
||||
// every call errors (issue #518). So we prefer a real bash when one is present
|
||||
// (Git Bash / WSL / MSYS), since commands are authored in bash syntax, and fall
|
||||
// back to PowerShell, then cmd, so a command still runs on a bare Windows box.
|
||||
func resolveShell(explicit, goos string, lookPath func(string) (string, error)) (shell, flag string) {
|
||||
if explicit != "" {
|
||||
return explicit, "-c"
|
||||
}
|
||||
if goos == "windows" {
|
||||
if p, err := lookPath("bash"); err == nil {
|
||||
return p, "-c"
|
||||
}
|
||||
if p, err := lookPath("powershell"); err == nil {
|
||||
return p, "-Command"
|
||||
}
|
||||
return "cmd", "/C"
|
||||
}
|
||||
return "bash", "-c"
|
||||
}
|
||||
|
||||
// streamWriter forwards each written chunk to onUpdate as a growing partial
|
||||
// result while accumulating the full output. It is safe for concurrent use so
|
||||
// stdout and stderr can share the same combined buffer.
|
||||
type streamWriter struct {
|
||||
mu *sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
onUpdate agentcore.ToolUpdateFunc
|
||||
}
|
||||
|
||||
func (w streamWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
w.buf.Write(p)
|
||||
snapshot := w.buf.String()
|
||||
w.mu.Unlock()
|
||||
if w.onUpdate != nil {
|
||||
w.onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(snapshot)}})
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It streams combined stdout/stderr via onUpdate,
|
||||
// enforces a timeout, and kills the process on context cancellation. A non-zero
|
||||
// exit returns a Go error (→ isError) carrying the exit code and output.
|
||||
func (t *BashTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[bashToolArgs](args, "bash")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Command == "" {
|
||||
return errorResult("bash: command is required"), nil
|
||||
}
|
||||
|
||||
if a.RunInBackground {
|
||||
return t.startBackground(a)
|
||||
}
|
||||
|
||||
timeout := bashDefaultTimeout
|
||||
if a.TimeoutMs > 0 {
|
||||
timeout = time.Duration(a.TimeoutMs) * time.Millisecond
|
||||
}
|
||||
if timeout > bashMaxTimeout {
|
||||
timeout = bashMaxTimeout
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
shell, flag := resolveShell(t.Shell, runtime.GOOS, shellLookPath)
|
||||
cmd := exec.CommandContext(runCtx, shell, flag, a.Command)
|
||||
if t.Dir != "" {
|
||||
cmd.Dir = t.Dir
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var combined bytes.Buffer
|
||||
sw := streamWriter{mu: &mu, buf: &combined, onUpdate: onUpdate}
|
||||
cmd.Stdout = sw
|
||||
cmd.Stderr = sw
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
mu.Lock()
|
||||
output := combined.String()
|
||||
mu.Unlock()
|
||||
|
||||
// Cap the output before it enters any ToolResult / error message, so a single
|
||||
// command's huge output cannot blow the model's context. Truncation keeps a
|
||||
// head + tail preview with a "[truncated N bytes]" marker in the middle.
|
||||
output = truncateBashOutput(output)
|
||||
|
||||
// Context cancellation / timeout takes precedence in the message.
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
|
||||
fmt.Errorf("bash: command timed out after %s\n%s", timeout, output)
|
||||
}
|
||||
if ctx.Err() == context.Canceled {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
|
||||
fmt.Errorf("bash: command canceled\n%s", output)
|
||||
}
|
||||
|
||||
// A missing interpreter (no bash/powershell/cmd on PATH) surfaces as an
|
||||
// *exec.Error before the command ever runs. Report it with actionable
|
||||
// guidance instead of a bare "code -1", so the model stops retrying blindly.
|
||||
var execErr *exec.Error
|
||||
if errors.As(err, &execErr) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
|
||||
fmt.Errorf("bash: could not start shell %q: %v. On Windows install Git Bash or WSL (or configure a shell); commands are bash syntax", shell, execErr.Err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
exitCode := -1
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(output)},
|
||||
Details: map[string]any{"exitCode": exitCode},
|
||||
},
|
||||
fmt.Errorf("bash: command exited with code %d\n%s", exitCode, output)
|
||||
}
|
||||
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(output)},
|
||||
Details: map[string]any{"exitCode": 0},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// startBackground launches the command detached from the turn context and
|
||||
// returns immediately with a bash_id. The job runs under its own cancelable
|
||||
// context (rooted at context.Background(), not the turn ctx which is canceled
|
||||
// when the turn ends), so it survives past Execute. A background command has no
|
||||
// default timeout — a dev server or watcher is expected to run indefinitely —
|
||||
// but an explicit timeout_ms still caps it. Its combined output accumulates in
|
||||
// the job's buffer for bash_output to drain; kill_bash cancels its context.
|
||||
func (t *BashTool) startBackground(a bashToolArgs) (agentcore.AgentToolResult, error) {
|
||||
if t.Jobs == nil {
|
||||
return errorResult("bash: run_in_background is not available in this environment"), nil
|
||||
}
|
||||
|
||||
var jobCtx context.Context
|
||||
var cancel context.CancelFunc
|
||||
if a.TimeoutMs > 0 {
|
||||
timeout := time.Duration(a.TimeoutMs) * time.Millisecond
|
||||
if timeout > bashMaxTimeout {
|
||||
timeout = bashMaxTimeout
|
||||
}
|
||||
jobCtx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
} else {
|
||||
jobCtx, cancel = context.WithCancel(context.Background())
|
||||
}
|
||||
|
||||
shell, flag := resolveShell(t.Shell, runtime.GOOS, shellLookPath)
|
||||
cmd := exec.CommandContext(jobCtx, shell, flag, a.Command)
|
||||
if t.Dir != "" {
|
||||
cmd.Dir = t.Dir
|
||||
}
|
||||
|
||||
job := t.Jobs.create(a.Command, cancel)
|
||||
w := job.writer()
|
||||
cmd.Stdout = w
|
||||
cmd.Stderr = w
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
job.finish(-1, err.Error())
|
||||
return errorResult(fmt.Sprintf("bash: could not start background command: %v", err)), nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
cancel()
|
||||
exitCode := 0
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
exitCode = -1
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
errMsg = err.Error()
|
||||
}
|
||||
job.finish(exitCode, errMsg)
|
||||
}()
|
||||
|
||||
msg := fmt.Sprintf("started background command %s: %s\nuse bash_output %q to read its output, kill_bash %q to stop it", job.ID, a.Command, job.ID, job.ID)
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
Details: map[string]any{"bash_id": job.ID, "background": true},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runBash(t *testing.T, tool *BashTool, args map[string]any, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
return tool.Execute(context.Background(), "call-1", raw, onUpdate)
|
||||
}
|
||||
|
||||
func TestBashToolSuccess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
res, gerr := runBash(t, tool, map[string]any{"command": "echo hello"}, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected go error: %v", gerr)
|
||||
}
|
||||
if !strings.Contains(resultText(res), "hello") {
|
||||
t.Errorf("output = %q, want to contain hello", resultText(res))
|
||||
}
|
||||
details, ok := res.Details.(map[string]any)
|
||||
if !ok || details["exitCode"] != 0 {
|
||||
t.Errorf("expected exitCode 0, details = %+v", res.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolNonZeroExitIsError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
res, gerr := runBash(t, tool, map[string]any{"command": "echo oops >&2; exit 3"}, nil)
|
||||
// A non-zero exit must surface as a Go error so the executor flags isError.
|
||||
if gerr == nil {
|
||||
t.Fatalf("expected go error for non-zero exit, got nil")
|
||||
}
|
||||
if !strings.Contains(gerr.Error(), "code 3") {
|
||||
t.Errorf("error = %q, want to mention code 3", gerr.Error())
|
||||
}
|
||||
// The captured output must ride along.
|
||||
if !strings.Contains(gerr.Error(), "oops") {
|
||||
t.Errorf("error = %q, want to carry output", gerr.Error())
|
||||
}
|
||||
details, ok := res.Details.(map[string]any)
|
||||
if !ok || details["exitCode"] != 3 {
|
||||
t.Errorf("expected exitCode 3, details = %+v", res.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolStreaming(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
var mu sync.Mutex
|
||||
var updates []string
|
||||
onUpdate := func(r agentcore.AgentToolResult) {
|
||||
mu.Lock()
|
||||
updates = append(updates, resultText(r))
|
||||
mu.Unlock()
|
||||
}
|
||||
_, gerr := runBash(t, tool, map[string]any{"command": "printf 'a'; printf 'b'"}, onUpdate)
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected go error: %v", gerr)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(updates) == 0 {
|
||||
t.Fatalf("expected streaming updates, got none")
|
||||
}
|
||||
// The final partial should be the full accumulated output.
|
||||
if last := updates[len(updates)-1]; !strings.Contains(last, "ab") {
|
||||
t.Errorf("final update = %q, want to contain ab", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolTimeout(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
start := time.Now()
|
||||
res, gerr := runBash(t, tool, map[string]any{"command": "sleep 5", "timeout_ms": 100}, nil)
|
||||
if gerr == nil {
|
||||
t.Fatalf("expected timeout error, got nil")
|
||||
}
|
||||
if !strings.Contains(gerr.Error(), "timed out") {
|
||||
t.Errorf("error = %q, want to mention timed out", gerr.Error())
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Errorf("timeout took too long: %s (process not killed?)", elapsed)
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
|
||||
func TestBashToolCancel(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
raw, _ := json.Marshal(map[string]any{"command": "sleep 5"})
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
_, gerr := tool.Execute(ctx, "call-1", raw, nil)
|
||||
if gerr == nil {
|
||||
t.Fatalf("expected cancellation error, got nil")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Errorf("cancel took too long: %s (process not killed?)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolMissingCommand(t *testing.T) {
|
||||
tool := &BashTool{}
|
||||
res, gerr := runBash(t, tool, map[string]any{"command": ""}, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected go error: %v", gerr)
|
||||
}
|
||||
if !strings.Contains(resultText(res), "command is required") {
|
||||
t.Errorf("expected command-required error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolMode(t *testing.T) {
|
||||
tool := &BashTool{}
|
||||
if tool.Name() != "bash" {
|
||||
t.Errorf("name = %q", tool.Name())
|
||||
}
|
||||
if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
|
||||
t.Error("bash should be sequential")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Errorf("schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolSmallOutputNotTruncated(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
res, gerr := runBash(t, tool, map[string]any{"command": "echo hello world"}, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected go error: %v", gerr)
|
||||
}
|
||||
out := resultText(res)
|
||||
if strings.Contains(out, "truncated") {
|
||||
t.Errorf("small output should not be truncated, got %q", out)
|
||||
}
|
||||
if strings.TrimSpace(out) != "hello world" {
|
||||
t.Errorf("output = %q, want %q", out, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolLargeOutputTruncatedHeadTail(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash not available on windows")
|
||||
}
|
||||
tool := &BashTool{}
|
||||
// Emit a marker at the very start and very end, with a large filler between,
|
||||
// so we can prove both the head and the tail survive truncation.
|
||||
total := bashMaxOutputBytes * 3
|
||||
filler := bashMaxOutputBytes // bytes of 'x' between the two markers
|
||||
cmd := fmt.Sprintf("printf 'HEADMARK'; head -c %d /dev/zero | tr '\\0' 'x'; printf 'TAILMARK'", filler)
|
||||
_ = total
|
||||
res, gerr := runBash(t, tool, map[string]any{"command": cmd}, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("unexpected go error: %v", gerr)
|
||||
}
|
||||
out := resultText(res)
|
||||
if len(out) > bashMaxOutputBytes+128 {
|
||||
t.Errorf("truncated output too long: %d bytes (cap %d)", len(out), bashMaxOutputBytes)
|
||||
}
|
||||
if !strings.HasPrefix(out, "HEADMARK") {
|
||||
t.Errorf("head not preserved; output starts with %q", out[:min(16, len(out))])
|
||||
}
|
||||
if !strings.HasSuffix(out, "TAILMARK") {
|
||||
t.Errorf("tail not preserved; output ends with %q", out[max(0, len(out)-16):])
|
||||
}
|
||||
if !strings.Contains(out, "[truncated ") || !strings.Contains(out, " bytes]") {
|
||||
t.Errorf("missing truncation marker in %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateBashOutputByteCount(t *testing.T) {
|
||||
// A pure-ASCII input of a known size: the marker's N must equal the exact
|
||||
// number of middle bytes dropped, i.e. total - head - tail.
|
||||
total := bashMaxOutputBytes * 2
|
||||
in := strings.Repeat("z", total)
|
||||
out := truncateBashOutput(in)
|
||||
|
||||
half := bashMaxOutputBytes / 2
|
||||
// For all-ASCII input no rune-boundary trimming happens, so head/tail are
|
||||
// each exactly half and N = total - 2*half.
|
||||
wantRemoved := total - 2*half
|
||||
wantMarker := fmt.Sprintf("[truncated %d bytes]", wantRemoved)
|
||||
if !strings.Contains(out, wantMarker) {
|
||||
t.Errorf("marker = ...%q..., want to contain %q", out, wantMarker)
|
||||
}
|
||||
if got := strings.Count(out, "z"); got != 2*half {
|
||||
t.Errorf("preserved %d content bytes, want %d (head+tail)", got, 2*half)
|
||||
}
|
||||
|
||||
// Input at or below the cap is returned verbatim.
|
||||
small := strings.Repeat("b", bashMaxOutputBytes)
|
||||
if got := truncateBashOutput(small); got != small {
|
||||
t.Errorf("input at cap should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveShell covers the platform-aware interpreter selection (issue #518).
|
||||
// It injects goos + a lookPath stub so every branch runs regardless of the host.
|
||||
func TestResolveShell(t *testing.T) {
|
||||
found := func(name string) func(string) (string, error) {
|
||||
return func(s string) (string, error) {
|
||||
if s == name {
|
||||
return `C:\bin\` + s, nil
|
||||
}
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
}
|
||||
none := func(string) (string, error) { return "", fmt.Errorf("not found") }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
explicit, goos string
|
||||
lookPath func(string) (string, error)
|
||||
wantFlag string
|
||||
wantShellHas string // substring the resolved shell must contain
|
||||
}{
|
||||
{"explicit honored on windows", "zsh", "windows", none, "-c", "zsh"},
|
||||
{"explicit honored on linux", "fish", "linux", none, "-c", "fish"},
|
||||
{"non-windows always bash", "", "linux", none, "-c", "bash"},
|
||||
{"darwin always bash", "", "darwin", none, "-c", "bash"},
|
||||
{"windows with bash", "", "windows", found("bash"), "-c", "bash"},
|
||||
{"windows falls back to powershell", "", "windows", found("powershell"), "-Command", "powershell"},
|
||||
{"windows falls back to cmd", "", "windows", none, "/C", "cmd"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
shell, flag := resolveShell(tc.explicit, tc.goos, tc.lookPath)
|
||||
if flag != tc.wantFlag {
|
||||
t.Errorf("flag = %q, want %q", flag, tc.wantFlag)
|
||||
}
|
||||
if !strings.Contains(shell, tc.wantShellHas) {
|
||||
t.Errorf("shell = %q, want to contain %q", shell, tc.wantShellHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// This file implements batch tool execution (US-005): a batch of tool calls
|
||||
// from one assistant message is run either sequentially or in parallel, mirroring
|
||||
// pi's semantics.
|
||||
//
|
||||
// - sequential mode runs each call prepare→execute→finalize in order and stops
|
||||
// early if the context is aborted.
|
||||
// - parallel mode preserves ordering by index-backfilling results, running the
|
||||
// allowed calls in goroutines. (prepare is not separately staged here because
|
||||
// executeToolCall keeps prepare+execute together per call; ordering is still
|
||||
// guaranteed by writing each result to its source index.)
|
||||
//
|
||||
// The whole batch signals termination only when every finalized result has
|
||||
// terminate=true, matching pi.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// ForceSequential, when true, makes the whole batch run serially regardless of
|
||||
// per-tool ExecutionMode.
|
||||
type BatchConfig struct {
|
||||
ToolExecutorConfig
|
||||
ForceSequential bool
|
||||
}
|
||||
|
||||
// ExecuteToolCalls runs a batch of tool calls belonging to one assistant
|
||||
// message. It returns the tool-result messages in source order and whether the
|
||||
// whole batch requests termination (only when every result terminates).
|
||||
func ExecuteToolCalls(ctx context.Context, cfg BatchConfig, calls []agentcore.AgentToolCall, emit agentcore.EmitFunc) ([]agentcore.ToolResultMessage, bool) {
|
||||
if len(calls) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
results := make([]agentcore.ToolResultMessage, len(calls))
|
||||
terminates := make([]bool, len(calls))
|
||||
|
||||
if cfg.ForceSequential || batchRequiresSequential(cfg.Registry, calls) {
|
||||
for i, call := range calls {
|
||||
if ctx.Err() != nil {
|
||||
// Abort: fill the remaining calls with aborted error results so
|
||||
// every tool call still gets a result message.
|
||||
for j := i; j < len(calls); j++ {
|
||||
results[j] = errorToolResult(calls[j], "tool call aborted")
|
||||
terminates[j] = false
|
||||
}
|
||||
break
|
||||
}
|
||||
results[i], terminates[i] = executeToolCall(ctx, cfg.ToolExecutorConfig, call, emit)
|
||||
}
|
||||
} else {
|
||||
var wg sync.WaitGroup
|
||||
for i, call := range calls {
|
||||
wg.Add(1)
|
||||
go func(i int, call agentcore.AgentToolCall) {
|
||||
defer wg.Done()
|
||||
results[i], terminates[i] = executeToolCall(ctx, cfg.ToolExecutorConfig, call, emit)
|
||||
}(i, call)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Whole batch terminates only when every result terminates (pi semantics).
|
||||
allTerminate := true
|
||||
for _, t := range terminates {
|
||||
if !t {
|
||||
allTerminate = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return results, allTerminate
|
||||
}
|
||||
|
||||
// batchRequiresSequential reports whether any tool in the batch declares
|
||||
// ExecutionMode sequential, which forces the whole batch to run serially.
|
||||
func batchRequiresSequential(reg *ToolRegistry, calls []agentcore.AgentToolCall) bool {
|
||||
for _, call := range calls {
|
||||
if tool, ok := reg.Get(call.Name); ok && tool.ExecutionMode() == agentcore.ToolExecutionSequential {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// registerAll builds a registry containing every tool given.
|
||||
func registerAll(t *testing.T, tools ...agentcore.AgentTool) *ToolRegistry {
|
||||
t.Helper()
|
||||
r := NewToolRegistry()
|
||||
for _, tool := range tools {
|
||||
if err := r.Register(tool); err != nil {
|
||||
t.Fatalf("register %s: %v", tool.Name(), err)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// echoTool returns its name as text; optionally terminates.
|
||||
func echoTool(name string, mode agentcore.ToolExecutionMode, terminate bool) execTool {
|
||||
return execTool{
|
||||
name: name,
|
||||
mode: mode,
|
||||
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
term := terminate
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}, Terminate: &term}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func callsFor(names ...string) []agentcore.AgentToolCall {
|
||||
calls := make([]agentcore.AgentToolCall, len(names))
|
||||
for i, n := range names {
|
||||
calls[i] = agentcore.AgentToolCall{ID: fmt.Sprintf("c%d", i), Name: n, Arguments: json.RawMessage(`{}`)}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// TestBatchParallelPreservesOrder verifies that parallel execution backfills
|
||||
// results at their source index regardless of completion order.
|
||||
func TestBatchParallelPreservesOrder(t *testing.T) {
|
||||
// t0 sleeps longest, t2 shortest — so completion order is reversed, but the
|
||||
// result slice must still be [t0, t1, t2].
|
||||
mk := func(name string, delay time.Duration) execTool {
|
||||
return execTool{
|
||||
name: name,
|
||||
mode: agentcore.ToolExecutionParallel,
|
||||
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
time.Sleep(delay)
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
reg := registerAll(t, mk("t0", 30*time.Millisecond), mk("t1", 15*time.Millisecond), mk("t2", 1*time.Millisecond))
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
|
||||
|
||||
results, term := ExecuteToolCalls(context.Background(), cfg, callsFor("t0", "t1", "t2"), nil)
|
||||
if term {
|
||||
t.Errorf("no tool terminates; batch must not terminate")
|
||||
}
|
||||
want := []string{"t0", "t1", "t2"}
|
||||
for i, w := range want {
|
||||
if got := textOf(results[i]); got != w {
|
||||
t.Errorf("result[%d] = %q, want %q (order not preserved)", i, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchParallelRunsConcurrently confirms parallel tools overlap in time.
|
||||
func TestBatchParallelRunsConcurrently(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
running := 0
|
||||
maxConcurrent := 0
|
||||
block := make(chan struct{})
|
||||
var started sync.WaitGroup
|
||||
started.Add(3)
|
||||
|
||||
mk := func(name string) execTool {
|
||||
return execTool{
|
||||
name: name,
|
||||
mode: agentcore.ToolExecutionParallel,
|
||||
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
mu.Lock()
|
||||
running++
|
||||
if running > maxConcurrent {
|
||||
maxConcurrent = running
|
||||
}
|
||||
mu.Unlock()
|
||||
started.Done()
|
||||
<-block // hold until all have started
|
||||
mu.Lock()
|
||||
running--
|
||||
mu.Unlock()
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
reg := registerAll(t, mk("a"), mk("b"), mk("c"))
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
|
||||
|
||||
go func() {
|
||||
started.Wait()
|
||||
close(block)
|
||||
}()
|
||||
ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b", "c"), nil)
|
||||
|
||||
if maxConcurrent < 3 {
|
||||
t.Errorf("expected 3 concurrent tools, saw max %d", maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchSequentialWhenAnyToolSequential forces serial execution and records
|
||||
// the order tools actually ran in.
|
||||
func TestBatchSequentialWhenAnyToolSequential(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var order []string
|
||||
mk := func(name string, mode agentcore.ToolExecutionMode) execTool {
|
||||
return execTool{
|
||||
name: name,
|
||||
mode: mode,
|
||||
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, name)
|
||||
mu.Unlock()
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// "b" is sequential → whole batch runs serially in source order.
|
||||
reg := registerAll(t, mk("a", agentcore.ToolExecutionParallel), mk("b", agentcore.ToolExecutionSequential), mk("c", agentcore.ToolExecutionParallel))
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
|
||||
|
||||
ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b", "c"), nil)
|
||||
want := []string{"a", "b", "c"}
|
||||
for i, w := range want {
|
||||
if order[i] != w {
|
||||
t.Fatalf("sequential order = %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchForceSequential verifies the global ForceSequential flag serializes
|
||||
// even all-parallel tools.
|
||||
func TestBatchForceSequential(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var order []string
|
||||
mk := func(name string) execTool {
|
||||
return execTool{
|
||||
name: name,
|
||||
mode: agentcore.ToolExecutionParallel,
|
||||
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, name)
|
||||
mu.Unlock()
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
reg := registerAll(t, mk("a"), mk("b"))
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}, ForceSequential: true}
|
||||
|
||||
ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b"), nil)
|
||||
if len(order) != 2 || order[0] != "a" || order[1] != "b" {
|
||||
t.Errorf("force-sequential order = %v, want [a b]", order)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchTerminateOnlyWhenAll checks the whole-batch terminate semantics.
|
||||
func TestBatchTerminateOnlyWhenAll(t *testing.T) {
|
||||
// Mixed: one terminates, one does not → batch must NOT terminate.
|
||||
reg := registerAll(t, echoTool("term", agentcore.ToolExecutionParallel, true), echoTool("noterm", agentcore.ToolExecutionParallel, false))
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
|
||||
_, term := ExecuteToolCalls(context.Background(), cfg, callsFor("term", "noterm"), nil)
|
||||
if term {
|
||||
t.Errorf("batch with one non-terminating tool must not terminate")
|
||||
}
|
||||
|
||||
// All terminate → batch terminates.
|
||||
reg2 := registerAll(t, echoTool("t1", agentcore.ToolExecutionParallel, true), echoTool("t2", agentcore.ToolExecutionParallel, true))
|
||||
cfg2 := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg2}}
|
||||
_, term2 := ExecuteToolCalls(context.Background(), cfg2, callsFor("t1", "t2"), nil)
|
||||
if !term2 {
|
||||
t.Errorf("batch with all terminating tools must terminate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchSequentialAbort verifies that aborting mid-batch fills the remaining
|
||||
// calls with aborted error results.
|
||||
func TestBatchSequentialAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
mk := func(name string) execTool {
|
||||
return execTool{
|
||||
name: name,
|
||||
mode: agentcore.ToolExecutionSequential,
|
||||
run: func(c context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
cancel() // abort after the first tool starts
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
reg := registerAll(t, mk("first"), echoTool("second", agentcore.ToolExecutionSequential, false))
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}}
|
||||
|
||||
results, _ := ExecuteToolCalls(ctx, cfg, callsFor("first", "second"), nil)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
if !results[1].IsError {
|
||||
t.Errorf("second (post-abort) result must be an error result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchEmpty covers the empty-batch fast path.
|
||||
func TestBatchEmpty(t *testing.T) {
|
||||
cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: NewToolRegistry()}}
|
||||
results, term := ExecuteToolCalls(context.Background(), cfg, nil, nil)
|
||||
if results != nil || term {
|
||||
t.Errorf("empty batch must return (nil, false), got (%v, %v)", results, term)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// This file implements the blackboard tool (US-coop): a shared-file-system
|
||||
// coordination primitive for the pigo coop runner (see coop/). A single pigo
|
||||
// agent works on the task in $BB: its workspace lives at $BB/workspace, and it
|
||||
// creates the DONE marker through this tool. The blackboard ($BB) holds
|
||||
// task.md, the workspace/, and a DONE marker.
|
||||
//
|
||||
// Raw file tools cannot safely create the DONE marker: the agent's file tools
|
||||
// are rooted at its own workspace, and a plain write could race the supervisor.
|
||||
// So the blackboard is a dedicated tool with three atomic operations:
|
||||
//
|
||||
// blackboard action=read [path=...] global snapshot, or one file's contents
|
||||
// blackboard action=post file=... content=... atomically append a message
|
||||
// blackboard action=done summary=... atomically create the DONE marker
|
||||
//
|
||||
// Append and marker creation are atomic at the OS level (O_APPEND single-write
|
||||
// for messages, O_CREATE|O_EXCL for DONE), so writes never interleave or
|
||||
// clobber each other. Every path is validated against the blackboard root to
|
||||
// forbid traversal.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// maxBlackboardMessageBytes caps a single post. The cap serves two purposes:
|
||||
// keeps each append a single atomic write, and keeps the tool result from
|
||||
// ballooning the context.
|
||||
const maxBlackboardMessageBytes = 32 * 1024
|
||||
|
||||
// maxBlackboardReadBytes caps how much of one file blackboard read returns.
|
||||
const maxBlackboardReadBytes = 32 * 1024
|
||||
|
||||
// BlackboardTool is the task blackboard for the pigo coop runner. It is
|
||||
// wired into the tool set only when the BB environment variable points at a
|
||||
// blackboard root (see run.SetupEnv), so ordinary pigo runs never see it.
|
||||
type BlackboardTool struct {
|
||||
// Root is the blackboard root directory (the value of $BB). Must be set.
|
||||
Root string
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *BlackboardTool) Name() string { return "blackboard" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *BlackboardTool) Description() string {
|
||||
return "Read and write the task blackboard used by the pigo coop runner (see " +
|
||||
"coop/). This is the ONLY tool that may touch shared blackboard files " +
|
||||
"atomically. Actions: read (no path: global snapshot of task.md, workspace " +
|
||||
"listing, DONE state; with path like \"workspace/exploit.py\": contents of " +
|
||||
"that one file); post (atomically append a progress note; file must be a " +
|
||||
"bare .md name under messages/, e.g. \"round-<ROUND>-<NAME>.md\"); done " +
|
||||
"(atomically create the DONE marker with a final delivery summary, only when " +
|
||||
"the deliverable is truly complete; fails if DONE already exists). The " +
|
||||
"blackboard root, current round and your name are available in the " +
|
||||
"environment as BB, ROUND, NAME."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *BlackboardTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["read", "post", "done"], "description": "read | post | done"},
|
||||
"path": {"type": "string", "description": "For read: a path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot."},
|
||||
"file": {"type": "string", "description": "For post: bare file name under messages/, must end in .md (e.g. round-1-a.md)."},
|
||||
"content": {"type": "string", "description": "For post: the message body (max 32 KiB)."},
|
||||
"summary": {"type": "string", "description": "For done: final delivery summary written into DONE."}
|
||||
},
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. It mutates shared files → sequential.
|
||||
func (t *BlackboardTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
type blackboardArgs struct {
|
||||
Action string `json:"action"`
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
Content string `json:"content"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Execute implements AgentTool.
|
||||
func (t *BlackboardTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[blackboardArgs](args, "blackboard")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if t.Root == "" {
|
||||
return errorResult("blackboard: no blackboard root configured"), nil
|
||||
}
|
||||
switch a.Action {
|
||||
case "read":
|
||||
return t.read(a)
|
||||
case "post":
|
||||
return t.post(a)
|
||||
case "done":
|
||||
return t.done(a)
|
||||
default:
|
||||
return errorResult(fmt.Sprintf("blackboard: unknown action %q (want read|post|done)", a.Action)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// read returns either one file's contents (when a.Path is set) or a global
|
||||
// snapshot of the blackboard: task.md, message list, both workspace listings,
|
||||
// and the DONE state.
|
||||
func (t *BlackboardTool) read(a blackboardArgs) (agentcore.AgentToolResult, error) {
|
||||
if strings.TrimSpace(a.Path) != "" {
|
||||
return t.readFile(a.Path)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if data, err := os.ReadFile(filepath.Join(t.Root, "task.md")); err == nil {
|
||||
b.WriteString("# task.md\n")
|
||||
b.WriteString(truncateToBudget(string(data), maxBlackboardReadBytes))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString("# task.md\n<missing>\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n# messages/ (" + strings.Join(readDirNames(t.Root, "messages"), ", ") + ")\n")
|
||||
for _, name := range readDirListing(t.Root, "messages") {
|
||||
b.WriteString(" - " + name + "\n")
|
||||
}
|
||||
b.WriteString("\n# workspace/ (your workspace)\n")
|
||||
for _, name := range readDirListing(t.Root, "workspace") {
|
||||
b.WriteString(" - " + name + "\n")
|
||||
}
|
||||
|
||||
done := ""
|
||||
if data, err := os.ReadFile(filepath.Join(t.Root, "DONE")); err == nil {
|
||||
done = truncateToBudget(string(data), 4096)
|
||||
}
|
||||
b.WriteString("\n# DONE\n")
|
||||
if done == "" {
|
||||
b.WriteString("<not created yet — cooperation is still in progress>\n")
|
||||
} else {
|
||||
b.WriteString("EXISTS:\n" + done + "\n")
|
||||
}
|
||||
|
||||
if env := blackboardEnvSummary(); env != "" {
|
||||
b.WriteString("\n# environment\n" + env)
|
||||
}
|
||||
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(b.String())}}, nil
|
||||
}
|
||||
|
||||
// readFile returns the contents of one file under the blackboard root, capped at
|
||||
// maxBlackboardReadBytes. Paths are validated (no traversal) and must point
|
||||
// inside the root.
|
||||
func (t *BlackboardTool) readFile(p string) (agentcore.AgentToolResult, error) {
|
||||
full, err := t.safePath(p)
|
||||
if err != nil {
|
||||
return errorResult("blackboard read: " + err.Error()), nil
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errorResult(fmt.Sprintf("blackboard read: %s is a directory; only files can be read", p)), nil
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent("# " + p + "\n" + truncateToBudget(string(data), maxBlackboardReadBytes))},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// post atomically appends a message to $BB/messages/<file>. The file name must
|
||||
// be a bare *.md name (no separators) so a message can never escape the
|
||||
// messages directory. Appending is a single O_APPEND write → atomic under
|
||||
// concurrent agents.
|
||||
func (t *BlackboardTool) post(a blackboardArgs) (agentcore.AgentToolResult, error) {
|
||||
name := strings.TrimSpace(a.File)
|
||||
if !validMessageName(name) {
|
||||
return errorResult("blackboard post: file must be a bare name ending in .md (e.g. \"round-1-a.md\"), no path separators, no \"..\""), nil
|
||||
}
|
||||
content := strings.TrimSpace(a.Content)
|
||||
if content == "" {
|
||||
return errorResult("blackboard post: content must not be empty"), nil
|
||||
}
|
||||
if len(content) > maxBlackboardMessageBytes {
|
||||
return errorResult(fmt.Sprintf("blackboard post: content too large (%d bytes, max %d)", len(content), maxBlackboardMessageBytes)), nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(t.Root, "messages")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return errorResult("blackboard post: " + err.Error()), nil
|
||||
}
|
||||
// O_APPEND + a single Write is atomic on POSIX: concurrent posts never
|
||||
// interleave bytes. The newline separates this message from the previous one.
|
||||
f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return errorResult("blackboard post: " + err.Error()), nil
|
||||
}
|
||||
_, werr := f.WriteString(content)
|
||||
cerr := f.Close()
|
||||
if werr != nil {
|
||||
return errorResult("blackboard post: write: " + werr.Error()), nil
|
||||
}
|
||||
if cerr != nil {
|
||||
return errorResult("blackboard post: close: " + cerr.Error()), nil
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("Message appended to messages/%s", name))},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// done atomically creates the DONE marker with a delivery summary. O_CREATE|O_EXCL
|
||||
// guarantees exactly one agent can create it; a second attempt reports the
|
||||
// existing marker rather than overwriting it.
|
||||
func (t *BlackboardTool) done(a blackboardArgs) (agentcore.AgentToolResult, error) {
|
||||
summary := strings.TrimSpace(a.Summary)
|
||||
if summary == "" {
|
||||
return errorResult("blackboard done: summary must not be empty (include the final delivery summary)"), nil
|
||||
}
|
||||
header := "Blackboard cooperation DONE\n"
|
||||
header += "created: " + time.Now().UTC().Format(time.RFC3339) + "\n\n"
|
||||
content := header + summary
|
||||
|
||||
target := filepath.Join(t.Root, "DONE")
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
existing, _ := os.ReadFile(target)
|
||||
return errorResult("blackboard done: DONE already exists — cooperation already finished:\n" + truncateToBudget(string(existing), 4096)), nil
|
||||
}
|
||||
return errorResult("blackboard done: " + err.Error()), nil
|
||||
}
|
||||
if _, werr := f.WriteString(content); werr != nil {
|
||||
f.Close()
|
||||
return errorResult("blackboard done: write: " + werr.Error()), nil
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
return errorResult("blackboard done: close: " + cerr.Error()), nil
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent("DONE marker created. Cooperation finished.")},
|
||||
Terminate: terminatePtr(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// safePath resolves p against the blackboard root and rejects anything that
|
||||
// escapes it (.., absolute paths, symlinks are not followed beyond validation of
|
||||
// the lexical path).
|
||||
func (t *BlackboardTool) safePath(p string) (string, error) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
clean := filepath.Clean(p)
|
||||
if filepath.IsAbs(clean) {
|
||||
return "", fmt.Errorf("path %q must be relative to the blackboard root", p)
|
||||
}
|
||||
rootClean := filepath.Clean(t.Root)
|
||||
full := filepath.Join(rootClean, clean)
|
||||
if full != rootClean && !strings.HasPrefix(full, rootClean+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path %q escapes the blackboard root", p)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// validMessageName checks a post file name: a bare *.md name with no directory
|
||||
// components and no "." or ".." tricks.
|
||||
func validMessageName(name string) bool {
|
||||
if name == "" || !strings.HasSuffix(name, ".md") {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
base := strings.TrimSuffix(name, ".md")
|
||||
if base == "" || strings.HasPrefix(base, ".") || strings.Contains(base, "..") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readDirNames lists direct child names of a directory under the root (missing
|
||||
// or unreadable → empty slice).
|
||||
func readDirNames(root, sub string) []string {
|
||||
entries, err := os.ReadDir(filepath.Join(root, sub))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// readDirListing lists direct children of a directory under the root with size
|
||||
// and modification time, sorted by name (missing or unreadable → empty slice).
|
||||
func readDirListing(root, sub string) []string {
|
||||
entries, err := os.ReadDir(filepath.Join(root, sub))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
var size string
|
||||
if info, err := e.Info(); err == nil && !info.IsDir() {
|
||||
size = fmt.Sprintf(" (%d bytes, %s)", info.Size(), info.ModTime().UTC().Format("15:04:05"))
|
||||
} else if err == nil {
|
||||
size = " (dir)"
|
||||
}
|
||||
out = append(out, e.Name()+size)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// blackboardEnvSummary renders the BB/ROUND/NAME environment values so the model
|
||||
// can address messages and understand the round. Returns "" when none are set.
|
||||
func blackboardEnvSummary() string {
|
||||
var b strings.Builder
|
||||
for _, k := range []string{"BB", "ROUND", "NAME"} {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
fmt.Fprintf(&b, " %s=%s\n", k, v)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// newTestBlackboard builds a BlackboardTool over a fresh temp dir and a helper
|
||||
// to run an action and return the text result.
|
||||
func newTestBlackboard(t *testing.T) (*BlackboardTool, func(args string) string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
tool := &BlackboardTool{Root: root}
|
||||
run := func(args string) string {
|
||||
t.Helper()
|
||||
res, err := tool.Execute(context.Background(), "t1", json.RawMessage(args), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range res.Content {
|
||||
if txt, ok := c.(agentcore.TextContent); ok {
|
||||
sb.WriteString(txt.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
return tool, run
|
||||
}
|
||||
|
||||
func TestBlackboardPostAndRead(t *testing.T) {
|
||||
tool, run := newTestBlackboard(t)
|
||||
|
||||
got := run(`{"action":"post","file":"round-1-a.md","content":"hello from a"}`)
|
||||
if !strings.Contains(got, "appended") {
|
||||
t.Fatalf("post result = %q, want appended confirmation", got)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tool.Root, "messages", "round-1-a.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("message file not written: %v", err)
|
||||
}
|
||||
if string(data) != "hello from a" {
|
||||
t.Fatalf("message content = %q, want %q", data, "hello from a")
|
||||
}
|
||||
|
||||
// The snapshot lists the message; readFile returns its contents.
|
||||
snap := run(`{"action":"read"}`)
|
||||
if !strings.Contains(snap, "round-1-a.md") {
|
||||
t.Fatalf("snapshot missing the message name:\n%s", snap)
|
||||
}
|
||||
if !strings.Contains(snap, "DONE") || !strings.Contains(snap, "not created") {
|
||||
t.Fatalf("snapshot missing DONE status:\n%s", snap)
|
||||
}
|
||||
one := run(`{"action":"read","path":"messages/round-1-a.md"}`)
|
||||
if !strings.Contains(one, "hello from a") {
|
||||
t.Fatalf("readFile result missing content:\n%s", one)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardPostRejectsTraversal(t *testing.T) {
|
||||
tool, run := newTestBlackboard(t)
|
||||
for _, bad := range []string{
|
||||
`{"action":"post","file":"../escape.md","content":"x"}`,
|
||||
`{"action":"post","file":"a/b.md","content":"x"}`,
|
||||
`{"action":"post","file":"..","content":"x"}`,
|
||||
`{"action":"post","file":"notes.txt","content":"x"}`,
|
||||
`{"action":"post","file":"round.md","content":""}`,
|
||||
} {
|
||||
got := run(bad)
|
||||
if strings.Contains(got, "appended") {
|
||||
t.Fatalf("post with %s must be rejected, got %q", bad, got)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tool.Root, "escape.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("traversal escaped the root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardReadRejectsTraversal(t *testing.T) {
|
||||
_, run := newTestBlackboard(t)
|
||||
for _, bad := range []string{
|
||||
`{"action":"read","path":"../outside.md"}`,
|
||||
`{"action":"read","path":"/etc/passwd"}`,
|
||||
`{"action":"read","path":"messages"}`,
|
||||
} {
|
||||
got := run(bad)
|
||||
if !strings.Contains(got, "blackboard read:") {
|
||||
t.Fatalf("read with %s must error, got %q", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardDoneIsExclusive(t *testing.T) {
|
||||
tool, run := newTestBlackboard(t)
|
||||
|
||||
got := run(`{"action":"done","summary":"delivered: flag=abc"}`)
|
||||
if !strings.Contains(got, "DONE marker created") {
|
||||
t.Fatalf("first done failed: %q", got)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tool.Root, "DONE"))
|
||||
if err != nil {
|
||||
t.Fatalf("DONE not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "flag=abc") {
|
||||
t.Fatalf("DONE content missing summary: %q", data)
|
||||
}
|
||||
|
||||
// A second done must report the existing marker, not overwrite it.
|
||||
got2 := run(`{"action":"done","summary":"another summary"}`)
|
||||
if !strings.Contains(got2, "already exists") {
|
||||
t.Fatalf("second done must report existing marker, got %q", got2)
|
||||
}
|
||||
data2, _ := os.ReadFile(filepath.Join(tool.Root, "DONE"))
|
||||
if strings.Contains(string(data2), "another summary") {
|
||||
t.Fatalf("second done overwrote the marker: %q", data2)
|
||||
}
|
||||
|
||||
// done with an empty summary is rejected.
|
||||
if got := run(`{"action":"done","summary":""}`); !strings.Contains(got, "summary must not be empty") {
|
||||
t.Fatalf("empty-summary done must error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlackboardPostConcurrentAtomic verifies that parallel posts to the same
|
||||
// message file never interleave or lose bytes: each message survives whole.
|
||||
func TestBlackboardPostConcurrentAtomic(t *testing.T) {
|
||||
tool, _ := newTestBlackboard(t)
|
||||
const n = 16
|
||||
msgs := make([]string, n)
|
||||
for i := range msgs {
|
||||
msgs[i] = strings.Repeat("M", 100) + string(rune('A'+i)) + strings.Repeat("N", 100)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
args := json.RawMessage(`{"action":"post","file":"round-1-a.md","content":"` + msgs[i] + `"}`)
|
||||
if _, err := tool.Execute(context.Background(), "t", args, nil); err != nil {
|
||||
t.Errorf("concurrent post %d: %v", i, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tool.Root, "messages", "round-1-a.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read messages: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
for i, m := range msgs {
|
||||
if !strings.Contains(got, m) {
|
||||
t.Fatalf("message %d lost/interleaved in concurrent append:\n%s", i, got)
|
||||
}
|
||||
}
|
||||
// Every message must appear exactly once (no duplication from re-read+write).
|
||||
for _, m := range msgs {
|
||||
if strings.Count(got, m) != 1 {
|
||||
t.Fatalf("message %q appears %d times:\n%s", m, strings.Count(got, m), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardPostSizeCap(t *testing.T) {
|
||||
_, run := newTestBlackboard(t)
|
||||
huge := strings.Repeat("x", maxBlackboardMessageBytes+1)
|
||||
got := run(`{"action":"post","file":"big.md","content":"` + huge + `"}`)
|
||||
if !strings.Contains(got, "too large") {
|
||||
t.Fatalf("oversized post must error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardUnknownAction(t *testing.T) {
|
||||
_, run := newTestBlackboard(t)
|
||||
if got := run(`{"action":"bogus"}`); !strings.Contains(got, "unknown action") {
|
||||
t.Fatalf("unknown action must error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardNoRoot(t *testing.T) {
|
||||
tool := &BlackboardTool{}
|
||||
res, err := tool.Execute(context.Background(), "t", json.RawMessage(`{"action":"read"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
txt := res.Content[0].(agentcore.TextContent).Text
|
||||
if !strings.Contains(txt, "no blackboard root") {
|
||||
t.Fatalf("no-root read must error, got %q", txt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// This file implements the edit tool (US-017): exact string replacement within
|
||||
// a file. old_string must match exactly; if it is not unique (and replace_all
|
||||
// is false) the edit is rejected. A unified-style diff of the change is returned
|
||||
// for the UI to render. Paths resolve against a Root with the same traversal
|
||||
// guard as the read/write tools.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// EditTool performs exact string replacements in files under Root.
|
||||
type EditTool struct {
|
||||
// Root bounds all edits; a path resolving outside Root is rejected. Empty
|
||||
// Root defaults to the current working directory.
|
||||
Root string
|
||||
// ExtraRoots are additional trusted directories an edit may target even though
|
||||
// they lie outside Root. It exists for the skills directory so the model can
|
||||
// modify existing skills that live outside the workspace.
|
||||
ExtraRoots []string
|
||||
// Snap, when non-nil, records the file's prior content before it is edited so
|
||||
// the /rewind command can roll the change back. It is shared with the write tool.
|
||||
Snap *FileSnapshotRecorder
|
||||
}
|
||||
|
||||
// editToolArgs is the decoded argument shape for EditTool.
|
||||
type editToolArgs struct {
|
||||
Path string `json:"path"`
|
||||
OldString string `json:"old_string"`
|
||||
NewString string `json:"new_string"`
|
||||
ReplaceAll bool `json:"replace_all,omitempty"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *EditTool) Name() string { return "edit" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *EditTool) Description() string {
|
||||
return "Replace an exact string in a file. old_string must be unique unless " +
|
||||
"replace_all is set. Returns a diff of the change."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *EditTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to edit, relative to the workspace root."},
|
||||
"old_string": {"type": "string", "description": "Exact text to replace."},
|
||||
"new_string": {"type": "string", "description": "Replacement text."},
|
||||
"replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring a unique match."}
|
||||
},
|
||||
"required": ["path", "old_string", "new_string"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Edits mutate the filesystem → sequential.
|
||||
func (t *EditTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
|
||||
// resolveWithin boundary policy, so every file tool enforces the same
|
||||
// workspace-escape guard while edits can also reach trusted extra roots.
|
||||
func (t *EditTool) resolvePath(p string) (string, error) {
|
||||
if len(t.ExtraRoots) == 0 {
|
||||
return resolveWithin(t.Root, p)
|
||||
}
|
||||
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. Edit failures (no match, non-unique match,
|
||||
// missing file, out-of-root) are encoded as error results.
|
||||
func (t *EditTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[editToolArgs](args, "edit")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Path == "" {
|
||||
return errorResult("edit: path is required"), nil
|
||||
}
|
||||
if a.OldString == a.NewString {
|
||||
return errorResult("edit: old_string and new_string are identical; nothing to change"), nil
|
||||
}
|
||||
full, err := t.resolvePath(a.Path)
|
||||
if err != nil {
|
||||
return errorResult("edit: " + err.Error()), nil
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult(fmt.Sprintf("edit: file %q does not exist", a.Path)), nil
|
||||
}
|
||||
return errorResult(fmt.Sprintf("edit: cannot read %q: %v", a.Path, err)), nil
|
||||
}
|
||||
original := string(data)
|
||||
|
||||
count := strings.Count(original, a.OldString)
|
||||
if count == 0 {
|
||||
return errorResult(fmt.Sprintf("edit: old_string not found in %q", a.Path)), nil
|
||||
}
|
||||
if count > 1 && !a.ReplaceAll {
|
||||
return errorResult(fmt.Sprintf("edit: old_string is not unique in %q (%d matches); provide more context or set replace_all", a.Path, count)), nil
|
||||
}
|
||||
|
||||
var updated string
|
||||
if a.ReplaceAll {
|
||||
updated = strings.ReplaceAll(original, a.OldString, a.NewString)
|
||||
} else {
|
||||
updated = strings.Replace(original, a.OldString, a.NewString, 1)
|
||||
}
|
||||
|
||||
// Snapshot the prior state before mutating so /rewind can restore it.
|
||||
t.Snap.Record(full)
|
||||
if err := os.WriteFile(full, []byte(updated), filePerm); err != nil {
|
||||
return errorResult(fmt.Sprintf("edit: cannot write %q: %v", a.Path, err)), nil
|
||||
}
|
||||
|
||||
diff := unifiedDiff(a.Path, original, updated)
|
||||
replaced := 1
|
||||
if a.ReplaceAll {
|
||||
replaced = count
|
||||
}
|
||||
msg := fmt.Sprintf("Edited %s (%d replacement(s))\n%s", a.Path, replaced, diff)
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
Details: map[string]any{"path": a.Path, "replacements": replaced, "diff": diff},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// unifiedDiff produces a minimal line-based diff between old and new content.
|
||||
// It is not a full unified-diff implementation (no hunk coalescing); it emits a
|
||||
// header plus per-line -/+ markers, which is enough for a UI to render the
|
||||
// change. Unchanged lines are shown with a leading space for context.
|
||||
func unifiedDiff(path, oldContent, newContent string) string {
|
||||
oldLines := splitLinesKeep(oldContent)
|
||||
newLines := splitLinesKeep(newContent)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)
|
||||
|
||||
// Longest common subsequence over lines drives the -/+ markers.
|
||||
ops := diffLines(oldLines, newLines)
|
||||
for _, op := range ops {
|
||||
switch op.kind {
|
||||
case diffEqual:
|
||||
fmt.Fprintf(&b, " %s\n", op.text)
|
||||
case diffDelete:
|
||||
fmt.Fprintf(&b, "-%s\n", op.text)
|
||||
case diffInsert:
|
||||
fmt.Fprintf(&b, "+%s\n", op.text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitLinesKeep splits s into lines, dropping a single trailing newline so an
|
||||
// empty final element is not produced for the common "ends with \n" case.
|
||||
func splitLinesKeep(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
type diffKind int
|
||||
|
||||
const (
|
||||
diffEqual diffKind = iota
|
||||
diffDelete
|
||||
diffInsert
|
||||
)
|
||||
|
||||
type diffOp struct {
|
||||
kind diffKind
|
||||
text string
|
||||
}
|
||||
|
||||
// diffLines computes a line diff via a standard LCS dynamic-programming table,
|
||||
// then backtracks to emit equal/delete/insert ops in order.
|
||||
func diffLines(a, b []string) []diffOp {
|
||||
n, m := len(a), len(b)
|
||||
// lcs[i][j] = length of LCS of a[i:] and b[j:].
|
||||
lcs := make([][]int, n+1)
|
||||
for i := range lcs {
|
||||
lcs[i] = make([]int, m+1)
|
||||
}
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
for j := m - 1; j >= 0; j-- {
|
||||
if a[i] == b[j] {
|
||||
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||
lcs[i][j] = lcs[i+1][j]
|
||||
} else {
|
||||
lcs[i][j] = lcs[i][j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
var ops []diffOp
|
||||
i, j := 0, 0
|
||||
for i < n && j < m {
|
||||
if a[i] == b[j] {
|
||||
ops = append(ops, diffOp{diffEqual, a[i]})
|
||||
i++
|
||||
j++
|
||||
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||
ops = append(ops, diffOp{diffDelete, a[i]})
|
||||
i++
|
||||
} else {
|
||||
ops = append(ops, diffOp{diffInsert, b[j]})
|
||||
j++
|
||||
}
|
||||
}
|
||||
for ; i < n; i++ {
|
||||
ops = append(ops, diffOp{diffDelete, a[i]})
|
||||
}
|
||||
for ; j < m; j++ {
|
||||
ops = append(ops, diffOp{diffInsert, b[j]})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runEdit(t *testing.T, tool *EditTool, args map[string]any) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("execute returned go error: %v", gerr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func seedFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestEditToolUniqueMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := seedFile(t, dir, "f.txt", "alpha\nbeta\ngamma\n")
|
||||
tool := &EditTool{Root: dir}
|
||||
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "beta", "new_string": "BETA"})
|
||||
if strings.Contains(resultText(res), "not found") || strings.Contains(resultText(res), "not unique") {
|
||||
t.Fatalf("unexpected error: %q", resultText(res))
|
||||
}
|
||||
got, _ := os.ReadFile(p)
|
||||
if string(got) != "alpha\nBETA\ngamma\n" {
|
||||
t.Errorf("content = %q", got)
|
||||
}
|
||||
// Diff present.
|
||||
if !strings.Contains(resultText(res), "-beta") || !strings.Contains(resultText(res), "+BETA") {
|
||||
t.Errorf("diff missing markers: %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolNonUniqueErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedFile(t, dir, "f.txt", "x\nx\nx\n")
|
||||
tool := &EditTool{Root: dir}
|
||||
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "x", "new_string": "y"})
|
||||
if !strings.Contains(resultText(res), "not unique") {
|
||||
t.Errorf("expected non-unique error, got %q", resultText(res))
|
||||
}
|
||||
// File unchanged.
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "f.txt"))
|
||||
if string(got) != "x\nx\nx\n" {
|
||||
t.Errorf("file should be unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolReplaceAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := seedFile(t, dir, "f.txt", "x\nx\nx\n")
|
||||
tool := &EditTool{Root: dir}
|
||||
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "x", "new_string": "y", "replace_all": true})
|
||||
got, _ := os.ReadFile(p)
|
||||
if string(got) != "y\ny\ny\n" {
|
||||
t.Errorf("content = %q, want all replaced", got)
|
||||
}
|
||||
details, ok := res.Details.(map[string]any)
|
||||
if !ok || details["replacements"] != 3 {
|
||||
t.Errorf("expected 3 replacements, details = %+v", res.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedFile(t, dir, "f.txt", "hello\n")
|
||||
tool := &EditTool{Root: dir}
|
||||
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "missing", "new_string": "x"})
|
||||
if !strings.Contains(resultText(res), "not found") {
|
||||
t.Errorf("expected not-found error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolMissingFile(t *testing.T) {
|
||||
tool := &EditTool{Root: t.TempDir()}
|
||||
res := runEdit(t, tool, map[string]any{"path": "nope.txt", "old_string": "a", "new_string": "b"})
|
||||
if !strings.Contains(resultText(res), "does not exist") {
|
||||
t.Errorf("expected does-not-exist, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolIdenticalStrings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedFile(t, dir, "f.txt", "a\n")
|
||||
tool := &EditTool{Root: dir}
|
||||
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "a", "new_string": "a"})
|
||||
if !strings.Contains(resultText(res), "identical") {
|
||||
t.Errorf("expected identical error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tool := &EditTool{Root: dir}
|
||||
res := runEdit(t, tool, map[string]any{"path": "../x.txt", "old_string": "a", "new_string": "b"})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Errorf("expected boundary error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolExtraRootsAllowsSkillModification(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
skills := t.TempDir()
|
||||
skillFile := filepath.Join(skills, "weather", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(skillFile, []byte("old body\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed skill: %v", err)
|
||||
}
|
||||
|
||||
// Without ExtraRoots the out-of-workspace skill edit is rejected.
|
||||
bounded := &EditTool{Root: work}
|
||||
res := runEdit(t, bounded, map[string]any{"path": skillFile, "old_string": "old body", "new_string": "new body"})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
|
||||
}
|
||||
|
||||
// With the skills dir as an extra root the edit applies.
|
||||
tool := &EditTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res = runEdit(t, tool, map[string]any{"path": skillFile, "old_string": "old body", "new_string": "new body"})
|
||||
if strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Fatalf("edit still blocked with ExtraRoots: %q", resultText(res))
|
||||
}
|
||||
got, _ := os.ReadFile(skillFile)
|
||||
if !strings.Contains(string(got), "new body") {
|
||||
t.Fatalf("skill not modified, content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditToolMode(t *testing.T) {
|
||||
tool := &EditTool{}
|
||||
if tool.Name() != "edit" {
|
||||
t.Errorf("name = %q", tool.Name())
|
||||
}
|
||||
if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
|
||||
t.Error("edit should be sequential")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Errorf("schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedDiff(t *testing.T) {
|
||||
diff := unifiedDiff("f.txt", "a\nb\nc\n", "a\nB\nc\n")
|
||||
if !strings.Contains(diff, "--- a/f.txt") || !strings.Contains(diff, "+++ b/f.txt") {
|
||||
t.Errorf("missing header: %q", diff)
|
||||
}
|
||||
if !strings.Contains(diff, "-b") || !strings.Contains(diff, "+B") {
|
||||
t.Errorf("missing change lines: %q", diff)
|
||||
}
|
||||
// Unchanged context lines carry a leading space.
|
||||
if !strings.Contains(diff, " a") || !strings.Contains(diff, " c") {
|
||||
t.Errorf("missing context lines: %q", diff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// This file implements FileSnapshotRecorder, the edit-rewind journal backing the
|
||||
// /rewind command. Before the write and edit tools mutate a file they call
|
||||
// Record(absPath), which captures the file's prior content (or notes that it did
|
||||
// not exist). Snapshots accumulate per turn; Commit groups the turn's snapshots
|
||||
// into a RestorePoint tagged with the conversation leaf that preceded the turn.
|
||||
// Restore replays a suffix of the restore points in reverse to roll the working
|
||||
// tree back to an earlier state, mirroring Claude Code's Esc-Esc rewind. The
|
||||
// journal is in-memory and scoped to the running session; only pigo's own
|
||||
// write/edit tools are captured (arbitrary bash edits are not).
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// snapshotMaxBytes caps how large a file may be for its prior content to be held
|
||||
// in the rewind journal. A file above this is still recorded (so rewind knows it
|
||||
// changed) but its content is not retained, and rewind reports it as skipped
|
||||
// rather than clobbering it with stale bytes.
|
||||
const snapshotMaxBytes = 16 * 1024 * 1024
|
||||
|
||||
// fileSnapshot is the pre-mutation state of a single file: its content before the
|
||||
// first write/edit of a turn, or a marker that it did not yet exist (so rewind
|
||||
// deletes it). TooLarge marks a file that exceeded snapshotMaxBytes, whose
|
||||
// content was not retained.
|
||||
type fileSnapshot struct {
|
||||
Path string
|
||||
Existed bool
|
||||
TooLarge bool
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// RestorePoint is one turn's worth of file snapshots plus the conversation leaf
|
||||
// that preceded the turn. Rewinding to it restores every file to its Snapshots
|
||||
// state and moves the active conversation leaf back to LeafID.
|
||||
type RestorePoint struct {
|
||||
Seq int
|
||||
Time time.Time
|
||||
LeafID string
|
||||
Label string
|
||||
Snapshots []fileSnapshot
|
||||
}
|
||||
|
||||
// FileSnapshotRecorder captures prior file content before write/edit mutations
|
||||
// and groups it into per-turn RestorePoints. Its methods are safe for concurrent
|
||||
// use so parallel tool calls within a turn can record without racing.
|
||||
type FileSnapshotRecorder struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]fileSnapshot // absolute path -> first snapshot this turn
|
||||
order []string // first-touch order within the turn
|
||||
points []RestorePoint
|
||||
nextSeq int
|
||||
}
|
||||
|
||||
// NewFileSnapshotRecorder returns an empty recorder ready to record the first
|
||||
// turn's mutations.
|
||||
func NewFileSnapshotRecorder() *FileSnapshotRecorder {
|
||||
return &FileSnapshotRecorder{pending: map[string]fileSnapshot{}, nextSeq: 1}
|
||||
}
|
||||
|
||||
// Record captures the current on-disk state of absPath before it is mutated. Only
|
||||
// the first call for a given path within a turn is retained, so the snapshot
|
||||
// reflects the state at the turn's start (later mutations in the same turn are
|
||||
// rolled back to that same baseline). A nil recorder is a no-op, so tools can
|
||||
// hold an always-safe optional handle.
|
||||
func (r *FileSnapshotRecorder) Record(absPath string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, seen := r.pending[absPath]; seen {
|
||||
return
|
||||
}
|
||||
snap := fileSnapshot{Path: absPath}
|
||||
info, err := os.Stat(absPath)
|
||||
switch {
|
||||
case err != nil:
|
||||
// Treat any stat error (including not-exist) as "did not exist": rewind will
|
||||
// delete the file created this turn.
|
||||
snap.Existed = false
|
||||
case info.IsDir():
|
||||
// A directory is never written by the file tools; skip content capture.
|
||||
snap.Existed = true
|
||||
snap.TooLarge = true
|
||||
case info.Size() > snapshotMaxBytes:
|
||||
snap.Existed = true
|
||||
snap.TooLarge = true
|
||||
default:
|
||||
data, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil {
|
||||
snap.Existed = true
|
||||
snap.TooLarge = true
|
||||
} else {
|
||||
snap.Existed = true
|
||||
snap.Content = data
|
||||
}
|
||||
}
|
||||
r.pending[absPath] = snap
|
||||
r.order = append(r.order, absPath)
|
||||
}
|
||||
|
||||
// Commit closes the current turn: if any files were recorded it appends a
|
||||
// RestorePoint tagged with leafID (the conversation leaf before the turn) and
|
||||
// label (a short description, e.g. the user prompt), then clears the pending
|
||||
// buffer. A turn that mutated no files creates no restore point. It reports
|
||||
// whether a restore point was created.
|
||||
func (r *FileSnapshotRecorder) Commit(leafID, label string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.order) == 0 {
|
||||
return false
|
||||
}
|
||||
snaps := make([]fileSnapshot, 0, len(r.order))
|
||||
for _, p := range r.order {
|
||||
snaps = append(snaps, r.pending[p])
|
||||
}
|
||||
r.points = append(r.points, RestorePoint{
|
||||
Seq: r.nextSeq,
|
||||
Time: time.Now().UTC(),
|
||||
LeafID: leafID,
|
||||
Label: label,
|
||||
Snapshots: snaps,
|
||||
})
|
||||
r.nextSeq++
|
||||
r.pending = map[string]fileSnapshot{}
|
||||
r.order = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Points returns a copy of the committed restore points, oldest first.
|
||||
func (r *FileSnapshotRecorder) Points() []RestorePoint {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]RestorePoint, len(r.points))
|
||||
copy(out, r.points)
|
||||
return out
|
||||
}
|
||||
|
||||
// Restore rolls the working tree back to the state before the restore point at
|
||||
// index idx (0-based into the Points slice). It replays that point and every
|
||||
// later point in reverse, restoring each file's prior content (or deleting files
|
||||
// that did not exist), then drops those points from the journal so the next
|
||||
// rewind starts from the new tip. It returns the conversation leaf to switch to
|
||||
// (the target point's LeafID), the list of restored file paths, and any
|
||||
// non-fatal warnings (e.g. files skipped because they were too large or a
|
||||
// restore write failed).
|
||||
func (r *FileSnapshotRecorder) Restore(idx int) (leafID string, restored []string, warnings []string, err error) {
|
||||
if r == nil {
|
||||
return "", nil, nil, fmt.Errorf("no restore points")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if idx < 0 || idx >= len(r.points) {
|
||||
return "", nil, nil, fmt.Errorf("restore point %d out of range (have %d)", idx+1, len(r.points))
|
||||
}
|
||||
leafID = r.points[idx].LeafID
|
||||
|
||||
// A file touched across several turns must end at its OLDEST (pre-target)
|
||||
// baseline. Iterate points oldest→newest and keep only the first snapshot seen
|
||||
// for each path, so the earliest baseline is the one applied.
|
||||
applied := map[string]bool{}
|
||||
for i := idx; i < len(r.points); i++ {
|
||||
for _, s := range r.points[i].Snapshots {
|
||||
if applied[s.Path] {
|
||||
continue
|
||||
}
|
||||
applied[s.Path] = true
|
||||
if w := applySnapshot(s); w != "" {
|
||||
warnings = append(warnings, w)
|
||||
continue
|
||||
}
|
||||
restored = append(restored, s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
r.points = r.points[:idx]
|
||||
if len(r.points) > 0 {
|
||||
r.nextSeq = r.points[len(r.points)-1].Seq + 1
|
||||
} else {
|
||||
r.nextSeq = 1
|
||||
}
|
||||
return leafID, restored, warnings, nil
|
||||
}
|
||||
|
||||
// applySnapshot restores one file to its recorded prior state: rewrite the prior
|
||||
// content, or delete the file if it did not exist before. It returns a warning
|
||||
// string when the file cannot be safely restored (too large to have retained
|
||||
// content, or a filesystem error), or "" on success.
|
||||
func applySnapshot(s fileSnapshot) string {
|
||||
if s.TooLarge {
|
||||
return fmt.Sprintf("%s: skipped (too large to snapshot; left unchanged)", s.Path)
|
||||
}
|
||||
if !s.Existed {
|
||||
if err := os.Remove(s.Path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Sprintf("%s: could not delete: %v", s.Path, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if err := os.WriteFile(s.Path, s.Content, filePerm); err != nil {
|
||||
return fmt.Sprintf("%s: could not restore: %v", s.Path, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Tests for the file-snapshot rewind journal: per-turn dedup of recorded paths,
|
||||
// commit grouping (an untouched turn produces no restore point), and restore
|
||||
// semantics — reverse replay across turns rolls a file to its oldest baseline,
|
||||
// files that did not exist are deleted, and the journal is truncated to the tip.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFileT(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFileT(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// A turn's first Record for a path is the baseline; later Records that turn are
|
||||
// ignored, and Commit groups the turn's files into one point.
|
||||
func TestRecorderRecordDedupAndCommit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "a.txt")
|
||||
writeFileT(t, f, "v0")
|
||||
|
||||
r := NewFileSnapshotRecorder()
|
||||
r.Record(f) // baseline "v0"
|
||||
writeFileT(t, f, "v1") // model's first edit
|
||||
r.Record(f) // second edit same turn — must be ignored
|
||||
writeFileT(t, f, "v2")
|
||||
|
||||
if !r.Commit("leaf0", "edit a") {
|
||||
t.Fatal("Commit reported no restore point despite a recorded file")
|
||||
}
|
||||
// An untouched turn creates nothing.
|
||||
if r.Commit("leaf1", "no edits") {
|
||||
t.Fatal("Commit created a restore point for a turn with no records")
|
||||
}
|
||||
points := r.Points()
|
||||
if len(points) != 1 || len(points[0].Snapshots) != 1 {
|
||||
t.Fatalf("want 1 point with 1 snapshot, got %+v", points)
|
||||
}
|
||||
if got := string(points[0].Snapshots[0].Content); got != "v0" {
|
||||
t.Errorf("baseline content = %q, want v0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Restoring rolls files back and deletes ones that did not exist before, and
|
||||
// returns the pre-turn leaf id.
|
||||
func TestRecorderRestore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
existing := filepath.Join(dir, "keep.txt")
|
||||
created := filepath.Join(dir, "new.txt")
|
||||
writeFileT(t, existing, "orig")
|
||||
|
||||
r := NewFileSnapshotRecorder()
|
||||
|
||||
// Turn 1: edit an existing file.
|
||||
r.Record(existing)
|
||||
writeFileT(t, existing, "edited")
|
||||
r.Commit("leafA", "turn1")
|
||||
|
||||
// Turn 2: create a brand-new file.
|
||||
r.Record(created)
|
||||
writeFileT(t, created, "brand new")
|
||||
r.Commit("leafB", "turn2")
|
||||
|
||||
// Rewind to before turn 1 (index 0): both turns roll back.
|
||||
leaf, restored, warnings, err := r.Restore(0)
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
if leaf != "leafA" {
|
||||
t.Errorf("target leaf = %q, want leafA", leaf)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Errorf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
if len(restored) != 2 {
|
||||
t.Errorf("restored %d files, want 2", len(restored))
|
||||
}
|
||||
if got := readFileT(t, existing); got != "orig" {
|
||||
t.Errorf("existing file = %q, want orig", got)
|
||||
}
|
||||
if _, err := os.Stat(created); !os.IsNotExist(err) {
|
||||
t.Errorf("created file should have been deleted, stat err = %v", err)
|
||||
}
|
||||
if len(r.Points()) != 0 {
|
||||
t.Errorf("journal should be empty after restoring from index 0")
|
||||
}
|
||||
}
|
||||
|
||||
// When a file is edited across several turns, restoring to before the earliest
|
||||
// of them lands it at its oldest baseline (not an intermediate version).
|
||||
func TestRecorderRestoreOldestBaselineWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "a.txt")
|
||||
writeFileT(t, f, "v0")
|
||||
|
||||
r := NewFileSnapshotRecorder()
|
||||
r.Record(f) // baseline v0
|
||||
writeFileT(t, f, "v1")
|
||||
r.Commit("leaf0", "t1")
|
||||
|
||||
r.Record(f) // baseline v1
|
||||
writeFileT(t, f, "v2")
|
||||
r.Commit("leaf1", "t2")
|
||||
|
||||
// Rewind to before t2 only (index 1): file returns to v1.
|
||||
if _, _, _, err := r.Restore(1); err != nil {
|
||||
t.Fatalf("Restore(1): %v", err)
|
||||
}
|
||||
if got := readFileT(t, f); got != "v1" {
|
||||
t.Errorf("after rewind to before t2, file = %q, want v1", got)
|
||||
}
|
||||
// One point remains (t1); rewind it too → v0.
|
||||
if _, _, _, err := r.Restore(0); err != nil {
|
||||
t.Fatalf("Restore(0): %v", err)
|
||||
}
|
||||
if got := readFileT(t, f); got != "v0" {
|
||||
t.Errorf("after full rewind, file = %q, want v0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorderRestoreOutOfRange(t *testing.T) {
|
||||
r := NewFileSnapshotRecorder()
|
||||
if _, _, _, err := r.Restore(0); err == nil {
|
||||
t.Error("Restore on empty journal should error")
|
||||
}
|
||||
}
|
||||
|
||||
// A nil recorder is a safe no-op so tools can hold an optional handle.
|
||||
func TestRecorderNilSafe(t *testing.T) {
|
||||
var r *FileSnapshotRecorder
|
||||
r.Record("/nonexistent")
|
||||
if r.Commit("x", "y") {
|
||||
t.Error("nil Commit should report no point")
|
||||
}
|
||||
if r.Points() != nil {
|
||||
t.Error("nil Points should be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// This file implements the goal state and the two goal-control tools that power
|
||||
// the /goal command (mirrors pi-goal / Claude Code's goal mode): given a high-level
|
||||
// objective, the agent runs autonomously — re-prompted turn after turn — until
|
||||
// it either declares the goal done (goal_complete), hits a true impasse
|
||||
// (goal_blocked), or a safety guard / token budget stops it.
|
||||
//
|
||||
// The tools live here (rather than in the REPL) because they are ordinary
|
||||
// AgentTools the model invokes, and because the runtime's GoalReminderProvider
|
||||
// needs to read the same state — mirroring how TodoTool/TodoStore pairs with
|
||||
// TodoReminderProvider. GoalState is the shared, concurrency-safe handle both
|
||||
// the tools (which may run in a batch) and the REPL/reminder (which reads it
|
||||
// each turn) touch.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// GoalStatus is the lifecycle state of the active goal.
|
||||
type GoalStatus string
|
||||
|
||||
const (
|
||||
// GoalIdle means no goal is set (the zero value).
|
||||
GoalIdle GoalStatus = ""
|
||||
// GoalActive means the agent is autonomously working toward the goal.
|
||||
GoalActive GoalStatus = "active"
|
||||
// GoalPaused means autonomous continuation stopped (a safety guard fired, or
|
||||
// the user paused it); it can be resumed.
|
||||
GoalPaused GoalStatus = "paused"
|
||||
// GoalBlocked means the agent hit a true impasse (goal_blocked was called).
|
||||
GoalBlocked GoalStatus = "blocked"
|
||||
// GoalComplete means the agent declared the goal done (goal_complete).
|
||||
GoalComplete GoalStatus = "complete"
|
||||
// GoalBudgetLimited means the token budget was exhausted before completion.
|
||||
GoalBudgetLimited GoalStatus = "budget_limited"
|
||||
)
|
||||
|
||||
// GoalState holds the current goal for a REPL session. It is safe for concurrent
|
||||
// use so the goal tools (which may run in a batch) and the REPL/reminder reader
|
||||
// can touch it without racing. A single state is shared for a session's
|
||||
// lifetime; /goal clear resets it to the idle zero value.
|
||||
type GoalState struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
id string
|
||||
objective string
|
||||
summary string // set by goal_complete
|
||||
blockReason string // set by goal_blocked
|
||||
status GoalStatus
|
||||
|
||||
iterations int // autonomous continuations issued so far
|
||||
noProgress int // consecutive settles with no tool activity
|
||||
|
||||
tokenBudget int // 0 = unlimited
|
||||
tokensUsed int
|
||||
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
// NewGoalState returns an empty (idle) goal state.
|
||||
func NewGoalState() *GoalState { return &GoalState{} }
|
||||
|
||||
// GoalSnapshot is an immutable copy of the goal state for display/decisions.
|
||||
type GoalSnapshot struct {
|
||||
ID string
|
||||
Objective string
|
||||
Summary string
|
||||
BlockReason string
|
||||
Status GoalStatus
|
||||
Iterations int
|
||||
NoProgress int
|
||||
TokenBudget int
|
||||
TokensUsed int
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// Start (re)initializes the state for a new objective, moving it to active. It
|
||||
// resets all counters so a fresh goal never inherits a prior goal's tallies.
|
||||
func (s *GoalState) Start(id, objective string, tokenBudget int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = id
|
||||
s.objective = objective
|
||||
s.summary = ""
|
||||
s.blockReason = ""
|
||||
s.status = GoalActive
|
||||
s.iterations = 0
|
||||
s.noProgress = 0
|
||||
s.tokenBudget = tokenBudget
|
||||
s.tokensUsed = 0
|
||||
s.startedAt = time.Now()
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the current state, safe to read without a lock.
|
||||
func (s *GoalState) Snapshot() GoalSnapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return GoalSnapshot{
|
||||
ID: s.id,
|
||||
Objective: s.objective,
|
||||
Summary: s.summary,
|
||||
BlockReason: s.blockReason,
|
||||
Status: s.status,
|
||||
Iterations: s.iterations,
|
||||
NoProgress: s.noProgress,
|
||||
TokenBudget: s.tokenBudget,
|
||||
TokensUsed: s.tokensUsed,
|
||||
StartedAt: s.startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets the state to idle (no goal). It zeroes the fields individually
|
||||
// rather than replacing the whole struct so the embedded mutex (currently held)
|
||||
// is preserved — overwriting it while locked would corrupt the lock.
|
||||
func (s *GoalState) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = ""
|
||||
s.objective = ""
|
||||
s.summary = ""
|
||||
s.blockReason = ""
|
||||
s.status = GoalIdle
|
||||
s.iterations = 0
|
||||
s.noProgress = 0
|
||||
s.tokenBudget = 0
|
||||
s.tokensUsed = 0
|
||||
s.startedAt = time.Time{}
|
||||
}
|
||||
|
||||
// SetStatus transitions the goal to a new status (used by the REPL when a safety
|
||||
// guard fires or the user pauses/resumes).
|
||||
func (s *GoalState) SetStatus(status GoalStatus) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.status = status
|
||||
}
|
||||
|
||||
// Resume reactivates a paused or budget-limited goal and clears the transient
|
||||
// safety-guard counters (iterations, no-progress) that stopped it, so the run
|
||||
// gets a fresh allowance rather than immediately re-tripping the same guard. The
|
||||
// token budget is intentionally reset too (tokensUsed → 0): resuming past an
|
||||
// exhausted budget is an explicit user decision to grant another window. The
|
||||
// objective and id are preserved. It is a no-op-safe wrapper — callers gate on
|
||||
// the current status before invoking it.
|
||||
func (s *GoalState) Resume() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.status = GoalActive
|
||||
s.iterations = 0
|
||||
s.noProgress = 0
|
||||
s.tokensUsed = 0
|
||||
}
|
||||
|
||||
// ID returns the current goal id (empty when idle).
|
||||
func (s *GoalState) ID() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.id
|
||||
}
|
||||
|
||||
// RecordIteration increments the autonomous-continuation counter and folds the
|
||||
// given output-token delta into the running total. hadToolActivity resets the
|
||||
// no-progress counter when true, else increments it — so a run of tool-free
|
||||
// turns can trip the no-progress guard.
|
||||
func (s *GoalState) RecordIteration(outputTokens int, hadToolActivity bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.iterations++
|
||||
s.tokensUsed += outputTokens
|
||||
if hadToolActivity {
|
||||
s.noProgress = 0
|
||||
} else {
|
||||
s.noProgress++
|
||||
}
|
||||
}
|
||||
|
||||
// MarkComplete records the completion summary and moves the goal to complete.
|
||||
func (s *GoalState) MarkComplete(summary string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.summary = summary
|
||||
s.status = GoalComplete
|
||||
}
|
||||
|
||||
// MarkBlocked records the block reason and moves the goal to blocked.
|
||||
func (s *GoalState) MarkBlocked(reason string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.blockReason = reason
|
||||
s.status = GoalBlocked
|
||||
}
|
||||
|
||||
// terminate is the shared *bool=true value returned by both goal tools to end
|
||||
// the run immediately (the loop terminates when every result in a batch has
|
||||
// Terminate=true; a goal tool is expected to be the sole call in its turn).
|
||||
func terminatePtr() *bool { b := true; return &b }
|
||||
|
||||
// contradictorySummary reports whether a goal_complete summary plainly claims the
|
||||
// goal is NOT done — a guard against the model closing a goal it just admitted
|
||||
// is unfinished. The check is a conservative substring match on well-known
|
||||
// negative phrasings (English), matching pi-goal's "plainly contradictory
|
||||
// summary" rejection.
|
||||
func contradictorySummary(summary string) bool {
|
||||
lower := strings.ToLower(summary)
|
||||
for _, bad := range []string{
|
||||
"not complete",
|
||||
"not done",
|
||||
"incomplete",
|
||||
"tests still fail",
|
||||
"tests fail",
|
||||
"still failing",
|
||||
"could not",
|
||||
"couldn't",
|
||||
"unable to",
|
||||
"unfinished",
|
||||
"did not finish",
|
||||
"failed to",
|
||||
"cannot complete",
|
||||
"still fails",
|
||||
"test failure",
|
||||
} {
|
||||
if strings.Contains(lower, bad) || strings.Contains(summary, bad) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GoalCompleteTool lets the model declare the active goal finished. It records
|
||||
// the summary, moves the state to complete, and terminates the run.
|
||||
type GoalCompleteTool struct {
|
||||
// State is the session goal state. Must be non-nil.
|
||||
State *GoalState
|
||||
}
|
||||
|
||||
type goalCompleteArgs struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *GoalCompleteTool) Name() string { return "goal_complete" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *GoalCompleteTool) Description() string {
|
||||
return "Declare the current goal COMPLETE. Call this ONLY after verifying, " +
|
||||
"requirement by requirement, that the objective is fully met — treat the " +
|
||||
"working tree, tests, and actual runtime behavior as authoritative, not " +
|
||||
"the prior conversation. Provide a concise summary of what was accomplished. " +
|
||||
"Do NOT call this if any requirement is unmet, tests fail, or work remains; " +
|
||||
"use goal_blocked for a true impasse instead. Calling this ends the run."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *GoalCompleteTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string", "description": "Concise summary of what was accomplished to satisfy the goal."}
|
||||
},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. It mutates shared goal state → sequential.
|
||||
func (t *GoalCompleteTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It validates the summary (non-empty and not
|
||||
// plainly contradictory), records completion, and terminates the run. Invalid
|
||||
// input degrades to an error result (not a Go error) so the model can retry.
|
||||
func (t *GoalCompleteTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[goalCompleteArgs](args, "goal_complete")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if t.State == nil {
|
||||
return errorResult("goal_complete: no active goal"), nil
|
||||
}
|
||||
summary := strings.TrimSpace(a.Summary)
|
||||
if summary == "" {
|
||||
return errorResult("goal_complete: summary must not be empty"), nil
|
||||
}
|
||||
if contradictorySummary(summary) {
|
||||
return errorResult("goal_complete: summary indicates the goal is NOT complete; " +
|
||||
"keep working, or call goal_blocked with evidence if truly stuck"), nil
|
||||
}
|
||||
snap := t.State.Snapshot()
|
||||
if snap.Status == GoalIdle {
|
||||
return errorResult("goal_complete: no active goal"), nil
|
||||
}
|
||||
t.State.MarkComplete(summary)
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent("Goal marked complete: " + summary)},
|
||||
Terminate: terminatePtr(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GoalBlockedTool lets the model report a true impasse it cannot work around. It
|
||||
// records the reason, moves the state to blocked, and terminates the run.
|
||||
type GoalBlockedTool struct {
|
||||
// State is the session goal state. Must be non-nil.
|
||||
State *GoalState
|
||||
}
|
||||
|
||||
type goalBlockedArgs struct {
|
||||
Reason string `json:"reason"`
|
||||
Evidence string `json:"evidence"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *GoalBlockedTool) Name() string { return "goal_blocked" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *GoalBlockedTool) Description() string {
|
||||
return "Report that the current goal is BLOCKED by a true impasse you cannot " +
|
||||
"resolve (e.g. missing credentials, an external dependency you cannot " +
|
||||
"install, contradictory requirements). Provide a concrete reason and the " +
|
||||
"evidence that establishes the blocker. Use this only as a last resort — " +
|
||||
"prefer trying a different approach first. Calling this ends the run."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *GoalBlockedTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {"type": "string", "description": "Concise statement of what blocks the goal."},
|
||||
"evidence": {"type": "string", "description": "Concrete evidence establishing the blocker (error output, missing file, etc.)."}
|
||||
},
|
||||
"required": ["reason", "evidence"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. It mutates shared goal state → sequential.
|
||||
func (t *GoalBlockedTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It validates the reason/evidence, records the
|
||||
// block, and terminates the run.
|
||||
func (t *GoalBlockedTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[goalBlockedArgs](args, "goal_blocked")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if t.State == nil {
|
||||
return errorResult("goal_blocked: no active goal"), nil
|
||||
}
|
||||
reason := strings.TrimSpace(a.Reason)
|
||||
if reason == "" {
|
||||
return errorResult("goal_blocked: reason must not be empty"), nil
|
||||
}
|
||||
if strings.TrimSpace(a.Evidence) == "" {
|
||||
return errorResult("goal_blocked: evidence must not be empty"), nil
|
||||
}
|
||||
snap := t.State.Snapshot()
|
||||
if snap.Status == GoalIdle {
|
||||
return errorResult("goal_blocked: no active goal"), nil
|
||||
}
|
||||
t.State.MarkBlocked(reason)
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent("Goal marked blocked: " + reason)},
|
||||
Terminate: terminatePtr(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Tests for the goal tools (mirrors pi-goal): goal_complete validation (empty and
|
||||
// contradictory summaries are rejected, a valid summary marks complete and
|
||||
// terminates the run), goal_blocked validation, and GoalState counter/lifecycle
|
||||
// behavior. Mirrors todo_tool_test.go's structure.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// execGoalComplete runs the goal_complete tool with the given JSON args.
|
||||
func execGoalComplete(t *testing.T, tool *GoalCompleteTool, args string) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
res, err := tool.Execute(context.Background(), "call-1", json.RawMessage(args), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned Go error: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func isErrorResult(res agentcore.AgentToolResult) bool {
|
||||
// An error result carries no Terminate and its text is the error message;
|
||||
// the tools return errorResult(...) which has Terminate=nil.
|
||||
return res.Terminate == nil
|
||||
}
|
||||
|
||||
func TestGoalToolsRegister(t *testing.T) {
|
||||
reg := NewToolRegistry()
|
||||
st := NewGoalState()
|
||||
if err := reg.Register(&GoalCompleteTool{State: st}); err != nil {
|
||||
t.Fatalf("Register goal_complete: %v", err)
|
||||
}
|
||||
if err := reg.Register(&GoalBlockedTool{State: st}); err != nil {
|
||||
t.Fatalf("Register goal_blocked: %v", err)
|
||||
}
|
||||
if _, ok := reg.Get("goal_complete"); !ok {
|
||||
t.Fatal("goal_complete not found after Register")
|
||||
}
|
||||
if _, ok := reg.Get("goal_blocked"); !ok {
|
||||
t.Fatal("goal_blocked not found after Register")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalCompleteValidSummary(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "do the thing", 0)
|
||||
tool := &GoalCompleteTool{State: st}
|
||||
|
||||
res := execGoalComplete(t, tool, `{"summary":"created hello.txt with the requested contents"}`)
|
||||
if res.Terminate == nil || !*res.Terminate {
|
||||
t.Fatalf("expected Terminate=true, got %v", res.Terminate)
|
||||
}
|
||||
if snap := st.Snapshot(); snap.Status != GoalComplete {
|
||||
t.Errorf("status = %q, want complete", snap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalCompleteRejectsEmpty(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "do the thing", 0)
|
||||
tool := &GoalCompleteTool{State: st}
|
||||
|
||||
res := execGoalComplete(t, tool, `{"summary":" "}`)
|
||||
if !isErrorResult(res) {
|
||||
t.Fatal("expected error result for empty summary")
|
||||
}
|
||||
if snap := st.Snapshot(); snap.Status != GoalActive {
|
||||
t.Errorf("status = %q, want still active after rejected summary", snap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalCompleteRejectsContradictory(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "do the thing", 0)
|
||||
tool := &GoalCompleteTool{State: st}
|
||||
|
||||
for _, summary := range []string{
|
||||
`{"summary":"the goal is not complete yet"}`,
|
||||
`{"summary":"tests still fail but I stopped"}`,
|
||||
`{"summary":"the task is unfinished"}`,
|
||||
} {
|
||||
res := execGoalComplete(t, tool, summary)
|
||||
if !isErrorResult(res) {
|
||||
t.Fatalf("expected error result for contradictory summary %s", summary)
|
||||
}
|
||||
}
|
||||
if snap := st.Snapshot(); snap.Status != GoalActive {
|
||||
t.Errorf("status = %q, want still active", snap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalBlockedRecordsReason(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "do the thing", 0)
|
||||
tool := &GoalBlockedTool{State: st}
|
||||
|
||||
res, err := tool.Execute(context.Background(), "c1",
|
||||
json.RawMessage(`{"reason":"missing API key","evidence":"env AUTH_TOKEN is empty"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Terminate == nil || !*res.Terminate {
|
||||
t.Fatalf("expected Terminate=true, got %v", res.Terminate)
|
||||
}
|
||||
snap := st.Snapshot()
|
||||
if snap.Status != GoalBlocked {
|
||||
t.Errorf("status = %q, want blocked", snap.Status)
|
||||
}
|
||||
if snap.BlockReason != "missing API key" {
|
||||
t.Errorf("block reason = %q", snap.BlockReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalBlockedRequiresEvidence(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "do the thing", 0)
|
||||
tool := &GoalBlockedTool{State: st}
|
||||
|
||||
res, err := tool.Execute(context.Background(), "c1",
|
||||
json.RawMessage(`{"reason":"stuck","evidence":""}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !isErrorResult(res) {
|
||||
t.Fatal("expected error result when evidence is empty")
|
||||
}
|
||||
if snap := st.Snapshot(); snap.Status != GoalActive {
|
||||
t.Errorf("status = %q, want still active", snap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalStateRecordIteration(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "obj", 1000)
|
||||
|
||||
st.RecordIteration(100, true) // tool activity resets no-progress
|
||||
st.RecordIteration(50, false) // no tool activity
|
||||
st.RecordIteration(50, false)
|
||||
|
||||
snap := st.Snapshot()
|
||||
if snap.Iterations != 3 {
|
||||
t.Errorf("iterations = %d, want 3", snap.Iterations)
|
||||
}
|
||||
if snap.TokensUsed != 200 {
|
||||
t.Errorf("tokensUsed = %d, want 200", snap.TokensUsed)
|
||||
}
|
||||
if snap.NoProgress != 2 {
|
||||
t.Errorf("noProgress = %d, want 2", snap.NoProgress)
|
||||
}
|
||||
if snap.TokenBudget != 1000 {
|
||||
t.Errorf("tokenBudget = %d, want 1000", snap.TokenBudget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalStateClear(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "obj", 0)
|
||||
st.Clear()
|
||||
if snap := st.Snapshot(); snap.Status != GoalIdle || snap.Objective != "" {
|
||||
t.Errorf("after Clear: status=%q objective=%q, want idle/empty", snap.Status, snap.Objective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalStateResume(t *testing.T) {
|
||||
st := NewGoalState()
|
||||
st.Start("g1", "obj", 1000)
|
||||
// Simulate a run that tripped a safety guard.
|
||||
st.RecordIteration(500, false)
|
||||
st.RecordIteration(500, false)
|
||||
st.SetStatus(GoalPaused)
|
||||
|
||||
st.Resume()
|
||||
snap := st.Snapshot()
|
||||
if snap.Status != GoalActive {
|
||||
t.Errorf("status = %q, want active after Resume", snap.Status)
|
||||
}
|
||||
if snap.Iterations != 0 || snap.NoProgress != 0 || snap.TokensUsed != 0 {
|
||||
t.Errorf("Resume should clear transient counters: iterations=%d noProgress=%d tokensUsed=%d",
|
||||
snap.Iterations, snap.NoProgress, snap.TokensUsed)
|
||||
}
|
||||
if snap.Objective != "obj" || snap.TokenBudget != 1000 {
|
||||
t.Errorf("Resume should preserve objective/budget: objective=%q budget=%d", snap.Objective, snap.TokenBudget)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// This file implements the HTML→Markdown reduction used by the webfetch tool
|
||||
// (US-012, #128). It wraps the JohannesKaufmann/html-to-markdown/v2 library: the
|
||||
// base plugin already strips head/script/style/link/meta/iframe/noscript/input,
|
||||
// and we additionally register the remaining page chrome (nav/footer/header/
|
||||
// aside/form/svg/template) for removal so only readable content survives. The
|
||||
// commonmark plugin renders headings, links, lists, code, emphasis, and tables.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
|
||||
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/base"
|
||||
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/commonmark"
|
||||
)
|
||||
|
||||
// chromeElements are dropped whole (element + subtree) in addition to the base
|
||||
// plugin's defaults: they carry no readable body content for a text extraction.
|
||||
var chromeElements = []string{
|
||||
"nav", "footer", "header", "aside", "svg", "form", "template",
|
||||
}
|
||||
|
||||
// mdConverter is the shared, configured converter. It is built once — NewConverter
|
||||
// registers plugins and tag handlers, which is wasteful to repeat per call, and
|
||||
// the converter is safe for concurrent ConvertString use.
|
||||
var mdConverter = sync.OnceValue(func() *converter.Converter {
|
||||
conv := converter.NewConverter(
|
||||
converter.WithPlugins(
|
||||
base.NewBasePlugin(),
|
||||
commonmark.NewCommonmarkPlugin(),
|
||||
),
|
||||
)
|
||||
for _, tag := range chromeElements {
|
||||
conv.Register.TagType(tag, converter.TagTypeRemove, converter.PriorityStandard)
|
||||
}
|
||||
return conv
|
||||
})
|
||||
|
||||
// htmlToMarkdown converts body (HTML) to a simplified Markdown string. On a
|
||||
// conversion error (rare — the parser is lenient) it falls back to the raw bytes
|
||||
// so the caller always gets usable text.
|
||||
func htmlToMarkdown(body []byte) string {
|
||||
md, err := mdConverter().ConvertString(string(body))
|
||||
if err != nil {
|
||||
return string(body)
|
||||
}
|
||||
return md
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Tests for the HTML→Markdown reduction (US-012, #128): headings, links, lists,
|
||||
// code, and dropped chrome/script elements. The conversion is delegated to the
|
||||
// html-to-markdown/v2 library; these tests pin the behavior webfetch relies on.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHTMLToMarkdownBasics checks headings, emphasis, and links render.
|
||||
func TestHTMLToMarkdownBasics(t *testing.T) {
|
||||
html := `<html><body>
|
||||
<h2>Section</h2>
|
||||
<p>Some <strong>bold</strong> and a <a href="https://go.dev">link</a>.</p>
|
||||
</body></html>`
|
||||
md := htmlToMarkdown([]byte(html))
|
||||
for _, want := range []string{"## Section", "**bold**", "[link](https://go.dev)"} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("markdown missing %q in:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLToMarkdownDropsChrome checks script/style/nav/footer content is removed
|
||||
// while real body content survives.
|
||||
func TestHTMLToMarkdownDropsChrome(t *testing.T) {
|
||||
html := `<html><head><style>.x{}</style></head><body>
|
||||
<nav>menu links</nav>
|
||||
<script>tracker()</script>
|
||||
<p>real content</p>
|
||||
<footer>copyright notice</footer>
|
||||
</body></html>`
|
||||
md := htmlToMarkdown([]byte(html))
|
||||
if !strings.Contains(md, "real content") {
|
||||
t.Errorf("body content dropped: %q", md)
|
||||
}
|
||||
for _, gone := range []string{"tracker()", ".x{}", "menu links", "copyright notice"} {
|
||||
if strings.Contains(md, gone) {
|
||||
t.Errorf("chrome/noise %q leaked into: %q", gone, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLToMarkdownLists checks list items become dashes.
|
||||
func TestHTMLToMarkdownLists(t *testing.T) {
|
||||
html := `<ul><li>one</li><li>two</li></ul>`
|
||||
md := htmlToMarkdown([]byte(html))
|
||||
if !strings.Contains(md, "one") || !strings.Contains(md, "two") {
|
||||
t.Errorf("list not rendered: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "- ") {
|
||||
t.Errorf("list markers missing: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLToMarkdownInlineSpacing checks spaces around inline elements survive
|
||||
// (regression: "A <a>link</a> here" must not become "Alinkhere").
|
||||
func TestHTMLToMarkdownInlineSpacing(t *testing.T) {
|
||||
md := htmlToMarkdown([]byte(`<p>A <a href="https://x.io">link</a> here.</p>`))
|
||||
if !strings.Contains(md, "A [link](https://x.io) here.") {
|
||||
t.Errorf("inline spacing lost: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLToMarkdownEmptyFallback checks empty input does not panic.
|
||||
func TestHTMLToMarkdownEmptyFallback(t *testing.T) {
|
||||
if got := htmlToMarkdown([]byte("")); strings.TrimSpace(got) != "" {
|
||||
t.Errorf("empty input = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// This file implements the memory_search AgentTool (issue #477): a read-only
|
||||
// tool that queries the persistent memory library (internal/memory) by relevance
|
||||
// using its BM25 full-text index and returns ranked snippets to the model.
|
||||
//
|
||||
// Writes are intentionally NOT a separate tool here. Per the SPEC (§4.1/§4.2),
|
||||
// memory writes reuse the existing Write/Edit file tools, constrained to the
|
||||
// memory root and carrying the canonical frontmatter (name/description/
|
||||
// metadata.type). Traversal protection for those writes lives in the memory
|
||||
// package (memory.assertSafeComponent) and the write-path plumbing; a dedicated
|
||||
// memory_write tool would duplicate that. After an off-tool write, the next
|
||||
// memory_search picks it up automatically because Execute searches with
|
||||
// ReconcileFirst=true (lazy reconcile).
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/memory"
|
||||
)
|
||||
|
||||
// memorySearchDefaultLimit is the result cap used when the caller omits limit or
|
||||
// passes a non-positive value; memorySearchMaxLimit is the hard upper bound.
|
||||
const (
|
||||
memorySearchDefaultLimit = 10
|
||||
memorySearchMaxLimit = 50
|
||||
)
|
||||
|
||||
// MemorySearchTool searches the persistent memory library by relevance. Store is
|
||||
// exported so the loop-integration node (#481) can construct the tool with a
|
||||
// live *memory.Store, mirroring how TodoTool exposes its Store.
|
||||
type MemorySearchTool struct {
|
||||
// Store is the persistent memory store. When nil, Execute degrades to a
|
||||
// friendly no-op result rather than erroring, so a session without memory
|
||||
// configured still runs.
|
||||
Store *memory.Store
|
||||
}
|
||||
|
||||
// memorySearchArgs is the decoded argument shape for memory_search.
|
||||
type memorySearchArgs struct {
|
||||
Query string `json:"query"`
|
||||
Scope string `json:"scope"`
|
||||
Type string `json:"type"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *MemorySearchTool) Name() string { return "memory_search" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *MemorySearchTool) Description() string {
|
||||
return "Search the persistent memory library by relevance (BM25 full-text) " +
|
||||
"and return ranked snippets from previously saved notes, checkpoints, and " +
|
||||
"references. Use it to recall context from earlier sessions before " +
|
||||
"answering or acting. Optional filters: scope (global|projects|sessions|cc), " +
|
||||
"type (user|feedback|project|reference|checkpoint|progress|notes|free), and " +
|
||||
"limit (default 10, max 50). Results are ordered most-relevant first."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *MemorySearchTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Free-text query; tokenized and matched against memory bodies (BM25)."},
|
||||
"scope": {"type": "string", "description": "Optional scope filter: global, projects, sessions, or cc."},
|
||||
"type": {"type": "string", "description": "Optional type filter: user, feedback, project, reference, checkpoint, progress, notes, or free."},
|
||||
"limit": {"type": "integer", "description": "Max results to return (default 10, capped at 50)."}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. memory_search is read-only, so it runs in
|
||||
// the default parallel mode.
|
||||
func (t *MemorySearchTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It decodes the args, runs a lazily-reconciled
|
||||
// BM25 search, and formats the ranked hits as text (one line each:
|
||||
// "[type/scope] path (score) — snippet") with the structured []SearchResult in
|
||||
// Details. A nil Store or empty query degrades to a friendly no-op result rather
|
||||
// than a Go error so the loop keeps running.
|
||||
func (t *MemorySearchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[memorySearchArgs](args, "memory_search")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(a.Query)
|
||||
if t.Store == nil {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
"memory_search: no memory store configured; nothing to search.")},
|
||||
}, nil
|
||||
}
|
||||
if query == "" {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
"memory_search: empty query; provide a search string.")},
|
||||
}, nil
|
||||
}
|
||||
|
||||
limit := a.Limit
|
||||
if limit <= 0 {
|
||||
limit = memorySearchDefaultLimit
|
||||
}
|
||||
if limit > memorySearchMaxLimit {
|
||||
limit = memorySearchMaxLimit
|
||||
}
|
||||
|
||||
results, err := t.Store.Search(query, memory.SearchOptions{
|
||||
Scope: strings.TrimSpace(a.Scope),
|
||||
Type: strings.TrimSpace(a.Type),
|
||||
Limit: limit,
|
||||
ReconcileFirst: true, // lazy reconcile so off-tool writes are indexed
|
||||
// ScoreFloor left at its zero value → package default (0.15).
|
||||
})
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("memory_search: %v", err)), nil
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
fmt.Sprintf("memory_search: no results for %q.", query))},
|
||||
Details: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(formatMemoryResults(query, results))},
|
||||
Details: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// formatMemoryResults renders ranked hits as a header line plus one line per
|
||||
// result: "[type/scope] path (score) — snippet". Snippets are whitespace-
|
||||
// collapsed so a multi-line body stays on a single row.
|
||||
func formatMemoryResults(query string, results []memory.SearchResult) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "memory_search: %d result(s) for %q, most relevant first:", len(results), query)
|
||||
for _, r := range results {
|
||||
typ := string(r.Type)
|
||||
if typ == "" {
|
||||
typ = "free"
|
||||
}
|
||||
scope := string(r.Scope)
|
||||
if r.ScopeID != "" {
|
||||
scope = scope + "/" + r.ScopeID
|
||||
}
|
||||
line := fmt.Sprintf("\n[%s/%s] %s (%.3f)", typ, scope, r.Path, r.Score)
|
||||
if snip := collapseWhitespace(r.Snippet); snip != "" {
|
||||
line += " — " + snip
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// collapseWhitespace folds any run of whitespace (including newlines) into a
|
||||
// single space and trims the ends, keeping a snippet to one line.
|
||||
func collapseWhitespace(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/memory"
|
||||
)
|
||||
|
||||
// newMemoryStoreWithCorpus opens a *memory.Store over a temp DB + temp mimo root
|
||||
// and writes a couple of .md files under the layout. It does NOT reconcile — the
|
||||
// tool's ReconcileFirst=true is expected to index them lazily on first search.
|
||||
func newMemoryStoreWithCorpus(t *testing.T) *memory.Store {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "mimo")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatalf("mkdir root: %v", err)
|
||||
}
|
||||
writeMemFile(t, root, "permission deadlock encountered during checkpoint save then retry succeeded",
|
||||
"projects", "proj1", "notes", "rare.md")
|
||||
writeMemFile(t, root, "unrelated grocery shopping list",
|
||||
"global", "user", "u1.md")
|
||||
|
||||
dbPath := filepath.Join(base, "sub", "memory.db")
|
||||
st, err := memory.Open(dbPath, root, "")
|
||||
if err != nil {
|
||||
t.Fatalf("memory.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func writeMemFile(t *testing.T, root, body string, segs ...string) string {
|
||||
t.Helper()
|
||||
full := filepath.Join(append([]string{root}, segs...)...)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir for %q: %v", full, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %q: %v", full, err)
|
||||
}
|
||||
return filepath.Clean(full)
|
||||
}
|
||||
|
||||
func runMemorySearch(t *testing.T, tool *MemorySearchTool, args map[string]any) (string, any) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
res, err := tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned Go error: %v", err)
|
||||
}
|
||||
return contentText(res.Content), res.Details
|
||||
}
|
||||
|
||||
// contentText concatenates the text of every TextContent block in a result.
|
||||
func contentText(content agentcore.ContentList) string {
|
||||
var b strings.Builder
|
||||
for _, c := range content {
|
||||
if tc, ok := c.(agentcore.TextContent); ok {
|
||||
b.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestMemorySearchToolInterface(t *testing.T) {
|
||||
tool := &MemorySearchTool{}
|
||||
if tool.Name() != "memory_search" {
|
||||
t.Fatalf("Name = %q, want memory_search", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Fatal("Description must not be empty")
|
||||
}
|
||||
// Schema must be valid JSON declaring query as required.
|
||||
var schema struct {
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Fatalf("Schema is not valid JSON: %v", err)
|
||||
}
|
||||
if len(schema.Required) != 1 || schema.Required[0] != "query" {
|
||||
t.Fatalf("Schema required = %v, want [query]", schema.Required)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySearchFindsSnippet(t *testing.T) {
|
||||
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
|
||||
|
||||
text, details := runMemorySearch(t, tool, map[string]any{"query": "permission deadlock"})
|
||||
|
||||
if !strings.Contains(text, "rare.md") {
|
||||
t.Fatalf("expected result text to reference rare.md, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(text), "permission") {
|
||||
t.Fatalf("expected snippet to mention 'permission', got:\n%s", text)
|
||||
}
|
||||
|
||||
// Details must carry the structured results (lazy reconcile indexed the file).
|
||||
results, ok := details.([]memory.SearchResult)
|
||||
if !ok {
|
||||
t.Fatalf("Details type = %T, want []memory.SearchResult", details)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one structured result")
|
||||
}
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if strings.HasSuffix(r.Path, filepath.Join("notes", "rare.md")) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected rare.md among structured results, got %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySearchScopeAndTypeFilter(t *testing.T) {
|
||||
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
|
||||
|
||||
// Filter to the global/user doc; the projects/notes doc must be excluded even
|
||||
// though it also matches the shared word.
|
||||
text, _ := runMemorySearch(t, tool, map[string]any{
|
||||
"query": "grocery permission",
|
||||
"scope": "global",
|
||||
"type": "user",
|
||||
})
|
||||
if strings.Contains(text, "rare.md") {
|
||||
t.Fatalf("scope/type filter should exclude rare.md, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "u1.md") {
|
||||
t.Fatalf("expected u1.md to match global/user filter, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySearchNoResults(t *testing.T) {
|
||||
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
|
||||
text, _ := runMemorySearch(t, tool, map[string]any{"query": "zzzznonexistenttoken"})
|
||||
if !strings.Contains(text, "no results") {
|
||||
t.Fatalf("expected a clear empty message, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySearchEmptyQueryNoOp(t *testing.T) {
|
||||
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
|
||||
text, _ := runMemorySearch(t, tool, map[string]any{"query": " "})
|
||||
if !strings.Contains(text, "empty query") {
|
||||
t.Fatalf("expected empty-query no-op message, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySearchNilStoreNoOp(t *testing.T) {
|
||||
tool := &MemorySearchTool{} // Store nil
|
||||
text, _ := runMemorySearch(t, tool, map[string]any{"query": "anything"})
|
||||
if !strings.Contains(text, "no memory store") {
|
||||
t.Fatalf("expected nil-store no-op message, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySearchInvalidArgs(t *testing.T) {
|
||||
tool := &MemorySearchTool{Store: newMemoryStoreWithCorpus(t)}
|
||||
res, err := tool.Execute(context.Background(), "call-1", json.RawMessage(`{"query": 123}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned Go error: %v", err)
|
||||
}
|
||||
var text strings.Builder
|
||||
text.WriteString(contentText(res.Content))
|
||||
if !strings.Contains(text.String(), "invalid arguments") {
|
||||
t.Fatalf("expected invalid-arguments error result, got:\n%s", text.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// This file implements the read tool (US-015): read a file's contents by path,
|
||||
// with optional line offset/limit, numbered output, and large-file truncation.
|
||||
// Paths are resolved against a Root and rejected if they escape it (path
|
||||
// traversal guard) or do not exist.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// readToolMaxLines caps how many lines a single read returns before truncating
|
||||
// (protects the model's context from huge files). Callers page with offset.
|
||||
const readToolMaxLines = 2000
|
||||
|
||||
// readToolMaxLineLen caps how many bytes of a single line are returned; longer
|
||||
// lines are truncated with a marker.
|
||||
const readToolMaxLineLen = 2000
|
||||
|
||||
// scanBufInit is the initial per-line scanner buffer (it grows on demand up to
|
||||
// the max). readScanBufMax is generous — a read may page through a file with
|
||||
// very long lines (minified JS, JSON) that must not error out mid-read.
|
||||
const (
|
||||
scanBufInit = 64 * 1024
|
||||
readScanBufMax = 16 * 1024 * 1024
|
||||
grepScanBufMax = 1 * 1024 * 1024
|
||||
)
|
||||
|
||||
// filePerm / dirPerm are the modes new files and parent directories are created
|
||||
// with by the write/edit tools (standard non-executable file, traversable dir).
|
||||
const (
|
||||
filePerm os.FileMode = 0o644
|
||||
dirPerm os.FileMode = 0o755
|
||||
)
|
||||
|
||||
// ReadTool reads text files under Root. It is the first concrete AgentTool.
|
||||
type ReadTool struct {
|
||||
// Root is the directory that bounds all reads. A path resolving outside Root
|
||||
// is rejected. Empty Root defaults to the current working directory.
|
||||
Root string
|
||||
// ExtraRoots are additional trusted directories a read may target even though
|
||||
// they lie outside Root. It exists for the skills directory: pigo advertises
|
||||
// each skill's absolute SKILL.md path in the system prompt and tells the model
|
||||
// to read it, so the read tool must permit those paths (they are otherwise
|
||||
// outside the workspace and rejected).
|
||||
ExtraRoots []string
|
||||
}
|
||||
|
||||
// readToolArgs is the decoded argument shape for ReadTool.
|
||||
type readToolArgs struct {
|
||||
// Path is the file to read, relative to Root (or absolute within Root).
|
||||
Path string `json:"path"`
|
||||
// Offset is the 1-based line to start reading from. 0/1 both mean line 1.
|
||||
Offset int `json:"offset,omitempty"`
|
||||
// Limit is the maximum number of lines to return. 0 means the default cap.
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *ReadTool) Name() string { return "read" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *ReadTool) Description() string {
|
||||
return "Read a text file's contents by path, with optional line offset/limit. " +
|
||||
"Output is line-numbered; very large files are truncated."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *ReadTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to read, relative to the workspace root."},
|
||||
"offset": {"type": "integer", "description": "1-based line number to start from.", "minimum": 0},
|
||||
"limit": {"type": "integer", "description": "Maximum number of lines to return.", "minimum": 0}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Reads are side-effect free → parallel.
|
||||
func (t *ReadTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
|
||||
// resolveWithin boundary policy, so every file tool enforces the same
|
||||
// workspace-escape guard while the read tool can also reach trusted extra roots.
|
||||
func (t *ReadTool) resolvePath(p string) (string, error) {
|
||||
if len(t.ExtraRoots) == 0 {
|
||||
return resolveWithin(t.Root, p)
|
||||
}
|
||||
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It never returns a Go error for a read failure
|
||||
// (bad path, missing file); those are encoded as error results so the model can
|
||||
// react. The returned error is reserved for argument decode failures.
|
||||
func (t *ReadTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[readToolArgs](args, "read")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Path == "" {
|
||||
return errorResult("read: path is required"), nil
|
||||
}
|
||||
full, err := t.resolvePath(a.Path)
|
||||
if err != nil {
|
||||
return errorResult("read: " + err.Error()), nil
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult(fmt.Sprintf("read: file %q does not exist", a.Path)), nil
|
||||
}
|
||||
return errorResult(fmt.Sprintf("read: cannot stat %q: %v", a.Path, err)), nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errorResult(fmt.Sprintf("read: %q is a directory, not a file", a.Path)), nil
|
||||
}
|
||||
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("read: cannot open %q: %v", a.Path, err)), nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
text, truncated := readNumbered(f, a.Offset, a.Limit)
|
||||
if truncated {
|
||||
text += fmt.Sprintf("\n... (output truncated at %d lines; use offset to read more)", readToolMaxLines)
|
||||
}
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
|
||||
}
|
||||
|
||||
// readNumbered reads lines from r starting at 1-based offset, returning at most
|
||||
// limit lines (capped at readToolMaxLines), each prefixed with its line number.
|
||||
// The bool reports whether the output was truncated by the cap.
|
||||
func readNumbered(r io.Reader, offset, limit int) (string, bool) {
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
}
|
||||
max := limit
|
||||
if max <= 0 || max > readToolMaxLines {
|
||||
max = readToolMaxLines
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, scanBufInit), readScanBufMax)
|
||||
var b strings.Builder
|
||||
lineNo := 0
|
||||
emitted := 0
|
||||
truncated := false
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
if lineNo < offset {
|
||||
continue
|
||||
}
|
||||
if emitted >= max {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
line := sc.Text()
|
||||
if len(line) > readToolMaxLineLen {
|
||||
line = line[:readToolMaxLineLen] + "… (line truncated)"
|
||||
}
|
||||
fmt.Fprintf(&b, "%6d\t%s\n", lineNo, line)
|
||||
emitted++
|
||||
}
|
||||
return b.String(), truncated
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runRead(t *testing.T, tool *ReadTool, args map[string]any) (agentcore.AgentToolResult, bool) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("execute returned go error: %v", gerr)
|
||||
}
|
||||
return res, false
|
||||
}
|
||||
|
||||
func resultText(res agentcore.AgentToolResult) string {
|
||||
var b strings.Builder
|
||||
for _, c := range res.Content {
|
||||
if tc, ok := c.(agentcore.TextContent); ok {
|
||||
b.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestReadToolBasic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hello.txt")
|
||||
if err := os.WriteFile(path, []byte("line one\nline two\nline three\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "hello.txt"})
|
||||
text := resultText(res)
|
||||
if !strings.Contains(text, "line one") || !strings.Contains(text, "line three") {
|
||||
t.Errorf("missing content: %q", text)
|
||||
}
|
||||
// Line numbers present.
|
||||
if !strings.Contains(text, "1\tline one") || !strings.Contains(text, "3\tline three") {
|
||||
t.Errorf("missing line numbers: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolOffsetLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var sb strings.Builder
|
||||
for i := 1; i <= 10; i++ {
|
||||
sb.WriteString("row\n")
|
||||
}
|
||||
path := filepath.Join(dir, "rows.txt")
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "rows.txt", "offset": 3, "limit": 2})
|
||||
text := resultText(res)
|
||||
// Should include line numbers 3 and 4, not 1,2,5.
|
||||
if !strings.Contains(text, "3\trow") || !strings.Contains(text, "4\trow") {
|
||||
t.Errorf("offset/limit window wrong: %q", text)
|
||||
}
|
||||
if strings.Contains(text, "2\trow") || strings.Contains(text, "5\trow") {
|
||||
t.Errorf("offset/limit leaked outside window: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolMissingFile(t *testing.T) {
|
||||
tool := &ReadTool{Root: t.TempDir()}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "nope.txt"})
|
||||
if !strings.Contains(resultText(res), "does not exist") {
|
||||
t.Errorf("expected does-not-exist error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A secret sits outside the root.
|
||||
parent := filepath.Dir(dir)
|
||||
secret := filepath.Join(parent, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
defer os.Remove(secret)
|
||||
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "../secret.txt"})
|
||||
text := resultText(res)
|
||||
if strings.Contains(text, "top secret") {
|
||||
t.Fatal("path traversal escaped the root!")
|
||||
}
|
||||
if !strings.Contains(text, "outside the workspace root") {
|
||||
t.Errorf("expected boundary error, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "subdir")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "subdir"})
|
||||
if !strings.Contains(resultText(res), "is a directory") {
|
||||
t.Errorf("expected directory error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolTruncation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var sb strings.Builder
|
||||
for i := 0; i < readToolMaxLines+50; i++ {
|
||||
sb.WriteString("x\n")
|
||||
}
|
||||
path := filepath.Join(dir, "big.txt")
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "big.txt"})
|
||||
if !strings.Contains(resultText(res), "output truncated") {
|
||||
t.Error("expected truncation notice for oversized file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolMissingPathArg(t *testing.T) {
|
||||
tool := &ReadTool{Root: t.TempDir()}
|
||||
res, _ := runRead(t, tool, map[string]any{})
|
||||
if !strings.Contains(resultText(res), "path is required") {
|
||||
t.Errorf("expected path-required error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolExtraRootsAllowsTrustedOutsidePath(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
// A skill file lives OUTSIDE the workspace root (mirrors ~/.agents/skills).
|
||||
skills := t.TempDir()
|
||||
skillFile := filepath.Join(skills, "weather", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(skillFile, []byte("skill body"), 0o644); err != nil {
|
||||
t.Fatalf("write skill: %v", err)
|
||||
}
|
||||
|
||||
// Without ExtraRoots the absolute skill path is rejected as out-of-workspace.
|
||||
bounded := &ReadTool{Root: work}
|
||||
res, _ := runRead(t, bounded, map[string]any{"path": skillFile})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
|
||||
}
|
||||
|
||||
// With the skills dir as an extra root the same read succeeds.
|
||||
tool := &ReadTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res, _ = runRead(t, tool, map[string]any{"path": skillFile})
|
||||
if !strings.Contains(resultText(res), "skill body") {
|
||||
t.Fatalf("expected skill contents with ExtraRoots, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolExtraRootsStillBlocksUntrustedPath(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
skills := t.TempDir()
|
||||
// A secret sits outside BOTH the workspace root and the extra root.
|
||||
other := t.TempDir()
|
||||
secret := filepath.Join(other, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
|
||||
tool := &ReadTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": secret})
|
||||
text := resultText(res)
|
||||
if strings.Contains(text, "top secret") {
|
||||
t.Fatal("read escaped both roots!")
|
||||
}
|
||||
if !strings.Contains(text, "outside the workspace root") {
|
||||
t.Errorf("expected boundary error for untrusted path, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolSchemaAndMode(t *testing.T) {
|
||||
tool := &ReadTool{}
|
||||
if tool.Name() != "read" {
|
||||
t.Errorf("name = %q", tool.Name())
|
||||
}
|
||||
if tool.ExecutionMode() != agentcore.ToolExecutionParallel {
|
||||
t.Errorf("read should be parallel")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Errorf("schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// This file implements the tool registry (US-014): tools are registered by
|
||||
// name, and their arguments are validated against a per-tool JSON Schema
|
||||
// (santhosh-tekuri/jsonschema v6) before execution. Validation failures are
|
||||
// turned into a field-level error tool result rather than a Go error, so the
|
||||
// model receives actionable feedback in the loop.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
// schemaPrinter renders jsonschema error kinds. LocalizedString dereferences
|
||||
// the printer, so it must be non-nil.
|
||||
var schemaPrinter = message.NewPrinter(language.English)
|
||||
|
||||
// ToolRegistry stores tools by name and validates call arguments against each
|
||||
// tool's declared JSON Schema. It is safe for concurrent use.
|
||||
type ToolRegistry struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]agentcore.AgentTool
|
||||
compiled map[string]*jsonschema.Schema
|
||||
}
|
||||
|
||||
// NewToolRegistry returns an empty registry.
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return &ToolRegistry{
|
||||
tools: make(map[string]agentcore.AgentTool),
|
||||
compiled: make(map[string]*jsonschema.Schema),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a tool, compiling its JSON Schema up front so bad schemas fail
|
||||
// at registration rather than on first call. A duplicate name is an error. A
|
||||
// tool whose Schema() is empty is registered with no validation.
|
||||
func (r *ToolRegistry) Register(tool agentcore.AgentTool) error {
|
||||
name := tool.Name()
|
||||
if name == "" {
|
||||
return fmt.Errorf("registry: tool has empty name")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.tools[name]; exists {
|
||||
return fmt.Errorf("registry: tool %q already registered", name)
|
||||
}
|
||||
if raw := tool.Schema(); len(bytes.TrimSpace(raw)) > 0 && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
sch, err := compileSchema(name, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: tool %q schema: %w", name, err)
|
||||
}
|
||||
r.compiled[name] = sch
|
||||
}
|
||||
r.tools[name] = tool
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the tool registered under name and whether it was found.
|
||||
func (r *ToolRegistry) Get(name string) (agentcore.AgentTool, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
t, ok := r.tools[name]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// List returns all registered tools sorted by name (stable ordering for
|
||||
// deterministic provider tool declarations).
|
||||
func (r *ToolRegistry) List() []agentcore.AgentTool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]agentcore.AgentTool, 0, len(r.tools))
|
||||
for _, t := range r.tools {
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
return out
|
||||
}
|
||||
|
||||
// FieldError is a single validation failure located at a JSON-pointer path
|
||||
// within the arguments.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"` // JSON pointer, e.g. "/path" or "" for root
|
||||
Message string `json:"message"` // human-readable reason
|
||||
}
|
||||
|
||||
// Validate checks args against the tool's compiled schema. It returns nil when
|
||||
// the tool has no schema or the arguments are valid; otherwise it returns the
|
||||
// flattened field-level errors. An unknown tool name is reported as a single
|
||||
// root-level error.
|
||||
func (r *ToolRegistry) Validate(name string, args json.RawMessage) []FieldError {
|
||||
r.mu.RLock()
|
||||
_, known := r.tools[name]
|
||||
sch, hasSchema := r.compiled[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !known {
|
||||
return []FieldError{{Field: "", Message: fmt.Sprintf("unknown tool %q", name)}}
|
||||
}
|
||||
if !hasSchema {
|
||||
return nil
|
||||
}
|
||||
|
||||
var inst any
|
||||
dec := json.NewDecoder(bytes.NewReader(nonEmptyJSON(args)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&inst); err != nil {
|
||||
return []FieldError{{Field: "", Message: fmt.Sprintf("arguments are not valid JSON: %v", err)}}
|
||||
}
|
||||
|
||||
if err := sch.Validate(inst); err != nil {
|
||||
var verr *jsonschema.ValidationError
|
||||
if as := asValidationError(err); as != nil {
|
||||
verr = as
|
||||
}
|
||||
if verr != nil {
|
||||
return flattenValidationError(verr)
|
||||
}
|
||||
return []FieldError{{Field: "", Message: err.Error()}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationErrorResult builds an error AgentToolResult describing the given
|
||||
// field errors, for the loop to hand back to the model (FR: field-level error
|
||||
// tool result). Terminate is left nil (a validation failure never ends the run).
|
||||
func ValidationErrorResult(toolName string, errs []FieldError) agentcore.AgentToolResult {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Invalid arguments for tool %q:\n", toolName)
|
||||
for _, e := range errs {
|
||||
field := e.Field
|
||||
if field == "" {
|
||||
field = "(root)"
|
||||
}
|
||||
fmt.Fprintf(&b, " - %s: %s\n", field, e.Message)
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(strings.TrimRight(b.String(), "\n"))},
|
||||
Details: errs,
|
||||
}
|
||||
}
|
||||
|
||||
// compileSchema compiles a raw JSON Schema document held in memory.
|
||||
func compileSchema(name string, raw json.RawMessage) (*jsonschema.Schema, error) {
|
||||
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := jsonschema.NewCompiler()
|
||||
// A synthetic in-memory URL; each tool gets its own so schemas never clash.
|
||||
loc := "mem:///" + name + ".json"
|
||||
if err := c.AddResource(loc, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Compile(loc)
|
||||
}
|
||||
|
||||
// flattenValidationError walks the ValidationError tree and returns one
|
||||
// FieldError per leaf cause (the most specific failures), falling back to the
|
||||
// node itself when it has no causes.
|
||||
func flattenValidationError(e *jsonschema.ValidationError) []FieldError {
|
||||
var out []FieldError
|
||||
var walk func(n *jsonschema.ValidationError)
|
||||
walk = func(n *jsonschema.ValidationError) {
|
||||
if len(n.Causes) == 0 {
|
||||
out = append(out, FieldError{
|
||||
Field: jsonPointer(n.InstanceLocation),
|
||||
Message: n.ErrorKind.LocalizedString(schemaPrinter),
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, c := range n.Causes {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(e)
|
||||
if len(out) == 0 {
|
||||
out = append(out, FieldError{Field: jsonPointer(e.InstanceLocation), Message: e.Error()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonPointer renders an instance-location path as a JSON pointer.
|
||||
func jsonPointer(loc []string) string {
|
||||
if len(loc) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, tok := range loc {
|
||||
b.WriteByte('/')
|
||||
tok = strings.ReplaceAll(tok, "~", "~0")
|
||||
tok = strings.ReplaceAll(tok, "/", "~1")
|
||||
b.WriteString(tok)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// asValidationError extracts a *jsonschema.ValidationError from err if present.
|
||||
func asValidationError(err error) *jsonschema.ValidationError {
|
||||
if verr, ok := err.(*jsonschema.ValidationError); ok {
|
||||
return verr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonEmptyJSON treats empty arguments as an empty object so schemas with only
|
||||
// optional properties validate, and "required" violations are reported.
|
||||
func nonEmptyJSON(args json.RawMessage) []byte {
|
||||
if len(bytes.TrimSpace(args)) == 0 {
|
||||
return []byte("{}")
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// stubTool is a minimal AgentTool for registry tests.
|
||||
type stubTool struct {
|
||||
name string
|
||||
schema string
|
||||
}
|
||||
|
||||
func (s stubTool) Name() string { return s.name }
|
||||
func (s stubTool) Description() string { return "stub" }
|
||||
func (s stubTool) Schema() json.RawMessage { return json.RawMessage(s.schema) }
|
||||
func (s stubTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel }
|
||||
func (s stubTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("ok")}}, nil
|
||||
}
|
||||
|
||||
const personSchema = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"}
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
}`
|
||||
|
||||
func newTestRegistry(t *testing.T) *ToolRegistry {
|
||||
t.Helper()
|
||||
r := NewToolRegistry()
|
||||
if err := r.Register(stubTool{name: "person", schema: personSchema}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRegistryRegisterAndGet(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
tool, ok := r.Get("person")
|
||||
if !ok || tool.Name() != "person" {
|
||||
t.Fatalf("Get(person) failed: %v %v", tool, ok)
|
||||
}
|
||||
if _, ok := r.Get("missing"); ok {
|
||||
t.Error("Get(missing) should report not found")
|
||||
}
|
||||
if got := r.List(); len(got) != 1 || got[0].Name() != "person" {
|
||||
t.Errorf("List wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDuplicateRejected(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
if err := r.Register(stubTool{name: "person", schema: personSchema}); err == nil {
|
||||
t.Fatal("expected duplicate registration to error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryValidArgs(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
errs := r.Validate("person", json.RawMessage(`{"name":"ada","age":36}`))
|
||||
if errs != nil {
|
||||
t.Fatalf("valid args reported errors: %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryMissingRequiredField(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
errs := r.Validate("person", json.RawMessage(`{"age":36}`))
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for missing required field 'name'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTypeError(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
errs := r.Validate("person", json.RawMessage(`{"name":"ada","age":"old"}`))
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected type error for age")
|
||||
}
|
||||
// The offending field should be located at /age.
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if e.Field == "/age" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected a field error at /age, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryUnknownTool(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
errs := r.Validate("nope", json.RawMessage(`{}`))
|
||||
if len(errs) != 1 || errs[0].Field != "" {
|
||||
t.Fatalf("expected single root error for unknown tool, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryNoSchemaSkipsValidation(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
if err := r.Register(stubTool{name: "free", schema: ""}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if errs := r.Validate("free", json.RawMessage(`{"anything":true}`)); errs != nil {
|
||||
t.Fatalf("no-schema tool should skip validation, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorResultShape(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
errs := r.Validate("person", json.RawMessage(`{"age":36}`))
|
||||
res := ValidationErrorResult("person", errs)
|
||||
if len(res.Content) == 0 {
|
||||
t.Fatal("expected content in validation error result")
|
||||
}
|
||||
if _, ok := res.Details.([]FieldError); !ok {
|
||||
t.Errorf("expected Details to carry []FieldError, got %T", res.Details)
|
||||
}
|
||||
if res.Terminate != nil {
|
||||
t.Error("validation failure must not terminate the run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryBadSchemaRejectedAtRegister(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
err := r.Register(stubTool{name: "bad", schema: `{"type": 123}`})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid schema to fail at registration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// This file implements the search tools (US-019): grep (search file contents by
|
||||
// regexp with optional glob filtering), find (locate files by name glob), and ls
|
||||
// (list a directory, distinguishing files from directories). All three resolve
|
||||
// paths against a Root with the same boundary guard as the other tools and skip
|
||||
// paths ignored by the workspace .gitignore. They are read-only → parallel.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// searchMaxResults caps the number of matches/entries any single search returns
|
||||
// so a broad query cannot flood the model's context.
|
||||
const searchMaxResults = 1000
|
||||
|
||||
// resolveWithin resolves p against root and verifies it stays within it. It is
|
||||
// the single workspace-boundary policy shared by every file tool: the search
|
||||
// tools call it directly, and ReadTool/WriteTool/EditTool.resolvePath delegate
|
||||
// to it, so the path-traversal guard lives in exactly one place.
|
||||
func resolveWithin(root, p string) (string, error) {
|
||||
if root == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
root = wd
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid root: %w", err)
|
||||
}
|
||||
var full string
|
||||
if filepath.IsAbs(p) {
|
||||
full = filepath.Clean(p)
|
||||
} else {
|
||||
full = filepath.Join(absRoot, p)
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, full)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path %q is outside the workspace root", p)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// resolveWithinAny resolves p against the first root that contains it, trying
|
||||
// roots in order. It exists so the file tools can additionally permit trusted
|
||||
// out-of-workspace roots (the skills directory) whose absolute SKILL.md paths
|
||||
// pigo itself advertises in the system prompt: without this, the workspace guard
|
||||
// would reject the very paths the model is instructed to read or author. Empty
|
||||
// roots are skipped; if none contain p, the standard workspace-escape error is
|
||||
// returned.
|
||||
func resolveWithinAny(roots []string, p string) (string, error) {
|
||||
var lastErr error
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
full, err := resolveWithin(root, p)
|
||||
if err == nil {
|
||||
return full, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
// No usable roots supplied: fall back to the default (cwd) policy.
|
||||
return resolveWithin("", p)
|
||||
}
|
||||
|
||||
// gitignore is a minimal .gitignore matcher. It supports the common subset:
|
||||
// blank lines and #-comments are skipped; a leading "/" anchors to the root;
|
||||
// a trailing "/" matches directories only; "!" negation re-includes; and plain
|
||||
// patterns match by base name or path via filepath.Match. It is intentionally
|
||||
// not a full gitignore implementation (no "**" spanning, no nested .gitignore).
|
||||
type gitignore struct {
|
||||
rules []ignoreRule
|
||||
// hasSegmentRule is true when at least one rule matches by path segment
|
||||
// (non-anchored, no "/"). Only then does ignored() need to split relPath into
|
||||
// segments, so the common all-anchored case skips the split entirely.
|
||||
hasSegmentRule bool
|
||||
}
|
||||
|
||||
type ignoreRule struct {
|
||||
pattern string
|
||||
negate bool
|
||||
dirOnly bool
|
||||
anchored bool
|
||||
// matchFull is precomputed at load time: an anchored pattern, or one that
|
||||
// contains a "/", matches against the full relative path; otherwise the rule
|
||||
// matches by base name or any single path segment. Hoisting this out of the
|
||||
// per-file loop avoids a strings.Contains scan for every file × rule.
|
||||
matchFull bool
|
||||
}
|
||||
|
||||
// loadGitignore reads root/.gitignore. A missing file yields an empty matcher
|
||||
// (matches nothing), never an error.
|
||||
func loadGitignore(root string) *gitignore {
|
||||
gi := &gitignore{}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
|
||||
if err != nil {
|
||||
return gi
|
||||
}
|
||||
sc := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimRight(sc.Text(), " ")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
r := ignoreRule{}
|
||||
if strings.HasPrefix(line, "!") {
|
||||
r.negate = true
|
||||
line = line[1:]
|
||||
}
|
||||
if strings.HasSuffix(line, "/") {
|
||||
r.dirOnly = true
|
||||
line = strings.TrimSuffix(line, "/")
|
||||
}
|
||||
if strings.HasPrefix(line, "/") {
|
||||
r.anchored = true
|
||||
line = strings.TrimPrefix(line, "/")
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
r.pattern = line
|
||||
r.matchFull = r.anchored || strings.Contains(line, "/")
|
||||
if !r.matchFull {
|
||||
gi.hasSegmentRule = true
|
||||
}
|
||||
gi.rules = append(gi.rules, r)
|
||||
}
|
||||
return gi
|
||||
}
|
||||
|
||||
// ignored reports whether relPath (slash-separated, relative to root) is ignored.
|
||||
// isDir refines dir-only rules. Later rules win, so a negation can re-include.
|
||||
//
|
||||
// The relPath is split into segments at most once per call (only when a
|
||||
// segment-matching rule exists), rather than re-splitting inside the rule loop:
|
||||
// this keeps the per-file cost O(rules) instead of O(rules × pathSegments),
|
||||
// which matters because ignored() is called for every entry of a WalkDir.
|
||||
func (g *gitignore) ignored(relPath string, isDir bool) bool {
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
base := relPath
|
||||
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
||||
base = relPath[i+1:]
|
||||
}
|
||||
var segs []string
|
||||
if g.hasSegmentRule {
|
||||
segs = strings.Split(relPath, "/")
|
||||
}
|
||||
result := false
|
||||
for _, r := range g.rules {
|
||||
if r.dirOnly && !isDir {
|
||||
continue
|
||||
}
|
||||
var match bool
|
||||
if r.matchFull {
|
||||
match, _ = filepath.Match(r.pattern, relPath)
|
||||
} else {
|
||||
match, _ = filepath.Match(r.pattern, base)
|
||||
if !match {
|
||||
// A non-anchored pattern also matches any path component,
|
||||
// so an ignored directory hides everything beneath it.
|
||||
for _, seg := range segs {
|
||||
if ok, _ := filepath.Match(r.pattern, seg); ok {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if match {
|
||||
result = !r.negate
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GrepTool searches file contents by regexp under Root, honoring .gitignore.
|
||||
type GrepTool struct {
|
||||
// Root bounds the search; empty defaults to the current working directory.
|
||||
Root string
|
||||
}
|
||||
|
||||
type grepToolArgs struct {
|
||||
// Pattern is the regexp to search for (Go regexp syntax).
|
||||
Pattern string `json:"pattern"`
|
||||
// Path optionally scopes the search to a subdirectory (relative to Root).
|
||||
Path string `json:"path,omitempty"`
|
||||
// Glob optionally filters files by base-name glob (e.g. "*.go").
|
||||
Glob string `json:"glob,omitempty"`
|
||||
}
|
||||
|
||||
func (t *GrepTool) Name() string { return "grep" }
|
||||
func (t *GrepTool) Description() string {
|
||||
return "Search file contents by regular expression under the workspace, " +
|
||||
"optionally filtering files by glob. Skips .gitignore'd paths."
|
||||
}
|
||||
func (t *GrepTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
func (t *GrepTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regular expression to search for."},
|
||||
"path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."},
|
||||
"glob": {"type": "string", "description": "Filter files by base-name glob, e.g. *.go."}
|
||||
},
|
||||
"required": ["pattern"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
func (t *GrepTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[grepToolArgs](args, "grep")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Pattern == "" {
|
||||
return errorResult("grep: pattern is required"), nil
|
||||
}
|
||||
re, err := regexp.Compile(a.Pattern)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("grep: invalid pattern: %v", err)), nil
|
||||
}
|
||||
root, err := resolveWithin(t.Root, "")
|
||||
if err != nil {
|
||||
return errorResult("grep: " + err.Error()), nil
|
||||
}
|
||||
start := root
|
||||
if a.Path != "" {
|
||||
if start, err = resolveWithin(t.Root, a.Path); err != nil {
|
||||
return errorResult("grep: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
gi := loadGitignore(root)
|
||||
|
||||
var matches []string
|
||||
count := 0
|
||||
walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable entries
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if gi.ignored(rel, d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if a.Glob != "" {
|
||||
if ok, _ := filepath.Match(a.Glob, d.Name()); !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, scanBufInit), grepScanBufMax)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := sc.Text()
|
||||
if re.MatchString(line) {
|
||||
matches = append(matches, fmt.Sprintf("%s:%d:%s", rel, lineNo, line))
|
||||
count++
|
||||
if count >= searchMaxResults {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return errorResult(fmt.Sprintf("grep: %v", walkErr)), nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%d match(es) for %q", len(matches), a.Pattern)
|
||||
if len(matches) > 0 {
|
||||
msg += "\n" + strings.Join(matches, "\n")
|
||||
}
|
||||
if count >= searchMaxResults {
|
||||
msg += fmt.Sprintf("\n[truncated at %d matches]", searchMaxResults)
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
Details: map[string]any{"matches": len(matches)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindTool locates files by base-name glob under Root, honoring .gitignore.
|
||||
type FindTool struct {
|
||||
// Root bounds the search; empty defaults to the current working directory.
|
||||
Root string
|
||||
}
|
||||
|
||||
type findToolArgs struct {
|
||||
// Glob is the base-name glob to match (e.g. "*.go").
|
||||
Glob string `json:"glob"`
|
||||
// Path optionally scopes the search to a subdirectory (relative to Root).
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
func (t *FindTool) Name() string { return "find" }
|
||||
func (t *FindTool) Description() string {
|
||||
return "Find files by base-name glob under the workspace. Skips .gitignore'd paths."
|
||||
}
|
||||
func (t *FindTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
func (t *FindTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"glob": {"type": "string", "description": "Base-name glob to match, e.g. *.go."},
|
||||
"path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."}
|
||||
},
|
||||
"required": ["glob"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
func (t *FindTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[findToolArgs](args, "find")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Glob == "" {
|
||||
return errorResult("find: glob is required"), nil
|
||||
}
|
||||
root, err := resolveWithin(t.Root, "")
|
||||
if err != nil {
|
||||
return errorResult("find: " + err.Error()), nil
|
||||
}
|
||||
start := root
|
||||
if a.Path != "" {
|
||||
if start, err = resolveWithin(t.Root, a.Path); err != nil {
|
||||
return errorResult("find: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
gi := loadGitignore(root)
|
||||
|
||||
var found []string
|
||||
walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if gi.ignored(rel, d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if ok, _ := filepath.Match(a.Glob, d.Name()); ok {
|
||||
found = append(found, rel)
|
||||
if len(found) >= searchMaxResults {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return errorResult(fmt.Sprintf("find: %v", walkErr)), nil
|
||||
}
|
||||
sort.Strings(found)
|
||||
|
||||
msg := fmt.Sprintf("%d file(s) matching %q", len(found), a.Glob)
|
||||
if len(found) > 0 {
|
||||
msg += "\n" + strings.Join(found, "\n")
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
Details: map[string]any{"count": len(found)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LsTool lists the entries of a directory, distinguishing files from directories.
|
||||
type LsTool struct {
|
||||
// Root bounds the listing; empty defaults to the current working directory.
|
||||
Root string
|
||||
}
|
||||
|
||||
type lsToolArgs struct {
|
||||
// Path is the directory to list, relative to Root (empty = Root itself).
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
func (t *LsTool) Name() string { return "ls" }
|
||||
func (t *LsTool) Description() string {
|
||||
return "List a directory's entries, marking directories with a trailing slash."
|
||||
}
|
||||
func (t *LsTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel }
|
||||
func (t *LsTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Directory to list, relative to the workspace root."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
func (t *LsTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[lsToolArgs](args, "ls")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
full, err := resolveWithin(t.Root, a.Path)
|
||||
if err != nil {
|
||||
return errorResult("ls: " + err.Error()), nil
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult(fmt.Sprintf("ls: %q does not exist", a.Path)), nil
|
||||
}
|
||||
return errorResult(fmt.Sprintf("ls: cannot stat %q: %v", a.Path, err)), nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errorResult(fmt.Sprintf("ls: %q is not a directory", a.Path)), nil
|
||||
}
|
||||
entries, err := os.ReadDir(full)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("ls: cannot read %q: %v", a.Path, err)), nil
|
||||
}
|
||||
|
||||
var dirs, files []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, e.Name()+"/")
|
||||
} else {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
sort.Strings(files)
|
||||
lines := append(dirs, files...)
|
||||
|
||||
label := a.Path
|
||||
if label == "" {
|
||||
label = "."
|
||||
}
|
||||
msg := fmt.Sprintf("%s (%d dir(s), %d file(s))", label, len(dirs), len(files))
|
||||
if len(lines) > 0 {
|
||||
msg += "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
Details: map[string]any{"dirs": len(dirs), "files": len(files)},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runSearch(t *testing.T, tool agentcore.AgentTool, args map[string]any) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("execute returned go error: %v", gerr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// seedTree writes a small directory tree with a .gitignore for the search tests.
|
||||
func seedTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
mustWrite := func(rel, content string) {
|
||||
p := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
mustWrite("main.go", "package main\nfunc main() { hello() }\n")
|
||||
mustWrite("util.go", "package main\nfunc hello() {}\n")
|
||||
mustWrite("README.md", "# project\nhello world\n")
|
||||
mustWrite("sub/deep.go", "package sub\n// hello from sub\n")
|
||||
mustWrite("build/generated.go", "package build\nfunc hello() {}\n")
|
||||
mustWrite(".gitignore", "build/\n*.log\n")
|
||||
mustWrite("debug.log", "hello log line\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestGrepBasic(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &GrepTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{"pattern": "hello"})
|
||||
txt := resultText(res)
|
||||
// Matches in tracked files.
|
||||
if !strings.Contains(txt, "main.go") || !strings.Contains(txt, "util.go") {
|
||||
t.Errorf("expected go file matches, got %q", txt)
|
||||
}
|
||||
// .gitignore'd paths must be skipped.
|
||||
if strings.Contains(txt, "build/generated.go") {
|
||||
t.Errorf("ignored dir should be skipped: %q", txt)
|
||||
}
|
||||
if strings.Contains(txt, "debug.log") {
|
||||
t.Errorf("ignored *.log should be skipped: %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepGlobFilter(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &GrepTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{"pattern": "hello", "glob": "*.md"})
|
||||
txt := resultText(res)
|
||||
if !strings.Contains(txt, "README.md") {
|
||||
t.Errorf("expected README match, got %q", txt)
|
||||
}
|
||||
if strings.Contains(txt, ".go") {
|
||||
t.Errorf("glob *.md should exclude .go files: %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepInvalidPattern(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &GrepTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{"pattern": "["})
|
||||
if !strings.Contains(resultText(res), "invalid pattern") {
|
||||
t.Errorf("expected invalid-pattern error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGlob(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &FindTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{"glob": "*.go"})
|
||||
txt := resultText(res)
|
||||
if !strings.Contains(txt, "main.go") || !strings.Contains(txt, "sub/deep.go") {
|
||||
t.Errorf("expected go files, got %q", txt)
|
||||
}
|
||||
if strings.Contains(txt, "build/generated.go") {
|
||||
t.Errorf("ignored dir should be skipped: %q", txt)
|
||||
}
|
||||
if strings.Contains(txt, "README.md") {
|
||||
t.Errorf("*.go should not match README.md: %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLsDistinguishesFilesAndDirs(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &LsTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{})
|
||||
txt := resultText(res)
|
||||
// Directories carry a trailing slash.
|
||||
if !strings.Contains(txt, "sub/") {
|
||||
t.Errorf("expected sub/ dir marker, got %q", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "main.go") {
|
||||
t.Errorf("expected main.go file, got %q", txt)
|
||||
}
|
||||
details, ok := res.Details.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("details missing: %+v", res.Details)
|
||||
}
|
||||
if details["files"] == nil || details["dirs"] == nil {
|
||||
t.Errorf("expected file/dir counts, got %+v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLsNotADirectory(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &LsTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{"path": "main.go"})
|
||||
if !strings.Contains(resultText(res), "not a directory") {
|
||||
t.Errorf("expected not-a-directory error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLsMissing(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
tool := &LsTool{Root: dir}
|
||||
res := runSearch(t, tool, map[string]any{"path": "nope"})
|
||||
if !strings.Contains(resultText(res), "does not exist") {
|
||||
t.Errorf("expected does-not-exist error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchPathTraversal(t *testing.T) {
|
||||
dir := seedTree(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tool agentcore.AgentTool
|
||||
args map[string]any
|
||||
}{
|
||||
{"grep", &GrepTool{Root: dir}, map[string]any{"pattern": "x", "path": "../"}},
|
||||
{"find", &FindTool{Root: dir}, map[string]any{"glob": "*", "path": "../"}},
|
||||
{"ls", &LsTool{Root: dir}, map[string]any{"path": "../"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := runSearch(t, tc.tool, tc.args)
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Errorf("expected boundary error, got %q", resultText(res))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchToolModes(t *testing.T) {
|
||||
for _, tool := range []agentcore.AgentTool{&GrepTool{}, &FindTool{}, &LsTool{}} {
|
||||
if tool.ExecutionMode() != agentcore.ToolExecutionParallel {
|
||||
t.Errorf("%s should be parallel", tool.Name())
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Errorf("%s schema not valid JSON: %v", tool.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitignoreNegation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.txt\n!keep.txt\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gi := loadGitignore(dir)
|
||||
if !gi.ignored("drop.txt", false) {
|
||||
t.Error("*.txt should be ignored")
|
||||
}
|
||||
if gi.ignored("keep.txt", false) {
|
||||
t.Error("!keep.txt should be re-included")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitignoreMatchModes locks in the three matching modes after the load-time
|
||||
// precompilation (matchFull / hasSegmentRule): a non-anchored name rule matches
|
||||
// any path segment (so an ignored dir hides everything beneath it); an anchored
|
||||
// rule matches only at the root; and a slash-bearing pattern matches the full
|
||||
// relative path.
|
||||
func TestGitignoreMatchModes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// node_modules: non-anchored → matches any segment (nested too).
|
||||
// /root.log: anchored → only at repo root.
|
||||
// a/b.tmp: contains "/" → full-path match.
|
||||
rules := "node_modules\n/root.log\na/b.tmp\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(rules), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gi := loadGitignore(dir)
|
||||
cases := []struct {
|
||||
path string
|
||||
dir bool
|
||||
want bool
|
||||
}{
|
||||
{"node_modules", true, true}, // segment rule, top level
|
||||
{"pkg/node_modules", true, true}, // segment rule, nested
|
||||
{"pkg/node_modules/x/y.js", false, true}, // hidden beneath ignored dir
|
||||
{"root.log", false, true}, // anchored, at root
|
||||
{"sub/root.log", false, false}, // anchored must not match nested
|
||||
{"a/b.tmp", false, true}, // full-path match
|
||||
{"z/a/b.tmp", false, false}, // full-path rule not anchored elsewhere
|
||||
{"keep.go", false, false}, // unrelated
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := gi.ignored(c.path, c.dir); got != c.want {
|
||||
t.Errorf("ignored(%q, dir=%v) = %v, want %v", c.path, c.dir, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// This file implements the todo tool (US-011, #127): a structured task list the
|
||||
// model uses to plan and track multi-step work, with progress visible to the
|
||||
// user. Unlike the file tools this one is stateful — the written list lives in a
|
||||
// per-session TodoStore the tool holds, so a later write replaces the plan and
|
||||
// the REPL can render the current progress after each update.
|
||||
//
|
||||
// pi itself has no such tool; this mirrors Claude Code's TodoWrite: the model
|
||||
// submits the WHOLE list each call (not incremental edits), each item carries a
|
||||
// content string and a status of pending | in_progress | completed.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// TodoStatus is the lifecycle state of a single todo item.
|
||||
type TodoStatus string
|
||||
|
||||
const (
|
||||
// TodoPending is a task not yet started.
|
||||
TodoPending TodoStatus = "pending"
|
||||
// TodoInProgress is the task currently being worked on.
|
||||
TodoInProgress TodoStatus = "in_progress"
|
||||
// TodoCompleted is a finished task.
|
||||
TodoCompleted TodoStatus = "completed"
|
||||
)
|
||||
|
||||
// validTodoStatus reports whether s is one of the three accepted statuses.
|
||||
func validTodoStatus(s TodoStatus) bool {
|
||||
switch s {
|
||||
case TodoPending, TodoInProgress, TodoCompleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TodoItem is one entry in the task list.
|
||||
type TodoItem struct {
|
||||
// Content is the human-readable task description.
|
||||
Content string `json:"content"`
|
||||
// Status is the item's lifecycle state.
|
||||
Status TodoStatus `json:"status"`
|
||||
}
|
||||
|
||||
// TodoStore holds the current task list for a session. It is safe for concurrent
|
||||
// use so the tool (which may run in a batch) and the REPL renderer can touch it
|
||||
// without racing. A single store is shared for a session's lifetime.
|
||||
type TodoStore struct {
|
||||
mu sync.RWMutex
|
||||
items []TodoItem
|
||||
}
|
||||
|
||||
// NewTodoStore returns an empty store.
|
||||
func NewTodoStore() *TodoStore { return &TodoStore{} }
|
||||
|
||||
// Set replaces the whole list with items (a copy, so the caller's slice can be
|
||||
// reused).
|
||||
func (s *TodoStore) Set(items []TodoItem) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items = append(s.items[:0:0], items...)
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the current list, safe to read without holding the
|
||||
// lock.
|
||||
func (s *TodoStore) Snapshot() []TodoItem {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]TodoItem(nil), s.items...)
|
||||
}
|
||||
|
||||
// TodoTool is the stateful todo-list tool. It writes the submitted list into
|
||||
// Store, replacing any previous list, and returns a rendered progress view.
|
||||
type TodoTool struct {
|
||||
// Store holds the session task list. Must be non-nil; NewTodoStore builds one.
|
||||
Store *TodoStore
|
||||
}
|
||||
|
||||
// todoToolArgs is the decoded argument shape: the full task list to store.
|
||||
type todoToolArgs struct {
|
||||
Todos []TodoItem `json:"todos"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *TodoTool) Name() string { return "todo" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *TodoTool) Description() string {
|
||||
return "Record and update a structured task list to plan and track multi-step " +
|
||||
"work. Submit the ENTIRE list every call; it replaces the previous list. " +
|
||||
"Each item has a content string and a status of pending, in_progress, or " +
|
||||
"completed. Keep exactly one item in_progress at a time and mark items " +
|
||||
"completed as soon as they are done."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *TodoTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "The full task list, replacing any previous list.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Task description."},
|
||||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"], "description": "Task lifecycle state."}
|
||||
},
|
||||
"required": ["content", "status"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["todos"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Updating the shared list mutates session
|
||||
// state → sequential so a batch cannot interleave two list writes.
|
||||
func (t *TodoTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It validates every item's status, stores the
|
||||
// list, and returns the rendered progress as the result content. Invalid input
|
||||
// degrades to an error result (matching the file tools) rather than a Go error.
|
||||
func (t *TodoTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[todoToolArgs](args, "todo")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
for i, it := range a.Todos {
|
||||
if strings.TrimSpace(it.Content) == "" {
|
||||
return errorResult(fmt.Sprintf("todo: item %d has empty content", i+1)), nil
|
||||
}
|
||||
if !validTodoStatus(it.Status) {
|
||||
return errorResult(fmt.Sprintf("todo: item %d has invalid status %q (want pending|in_progress|completed)", i+1, it.Status)), nil
|
||||
}
|
||||
}
|
||||
|
||||
if t.Store == nil {
|
||||
t.Store = NewTodoStore()
|
||||
}
|
||||
t.Store.Set(a.Todos)
|
||||
|
||||
rendered := RenderTodoList(a.Todos)
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(rendered)},
|
||||
Details: map[string]any{"todos": a.Todos},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenderTodoList renders items as a checkbox progress block, one line per task,
|
||||
// with a trailing summary count. An empty list renders as a single "(no tasks)"
|
||||
// line so an intentional clear is still visible. The marks are: [ ] pending,
|
||||
// [~] in_progress, [x] completed.
|
||||
func RenderTodoList(items []TodoItem) string {
|
||||
if len(items) == 0 {
|
||||
return "Todos: (no tasks)"
|
||||
}
|
||||
var b strings.Builder
|
||||
done := 0
|
||||
b.WriteString("Todos:")
|
||||
for _, it := range items {
|
||||
mark := " "
|
||||
switch it.Status {
|
||||
case TodoInProgress:
|
||||
mark = "~"
|
||||
case TodoCompleted:
|
||||
mark = "x"
|
||||
done++
|
||||
}
|
||||
fmt.Fprintf(&b, "\n [%s] %s", mark, it.Content)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n(%d/%d completed)", done, len(items))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Tests for the todo tool (US-011, #127): registration/validation, status
|
||||
// transitions across successive writes, and the rendered progress view.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// execTodo runs the tool with the given JSON args and returns the result.
|
||||
func execTodo(t *testing.T, tool *TodoTool, args string) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
res, err := tool.Execute(context.Background(), "call-1", json.RawMessage(args), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned Go error: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestTodoToolRegisters checks the tool registers cleanly (valid schema) and is
|
||||
// retrievable — the "registered in the agenttool registry" acceptance criterion.
|
||||
func TestTodoToolRegisters(t *testing.T) {
|
||||
reg := NewToolRegistry()
|
||||
tool := &TodoTool{Store: NewTodoStore()}
|
||||
if err := reg.Register(tool); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
got, ok := reg.Get("todo")
|
||||
if !ok {
|
||||
t.Fatal("todo tool not found after Register")
|
||||
}
|
||||
if got.Name() != "todo" {
|
||||
t.Errorf("Name = %q, want todo", got.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTodoToolStoresList checks a write lands in the session store.
|
||||
func TestTodoToolStoresList(t *testing.T) {
|
||||
store := NewTodoStore()
|
||||
tool := &TodoTool{Store: store}
|
||||
execTodo(t, tool, `{"todos":[
|
||||
{"content":"first","status":"in_progress"},
|
||||
{"content":"second","status":"pending"}
|
||||
]}`)
|
||||
|
||||
items := store.Snapshot()
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("Snapshot len = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Content != "first" || items[0].Status != TodoInProgress {
|
||||
t.Errorf("item 0 = %+v", items[0])
|
||||
}
|
||||
if items[1].Status != TodoPending {
|
||||
t.Errorf("item 1 status = %q, want pending", items[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTodoToolStatusTransition checks a later write replaces the previous list,
|
||||
// reflecting a status flow pending → in_progress → completed.
|
||||
func TestTodoToolStatusTransition(t *testing.T) {
|
||||
store := NewTodoStore()
|
||||
tool := &TodoTool{Store: store}
|
||||
|
||||
execTodo(t, tool, `{"todos":[{"content":"build feature","status":"pending"}]}`)
|
||||
if s := store.Snapshot()[0].Status; s != TodoPending {
|
||||
t.Fatalf("after write 1 status = %q, want pending", s)
|
||||
}
|
||||
|
||||
execTodo(t, tool, `{"todos":[{"content":"build feature","status":"in_progress"}]}`)
|
||||
if s := store.Snapshot()[0].Status; s != TodoInProgress {
|
||||
t.Fatalf("after write 2 status = %q, want in_progress", s)
|
||||
}
|
||||
|
||||
execTodo(t, tool, `{"todos":[{"content":"build feature","status":"completed"}]}`)
|
||||
items := store.Snapshot()
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("after write 3 len = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].Status != TodoCompleted {
|
||||
t.Errorf("after write 3 status = %q, want completed", items[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTodoToolRejectsInvalidStatus checks an unknown status degrades to an error
|
||||
// result and does not mutate the store.
|
||||
func TestTodoToolRejectsInvalidStatus(t *testing.T) {
|
||||
store := NewTodoStore()
|
||||
tool := &TodoTool{Store: store}
|
||||
res := execTodo(t, tool, `{"todos":[{"content":"x","status":"done"}]}`)
|
||||
if !strings.Contains(agentcore.ContentToText(res.Content), "invalid status") {
|
||||
t.Errorf("expected invalid-status error, got %q", agentcore.ContentToText(res.Content))
|
||||
}
|
||||
if len(store.Snapshot()) != 0 {
|
||||
t.Error("store mutated despite invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTodoToolRejectsEmptyContent checks a blank content is rejected.
|
||||
func TestTodoToolRejectsEmptyContent(t *testing.T) {
|
||||
tool := &TodoTool{Store: NewTodoStore()}
|
||||
res := execTodo(t, tool, `{"todos":[{"content":" ","status":"pending"}]}`)
|
||||
if !strings.Contains(agentcore.ContentToText(res.Content), "empty content") {
|
||||
t.Errorf("expected empty-content error, got %q", agentcore.ContentToText(res.Content))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTodoList checks the rendered progress view: marks per status and a
|
||||
// completion count.
|
||||
func TestRenderTodoList(t *testing.T) {
|
||||
out := RenderTodoList([]TodoItem{
|
||||
{Content: "alpha", Status: TodoCompleted},
|
||||
{Content: "beta", Status: TodoInProgress},
|
||||
{Content: "gamma", Status: TodoPending},
|
||||
})
|
||||
for _, want := range []string{"[x] alpha", "[~] beta", "[ ] gamma", "(1/3 completed)"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("render missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTodoListEmpty checks an empty list renders visibly (an intentional
|
||||
// clear should still show).
|
||||
func TestRenderTodoListEmpty(t *testing.T) {
|
||||
if out := RenderTodoList(nil); !strings.Contains(out, "no tasks") {
|
||||
t.Errorf("empty render = %q, want a (no tasks) marker", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTodoToolResultRenders checks Execute returns the rendered list as content
|
||||
// so the REPL has something to display.
|
||||
func TestTodoToolResultRenders(t *testing.T) {
|
||||
tool := &TodoTool{Store: NewTodoStore()}
|
||||
res := execTodo(t, tool, `{"todos":[{"content":"do it","status":"completed"}]}`)
|
||||
text := agentcore.ContentToText(res.Content)
|
||||
if !strings.Contains(text, "[x] do it") || !strings.Contains(text, "(1/1 completed)") {
|
||||
t.Errorf("result content = %q", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// This file implements the three-phase tool execution (US-004): prepare →
|
||||
// execute → finalize, with the beforeToolCall / afterToolCall hooks. It mirrors
|
||||
// pi's agent-loop tool handling: a tool call is looked up in the registry, its
|
||||
// arguments are (optionally) prepared and schema-validated, the beforeToolCall
|
||||
// hook may block it, the tool runs (streaming partial updates), and the
|
||||
// afterToolCall hook may override the result field-by-field (no deep merge).
|
||||
//
|
||||
// Every failure mode (unknown tool, validation failure, block, abort, tool
|
||||
// error/panic) is turned into an error tool result rather than a Go error, so
|
||||
// the loop always has a ToolResultMessage to feed back to the model.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// toolResultMaxBytes is the executor-layer budget for a single tool result's
|
||||
// combined text, applied uniformly to EVERY tool right before its result enters
|
||||
// the AgentToolResult / message list. Individual tools also impose their own,
|
||||
// stricter inner caps (read: readToolMaxLines, search: searchMaxResults,
|
||||
// webfetch: webFetchMaxBytes, bash: bashMaxOutputBytes); those still run first
|
||||
// and clip a tool below this outer budget. This budget is the last line of
|
||||
// defense so a tool with no (or a looser) inner cap cannot blow the model's
|
||||
// context. Override per-executor via ToolExecutorConfig.MaxResultBytes.
|
||||
const toolResultMaxBytes = 100_000
|
||||
|
||||
// ToolExecutorConfig holds the registry and the optional per-phase hooks. Every
|
||||
// hook is optional (nil = default behavior).
|
||||
type ToolExecutorConfig struct {
|
||||
Registry *ToolRegistry
|
||||
PrepareArguments agentcore.PrepareArgumentsFunc
|
||||
BeforeToolCall agentcore.BeforeToolCallFunc
|
||||
AfterToolCall agentcore.AfterToolCallFunc
|
||||
// MaxResultBytes overrides the executor-layer per-result text budget. Zero
|
||||
// (the default) uses toolResultMaxBytes; a negative value disables the
|
||||
// budget entirely.
|
||||
MaxResultBytes int
|
||||
// MaxToolRetries overrides the number of RETRIES for a transient tool error
|
||||
// (see isRetryableToolError). Zero (the default) uses maxToolRetries; a
|
||||
// negative value disables retrying (a single attempt). Mirrors the
|
||||
// MaxResultBytes sentinel convention.
|
||||
MaxToolRetries int
|
||||
}
|
||||
|
||||
// executeToolCall runs one tool call through prepare → execute → finalize and
|
||||
// returns the resulting ToolResultMessage plus whether the batch should
|
||||
// terminate. emit may be nil (no events). It never returns a Go error: every
|
||||
// failure is encoded into the returned message with IsError=true.
|
||||
func executeToolCall(ctx context.Context, cfg ToolExecutorConfig, call agentcore.AgentToolCall, emit agentcore.EmitFunc) (agentcore.ToolResultMessage, bool) {
|
||||
// 1. prepare: lookup, prepareArguments, validate, beforeToolCall.
|
||||
tool, args, prep, isError := prepareToolCall(ctx, cfg, call)
|
||||
if prep != nil {
|
||||
// Prepare short-circuited (unknown tool / prepare error / validation /
|
||||
// block / abort): finalize the error result without executing.
|
||||
return finalizeToolCall(ctx, cfg, call, *prep, isError, emit)
|
||||
}
|
||||
|
||||
// 2. execute.
|
||||
if emit != nil {
|
||||
if err := emit(ctx, agentcore.ToolExecutionStartEvent{ToolCallID: call.ID, ToolName: call.Name, Args: args}); err != nil {
|
||||
return errorToolResult(call, "aborted before execution: "+err.Error()), false
|
||||
}
|
||||
}
|
||||
result, isError := runToolWithRetry(ctx, cfg, tool, call, args, emit)
|
||||
|
||||
// 3. finalize: afterToolCall overrides.
|
||||
return finalizeToolCall(ctx, cfg, call, result, isError, emit)
|
||||
}
|
||||
|
||||
// prepareToolCall performs the prepare phase. On success it returns the tool and
|
||||
// the (possibly rewritten) arguments with a nil result. On any short-circuit it
|
||||
// returns a non-nil *AgentToolResult and the isError flag.
|
||||
func prepareToolCall(ctx context.Context, cfg ToolExecutorConfig, call agentcore.AgentToolCall) (agentcore.AgentTool, json.RawMessage, *agentcore.AgentToolResult, bool) {
|
||||
if ctx.Err() != nil {
|
||||
r := errorResult(fmt.Sprintf("tool %q aborted before execution", call.Name))
|
||||
return nil, nil, &r, true
|
||||
}
|
||||
|
||||
// Registry lookup.
|
||||
tool, ok := cfg.Registry.Get(call.Name)
|
||||
if !ok {
|
||||
r := errorResult(fmt.Sprintf("unknown tool %q", call.Name))
|
||||
return nil, nil, &r, true
|
||||
}
|
||||
|
||||
// prepareArguments (optional).
|
||||
args := call.Arguments
|
||||
if cfg.PrepareArguments != nil {
|
||||
prepared, err := cfg.PrepareArguments(ctx, call.Name, args)
|
||||
if err != nil {
|
||||
r := errorResult(fmt.Sprintf("prepareArguments for %q failed: %v", call.Name, err))
|
||||
return nil, nil, &r, true
|
||||
}
|
||||
args = prepared
|
||||
}
|
||||
|
||||
// JSON Schema validation.
|
||||
if errs := cfg.Registry.Validate(call.Name, args); len(errs) > 0 {
|
||||
r := ValidationErrorResult(call.Name, errs)
|
||||
return nil, nil, &r, true
|
||||
}
|
||||
|
||||
// beforeToolCall hook (may block or rewrite arguments).
|
||||
if cfg.BeforeToolCall != nil {
|
||||
if dec := cfg.BeforeToolCall(ctx, agentcore.AgentToolCall{ID: call.ID, Name: call.Name, Arguments: args}); dec != nil {
|
||||
if dec.Block {
|
||||
r := agentcore.AgentToolResult{}
|
||||
if dec.Content != nil {
|
||||
r.Content = *dec.Content
|
||||
} else {
|
||||
r.Content = agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("tool %q blocked by beforeToolCall", call.Name))}
|
||||
}
|
||||
if dec.Details != nil {
|
||||
r.Details = *dec.Details
|
||||
}
|
||||
return nil, nil, &r, true
|
||||
}
|
||||
// Argument rewrite (PreToolUse updatedInput): replace and re-validate
|
||||
// so a hook cannot smuggle schema-invalid args past the tool.
|
||||
if len(dec.UpdatedInput) > 0 {
|
||||
args = dec.UpdatedInput
|
||||
if errs := cfg.Registry.Validate(call.Name, args); len(errs) > 0 {
|
||||
r := ValidationErrorResult(call.Name, errs)
|
||||
return nil, nil, &r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tool, args, nil, false
|
||||
}
|
||||
|
||||
// runToolWithRetry wraps runTool with the classified, bounded retry policy at
|
||||
// the single tool-execution seam so EVERY tool gets uniform resilience. It only
|
||||
// retries when runTool surfaces a non-nil Go error (transport/agent-error path)
|
||||
// AND isRetryableToolError says that error is transient; a (result, nil) is
|
||||
// done regardless of the result's IsError flag (a tool's own terminal result is
|
||||
// never retried). Retries are capped by toolRetryCap and separated by a small
|
||||
// backoff. Context cancellation short-circuits immediately: a cancelled/expired
|
||||
// outer ctx is never retried.
|
||||
func runToolWithRetry(ctx context.Context, cfg ToolExecutorConfig, tool agentcore.AgentTool, call agentcore.AgentToolCall, args json.RawMessage, emit agentcore.EmitFunc) (agentcore.AgentToolResult, bool) {
|
||||
retryCap := toolRetryCap(cfg.MaxToolRetries)
|
||||
|
||||
var lastResult agentcore.AgentToolResult
|
||||
var lastIsError bool
|
||||
for attempt := 0; attempt <= retryCap; attempt++ {
|
||||
result, err, isError := runTool(ctx, tool, call, args, emit)
|
||||
if err == nil {
|
||||
// Execute returned (result, nil): terminal success regardless of
|
||||
// the result's own IsError flag. Done, no retry.
|
||||
return result, isError
|
||||
}
|
||||
|
||||
lastResult, lastIsError = result, isError
|
||||
|
||||
// Do not retry if the outer context is done (Canceled or its deadline
|
||||
// has passed) — a dead context means stop.
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
// Only transient errors are retried, and only if we have budget left.
|
||||
if attempt >= retryCap || !isRetryableToolError(err) {
|
||||
break
|
||||
}
|
||||
// Small backoff; abort the wait early if ctx dies mid-sleep.
|
||||
if !waitToolRetryBackoff(ctx, attempt) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastResult, lastIsError
|
||||
}
|
||||
|
||||
// runTool executes the tool, recovering a panic into an error. It returns the
|
||||
// shaped error result, the raw error (nil on success), and the isError flag.
|
||||
// The raw error is surfaced so the caller can classify it for retry; on success
|
||||
// err is nil even if the result itself carries IsError semantics.
|
||||
func runTool(ctx context.Context, tool agentcore.AgentTool, call agentcore.AgentToolCall, args json.RawMessage, emit agentcore.EmitFunc) (result agentcore.AgentToolResult, rawErr error, isError bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
result = errorResult(fmt.Sprintf("tool %q panicked: %v", call.Name, r))
|
||||
rawErr = toolPanic{value: r}
|
||||
isError = true
|
||||
}
|
||||
}()
|
||||
|
||||
onUpdate := func(partial agentcore.AgentToolResult) {
|
||||
if emit == nil {
|
||||
return
|
||||
}
|
||||
_ = emit(ctx, agentcore.ToolExecutionUpdateEvent{ToolCallID: call.ID, ToolName: call.Name, PartialResult: partial})
|
||||
}
|
||||
|
||||
res, err := tool.Execute(ctx, call.ID, args, onUpdate)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("tool %q failed: %v", call.Name, err)), err, true
|
||||
}
|
||||
return res, nil, false
|
||||
}
|
||||
|
||||
// finalizeToolCall applies the afterToolCall hook (field-level override, no deep
|
||||
// merge), emits the tool_execution_end event, and builds the ToolResultMessage.
|
||||
// It returns the message and whether this result requests termination.
|
||||
func finalizeToolCall(ctx context.Context, cfg ToolExecutorConfig, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool, emit agentcore.EmitFunc) (agentcore.ToolResultMessage, bool) {
|
||||
if cfg.AfterToolCall != nil {
|
||||
if ov := cfg.AfterToolCall(ctx, call, result, isError); ov != nil {
|
||||
if ov.Content != nil {
|
||||
result.Content = *ov.Content
|
||||
}
|
||||
if ov.Details != nil {
|
||||
result.Details = *ov.Details
|
||||
}
|
||||
if ov.Terminate != nil {
|
||||
result.Terminate = ov.Terminate
|
||||
}
|
||||
if ov.IsError != nil {
|
||||
isError = *ov.IsError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Result-shaping seam: every tool's output funnels through here before it
|
||||
// becomes a ToolResultMessage, so this is the single point where the
|
||||
// executor-layer byte budget is enforced uniformly for ALL tools.
|
||||
result.Content = clipToolResultContent(result.Content, cfg.MaxResultBytes)
|
||||
|
||||
if emit != nil {
|
||||
_ = emit(ctx, agentcore.ToolExecutionEndEvent{ToolCallID: call.ID, ToolName: call.Name, Result: result, IsError: isError})
|
||||
}
|
||||
|
||||
terminate := result.Terminate != nil && *result.Terminate
|
||||
return agentcore.ToolResultMessage{
|
||||
RoleField: agentcore.RoleToolResult,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Content: result.Content,
|
||||
Details: result.Details,
|
||||
IsError: isError,
|
||||
}, terminate
|
||||
}
|
||||
|
||||
// errorResult builds an error AgentToolResult carrying a single text block.
|
||||
func errorResult(msg string) agentcore.AgentToolResult {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(msg)}}
|
||||
}
|
||||
|
||||
// clipToolResultContent enforces the executor-layer byte budget on a tool
|
||||
// result's text, uniformly for every tool. budget<=0 with the sentinel meaning:
|
||||
// 0 => toolResultMaxBytes default, <0 => disabled. Non-text blocks (e.g. images)
|
||||
// pass through untouched and keep their order; the combined text of all text
|
||||
// blocks is measured against the budget and, when over, collapsed into a single
|
||||
// truncated text block via truncateToBudget (head + "[truncated N bytes]" +
|
||||
// tail, matching the bash idiom). Per-tool inner caps have already run, so this
|
||||
// only bites when a tool's own cap is looser or absent.
|
||||
func clipToolResultContent(content agentcore.ContentList, cfgMax int) agentcore.ContentList {
|
||||
budget := cfgMax
|
||||
if budget == 0 {
|
||||
budget = toolResultMaxBytes
|
||||
}
|
||||
if budget < 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
total := 0
|
||||
textBlocks := 0
|
||||
for _, c := range content {
|
||||
if t, ok := c.(agentcore.TextContent); ok {
|
||||
total += len(t.Text)
|
||||
textBlocks++
|
||||
}
|
||||
}
|
||||
if textBlocks == 0 || total <= budget {
|
||||
return content
|
||||
}
|
||||
|
||||
// Over budget: gather all text (in order) and non-text blocks separately,
|
||||
// then emit the non-text blocks followed by one truncated text block.
|
||||
var sb strings.Builder
|
||||
out := make(agentcore.ContentList, 0, len(content))
|
||||
for _, c := range content {
|
||||
if t, ok := c.(agentcore.TextContent); ok {
|
||||
sb.WriteString(t.Text)
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
out = append(out, agentcore.NewTextContent(truncateToBudget(sb.String(), budget)))
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateToBudget caps s at budget bytes. When s is longer it keeps a head and
|
||||
// a tail preview (split evenly) joined by a "[truncated N bytes]" marker, so
|
||||
// both the start and the end of the text survive. Cut points are pulled back to
|
||||
// UTF-8 rune boundaries so no partial rune is emitted; N counts the raw bytes
|
||||
// dropped from the middle. This is the single shared truncation idiom reused by
|
||||
// both the bash tool's inner cap and the executor-layer budget.
|
||||
func truncateToBudget(s string, budget int) string {
|
||||
if budget <= 0 || len(s) <= budget {
|
||||
return s
|
||||
}
|
||||
half := budget / 2
|
||||
head := trimUTF8Prefix(s[:half])
|
||||
tail := trimUTF8Suffix(s[len(s)-half:])
|
||||
removed := len(s) - len(head) - len(tail)
|
||||
return head + fmt.Sprintf("\n[truncated %d bytes]\n", removed) + tail
|
||||
}
|
||||
|
||||
// decodeArgs unmarshals a tool's JSON arguments into T. On failure it returns an
|
||||
// error result already shaped as "<tool>: invalid arguments: ...", so a tool's
|
||||
// Execute can decode and bail in one line:
|
||||
//
|
||||
// a, bad := decodeArgs[readToolArgs](args, "read")
|
||||
// if bad != nil {
|
||||
// return *bad, nil
|
||||
// }
|
||||
//
|
||||
// The ok flag distinguishes the failure case without comparing the zero value.
|
||||
func decodeArgs[T any](args json.RawMessage, tool string) (T, *agentcore.AgentToolResult) {
|
||||
var a T
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
res := errorResult(fmt.Sprintf("%s: invalid arguments: %v", tool, err))
|
||||
return a, &res
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// errorToolResult builds an error ToolResultMessage directly (used when a call
|
||||
// is aborted outside the normal finalize path).
|
||||
func errorToolResult(call agentcore.AgentToolCall, msg string) agentcore.ToolResultMessage {
|
||||
return agentcore.ToolResultMessage{
|
||||
RoleField: agentcore.RoleToolResult,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// execTool is a configurable AgentTool for executor tests.
|
||||
type execTool struct {
|
||||
name string
|
||||
schema string
|
||||
run func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error)
|
||||
mode agentcore.ToolExecutionMode
|
||||
}
|
||||
|
||||
func (t execTool) Name() string { return t.name }
|
||||
func (t execTool) Description() string { return "exec" }
|
||||
func (t execTool) Schema() json.RawMessage {
|
||||
if t.schema == "" {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(t.schema)
|
||||
}
|
||||
func (t execTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
if t.mode == "" {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
return t.mode
|
||||
}
|
||||
func (t execTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return t.run(ctx, id, args, onUpdate)
|
||||
}
|
||||
|
||||
func newExecCfg(t *testing.T, tool agentcore.AgentTool) ToolExecutorConfig {
|
||||
t.Helper()
|
||||
r := NewToolRegistry()
|
||||
if err := r.Register(tool); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
return ToolExecutorConfig{Registry: r}
|
||||
}
|
||||
|
||||
func textOf(msg agentcore.ToolResultMessage) string {
|
||||
if len(msg.Content) == 0 {
|
||||
return ""
|
||||
}
|
||||
if tc, ok := msg.Content[0].(agentcore.TextContent); ok {
|
||||
return tc.Text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestExecutorNormal(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("done")}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
|
||||
var events []agentcore.AgentEvent
|
||||
emit := func(ctx context.Context, ev agentcore.AgentEvent) error { events = append(events, ev); return nil }
|
||||
msg, term := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, emit)
|
||||
|
||||
if msg.IsError || textOf(msg) != "done" {
|
||||
t.Fatalf("normal result wrong: %+v", msg)
|
||||
}
|
||||
if term {
|
||||
t.Error("normal result should not terminate")
|
||||
}
|
||||
wantKinds := []string{agentcore.EventToolExecutionStart, agentcore.EventToolExecutionEnd}
|
||||
if len(events) != 2 || events[0].EventType() != wantKinds[0] || events[1].EventType() != wantKinds[1] {
|
||||
t.Errorf("events wrong: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorUnknownTool(t *testing.T) {
|
||||
cfg := ToolExecutorConfig{Registry: NewToolRegistry()}
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "ghost"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("unknown tool should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorValidationFailure(t *testing.T) {
|
||||
schema := `{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"],"additionalProperties":false}`
|
||||
tool := execTool{name: "need", schema: schema, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Fatal("execute must not run on validation failure")
|
||||
return agentcore.AgentToolResult{}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "need", Arguments: json.RawMessage(`{}`)}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("validation failure should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorBlock(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Fatal("execute must not run when blocked")
|
||||
return agentcore.AgentToolResult{}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.BeforeToolCall = func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
|
||||
return &agentcore.BeforeToolCallDecision{Block: true}
|
||||
}
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("blocked call should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorToolError(t *testing.T) {
|
||||
tool := execTool{name: "boom", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{}, errors.New("kaboom")
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "boom"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("tool error should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorPanicRecovered(t *testing.T) {
|
||||
tool := execTool{name: "panic", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
panic("oops")
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "panic"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("panic should be recovered into error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorAfterToolCallOverride(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("orig")}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
newContent := agentcore.ContentList{agentcore.NewTextContent("overridden")}
|
||||
isErr := true
|
||||
term := true
|
||||
cfg.AfterToolCall = func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
|
||||
return &agentcore.AfterToolCallResult{Content: &newContent, IsError: &isErr, Terminate: &term}
|
||||
}
|
||||
msg, terminate := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, nil)
|
||||
if textOf(msg) != "overridden" {
|
||||
t.Errorf("content override failed: %q", textOf(msg))
|
||||
}
|
||||
if !msg.IsError {
|
||||
t.Error("isError override failed")
|
||||
}
|
||||
if !terminate {
|
||||
t.Error("terminate override failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorUpdateCallback(t *testing.T) {
|
||||
tool := execTool{name: "stream", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("partial")}})
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("final")}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
var updates int
|
||||
emit := func(ctx context.Context, ev agentcore.AgentEvent) error {
|
||||
if ev.EventType() == agentcore.EventToolExecutionUpdate {
|
||||
updates++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "stream"}, emit)
|
||||
if updates != 1 {
|
||||
t.Errorf("expected 1 update event, got %d", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorAbortedContext(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Fatal("execute must not run when context already cancelled")
|
||||
return agentcore.AgentToolResult{}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
msg, _ := executeToolCall(ctx, cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("aborted call should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudget proves the executor-layer byte budget applies to any
|
||||
// tool: a stub tool emitting output larger than the budget gets its result text
|
||||
// truncated with an accurate "[truncated N bytes]" marker, while a small output
|
||||
// is left untouched.
|
||||
func TestExecutorResultBudget(t *testing.T) {
|
||||
const budget = 1000
|
||||
big := strings.Repeat("A", budget) + strings.Repeat("B", budget) // 2*budget bytes
|
||||
tool := execTool{name: "flood", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(big)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.MaxResultBytes = budget
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flood"}, nil)
|
||||
got := textOf(msg)
|
||||
if len(got) >= len(big) {
|
||||
t.Fatalf("output not truncated: len=%d, original=%d", len(got), len(big))
|
||||
}
|
||||
half := budget / 2
|
||||
removed := len(big) - 2*half
|
||||
marker := fmt.Sprintf("\n[truncated %d bytes]\n", removed)
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("missing/incorrect truncation marker %q in output %q", marker, got)
|
||||
}
|
||||
want := big[:half] + marker + big[len(big)-half:]
|
||||
if got != want {
|
||||
t.Fatalf("truncated output mismatch:\n got=%q\nwant=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudgetSmallOutputUntouched proves outputs within budget are
|
||||
// passed through verbatim.
|
||||
func TestExecutorResultBudgetSmallOutputUntouched(t *testing.T) {
|
||||
small := "just a little output"
|
||||
tool := execTool{name: "tiny", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(small)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.MaxResultBytes = 1000
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "tiny"}, nil)
|
||||
if got := textOf(msg); got != small {
|
||||
t.Fatalf("small output altered: got=%q want=%q", got, small)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudgetDefault proves the default (zero MaxResultBytes) uses
|
||||
// toolResultMaxBytes and truncates output beyond it.
|
||||
func TestExecutorResultBudgetDefault(t *testing.T) {
|
||||
big := strings.Repeat("x", toolResultMaxBytes+5000)
|
||||
tool := execTool{name: "flood", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(big)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool) // MaxResultBytes == 0 -> default
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flood"}, nil)
|
||||
got := textOf(msg)
|
||||
if !strings.Contains(got, "[truncated ") {
|
||||
t.Fatalf("default budget did not truncate: len=%d", len(got))
|
||||
}
|
||||
if len(got) > toolResultMaxBytes+64 {
|
||||
t.Fatalf("default-truncated output too large: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudgetDisabled proves a negative MaxResultBytes disables the
|
||||
// budget entirely.
|
||||
func TestExecutorResultBudgetDisabled(t *testing.T) {
|
||||
big := strings.Repeat("y", toolResultMaxBytes*2)
|
||||
tool := execTool{name: "flood", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(big)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.MaxResultBytes = -1
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flood"}, nil)
|
||||
if got := textOf(msg); got != big {
|
||||
t.Fatalf("disabled budget altered output: len=%d want=%d", len(got), len(big))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool-execution retry (node #252) ---------------------------------------
|
||||
|
||||
// countingTool returns a transient error for its first failN attempts, then
|
||||
// succeeds; if failN < 0 it always fails. It records how many times Execute ran.
|
||||
type retryStub struct {
|
||||
failN int // number of leading failures before success; <0 = always fail
|
||||
err error // error to return on a failing attempt
|
||||
attempts int32
|
||||
}
|
||||
|
||||
func (s *retryStub) run(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
n := atomic.AddInt32(&s.attempts, 1)
|
||||
if s.failN < 0 || int(n) <= s.failN {
|
||||
return agentcore.AgentToolResult{}, s.err
|
||||
}
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("ok")}}, nil
|
||||
}
|
||||
|
||||
func newRetryCfg(t *testing.T, name string, s *retryStub) ToolExecutorConfig {
|
||||
t.Helper()
|
||||
return newExecCfg(t, execTool{name: name, run: s.run})
|
||||
}
|
||||
|
||||
func TestExecutorRetryTransientThenSuccess(t *testing.T) {
|
||||
// Fails 2 times with a transient error, then succeeds. With the default cap
|
||||
// (2 retries = 3 attempts) this should ultimately succeed on attempt 3.
|
||||
s := &retryStub{failN: 2, err: syscall.ECONNRESET}
|
||||
cfg := newRetryCfg(t, "flaky", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flaky"}, nil)
|
||||
if msg.IsError {
|
||||
t.Fatalf("expected eventual success, got error: %q", textOf(msg))
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 retries), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryCapExhausted(t *testing.T) {
|
||||
// Always fails with a transient error: must stop after maxToolRetries+1
|
||||
// attempts (default cap) and give up with an error result.
|
||||
s := &retryStub{failN: -1, err: syscall.ETIMEDOUT}
|
||||
cfg := newRetryCfg(t, "always", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "always"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result after exhausting retries: %+v", msg)
|
||||
}
|
||||
want := int32(maxToolRetries + 1)
|
||||
if got := atomic.LoadInt32(&s.attempts); got != want {
|
||||
t.Fatalf("expected %d attempts, got %d", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryTerminalNoRetry(t *testing.T) {
|
||||
// A terminal (non-transient) error must be tried exactly once.
|
||||
s := &retryStub{failN: -1, err: errors.New("invalid argument: bad")}
|
||||
cfg := newRetryCfg(t, "terminal", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "terminal"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("terminal error must not retry: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryDisabled(t *testing.T) {
|
||||
// MaxToolRetries < 0 disables retry even for a transient error.
|
||||
s := &retryStub{failN: -1, err: syscall.ECONNRESET}
|
||||
cfg := newRetryCfg(t, "notretry", s)
|
||||
cfg.MaxToolRetries = -1
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "notretry"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("disabled retry must try once: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryCustomCap(t *testing.T) {
|
||||
// A custom positive cap is honored: always-failing transient error stops at
|
||||
// cap+1 attempts.
|
||||
s := &retryStub{failN: -1, err: syscall.EAGAIN}
|
||||
cfg := newRetryCfg(t, "custom", s)
|
||||
cfg.MaxToolRetries = 4
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "custom"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 5 {
|
||||
t.Fatalf("expected 5 attempts (cap 4), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryCanceledContextNoRetry(t *testing.T) {
|
||||
// context.Canceled surfaced by the tool must never be retried.
|
||||
s := &retryStub{failN: -1, err: context.Canceled}
|
||||
cfg := newRetryCfg(t, "cancel", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "cancel"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("context.Canceled must not retry: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryStopsWhenOuterCtxCanceled(t *testing.T) {
|
||||
// If the outer ctx is cancelled during execution, retries stop even though
|
||||
// the returned error is transient.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &retryStub{failN: -1, err: syscall.ECONNRESET}
|
||||
tool := execTool{name: "abortmid", run: func(c context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
atomic.AddInt32(&s.attempts, 1)
|
||||
cancel() // outer ctx dies after the first attempt
|
||||
return agentcore.AgentToolResult{}, syscall.ECONNRESET
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(ctx, cfg, agentcore.AgentToolCall{ID: "1", Name: "abortmid"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("cancelled outer ctx must stop retry: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableToolError(t *testing.T) {
|
||||
transient := []error{
|
||||
syscall.ETIMEDOUT,
|
||||
syscall.ECONNRESET,
|
||||
syscall.EAGAIN,
|
||||
context.DeadlineExceeded,
|
||||
os.ErrDeadlineExceeded,
|
||||
fmt.Errorf("dial tcp: %w", syscall.ECONNRESET),
|
||||
errors.New("connection refused"),
|
||||
errors.New("resource temporarily unavailable"),
|
||||
errors.New("read: i/o timeout"),
|
||||
&net.DNSError{IsTimeout: true},
|
||||
}
|
||||
for _, err := range transient {
|
||||
if !isRetryableToolError(err) {
|
||||
t.Errorf("expected transient (retryable): %v", err)
|
||||
}
|
||||
}
|
||||
terminal := []error{
|
||||
nil,
|
||||
context.Canceled,
|
||||
fmt.Errorf("wrapped: %w", context.Canceled),
|
||||
errors.New("file not found"),
|
||||
errors.New("invalid argument"),
|
||||
os.ErrNotExist,
|
||||
toolPanic{value: "boom"},
|
||||
}
|
||||
for _, err := range terminal {
|
||||
if isRetryableToolError(err) {
|
||||
t.Errorf("expected terminal (not retryable): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorRetrySuccessResultWithIsErrorNotRetried proves that a
|
||||
// (result, nil) whose own content signals an error is NOT retried: only a
|
||||
// non-nil Go error triggers retry.
|
||||
func TestExecutorRetrySuccessResultWithIsErrorNotRetried(t *testing.T) {
|
||||
var attempts int32
|
||||
term := false
|
||||
tool := execTool{name: "toolerr", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("tool-level error")}, Terminate: &term}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
// afterToolCall marks it as an error result; still must not be retried.
|
||||
isErr := true
|
||||
cfg.AfterToolCall = func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
|
||||
return &agentcore.AfterToolCallResult{IsError: &isErr}
|
||||
}
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "toolerr"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result from afterToolCall override")
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 1 {
|
||||
t.Fatalf("(result,nil) must not be retried: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// This file adds resilience to the single tool-execution seam (see
|
||||
// tool_executor.go): when a tool's Execute returns a non-nil Go error, the
|
||||
// error is classified as transient (worth a bounded retry) or terminal (give
|
||||
// up immediately). This is deliberately separate from and does NOT touch the
|
||||
// transport-layer connect-time retry in internal/provider/transport.go, which
|
||||
// keeps its own, stricter semantics (only 429/503/529, respect Retry-After,
|
||||
// never replay a consumed stream).
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxToolRetries is the default cap on RETRIES (not attempts) for a transient
|
||||
// tool error: 2 retries => at most 3 attempts total. Override per-executor via
|
||||
// ToolExecutorConfig.MaxToolRetries. This is always finite; the retry loop can
|
||||
// never spin forever.
|
||||
const maxToolRetries = 2
|
||||
|
||||
// toolRetryBaseDelay is the unit of the small linear backoff between attempts
|
||||
// (attempt N waits (N+1)*base). Kept intentionally short so retries add
|
||||
// resilience without stalling the agent loop.
|
||||
const toolRetryBaseDelay = 20 * time.Millisecond
|
||||
|
||||
// toolPanic wraps a recovered panic value so the retry loop can (a) tell a
|
||||
// panic apart from an ordinary error to shape the right message and (b) treat
|
||||
// it as terminal (never retryable).
|
||||
type toolPanic struct{ value any }
|
||||
|
||||
func (p toolPanic) Error() string { return "panic" }
|
||||
|
||||
// isRetryableToolError reports whether a non-nil error returned by a tool's
|
||||
// Execute is a TRANSIENT failure worth retrying. Transient means a temporary
|
||||
// IO/network/timeout condition that may succeed on a fresh attempt:
|
||||
//
|
||||
// - syscall.ETIMEDOUT / ECONNRESET / EAGAIN
|
||||
// - a net.Error whose Timeout() or Temporary() is true
|
||||
// - context.DeadlineExceeded (a per-attempt/inner deadline; the caller
|
||||
// separately refuses to retry when the OUTER ctx is already done)
|
||||
// - os.ErrDeadlineExceeded (i/o deadline)
|
||||
// - error text containing "connection refused" / "temporarily unavailable" /
|
||||
// "i/o timeout" / "connection reset"
|
||||
//
|
||||
// Everything else is TERMINAL and must not be retried: argument/validation
|
||||
// errors, file-not-found, and — importantly — context.Canceled, which always
|
||||
// means "stop", never "try again". A recovered panic (toolPanic) is terminal
|
||||
// too.
|
||||
func isRetryableToolError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Cancellation is always terminal, even if some inner cause looks transient.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
// A recovered panic is a programming error, never transient.
|
||||
var tp toolPanic
|
||||
if errors.As(err, &tp) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Deadline / timeout sentinels.
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
// Transient syscall errnos.
|
||||
if errors.Is(err, syscall.ETIMEDOUT) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EAGAIN) {
|
||||
return true
|
||||
}
|
||||
// net.Error temporary/timeout conditions.
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
if netErr.Timeout() || netErr.Temporary() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Best-effort string fallbacks for errors that lost their typed cause.
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, s := range []string{
|
||||
"connection refused",
|
||||
"temporarily unavailable",
|
||||
"i/o timeout",
|
||||
"connection reset",
|
||||
} {
|
||||
if strings.Contains(msg, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolRetryCap resolves the effective retry cap from the config field,
|
||||
// mirroring the sentinel convention used by MaxResultBytes: 0 => default
|
||||
// (maxToolRetries), <0 => disabled (0 retries, i.e. a single attempt).
|
||||
func toolRetryCap(cfgMax int) int {
|
||||
if cfgMax == 0 {
|
||||
return maxToolRetries
|
||||
}
|
||||
if cfgMax < 0 {
|
||||
return 0
|
||||
}
|
||||
return cfgMax
|
||||
}
|
||||
|
||||
// waitToolRetryBackoff sleeps a small, attempt-scaled delay before the next
|
||||
// attempt, but returns early (false) if ctx is cancelled/expired during the
|
||||
// wait so a dead context never costs the full backoff.
|
||||
func waitToolRetryBackoff(ctx context.Context, attempt int) bool {
|
||||
d := time.Duration(attempt+1) * toolRetryBaseDelay
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// This file implements the webfetch tool (US-012, #128): fetch a URL and return
|
||||
// its main text as simplified Markdown. pi has no such tool; this mirrors Claude
|
||||
// Code's WebFetch. Safety properties required by the issue:
|
||||
//
|
||||
// - HTTP URLs are upgraded to HTTPS before the request.
|
||||
// - Cross-origin redirects are NOT followed automatically; the redirect target
|
||||
// is returned to the caller (the model) so it can decide whether to fetch it.
|
||||
// - A request timeout and a response-body size cap bound the work.
|
||||
// - A failed fetch (timeout, non-2xx, unreachable) degrades to a structured
|
||||
// error result, never a panic.
|
||||
//
|
||||
// The optional "prompt" argument is accepted and echoed back in the result
|
||||
// framing so the model keeps its intent alongside the fetched content; the tool
|
||||
// does not itself call a model to summarize (that is the agent loop's job).
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// webFetchTimeout bounds a single fetch. webFetchMaxBytes caps how much of the
|
||||
// response body is read (protects the model's context and memory from huge
|
||||
// pages). webFetchMaxMarkdown caps the rendered Markdown length.
|
||||
const (
|
||||
webFetchTimeout = 30 * time.Second
|
||||
webFetchMaxBytes = 5 * 1024 * 1024
|
||||
webFetchMaxMarkdown = 100 * 1024
|
||||
)
|
||||
|
||||
// WebFetchTool fetches a URL and returns its text as simplified Markdown. The
|
||||
// zero value is usable; Client defaults to a redirect-blocking http.Client with
|
||||
// webFetchTimeout.
|
||||
type WebFetchTool struct {
|
||||
// Client performs the HTTP request. When nil, a default client is built that
|
||||
// blocks cross-origin redirects and enforces webFetchTimeout. Injected for
|
||||
// tests so a fake transport can serve canned responses.
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// webFetchArgs is the decoded argument shape for WebFetchTool.
|
||||
type webFetchArgs struct {
|
||||
// URL is the page to fetch. An http:// URL is upgraded to https://.
|
||||
URL string `json:"url"`
|
||||
// Prompt is an optional instruction describing what the caller wants from the
|
||||
// page; it is echoed into the result framing, not acted on by the tool.
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *WebFetchTool) Name() string { return "webfetch" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *WebFetchTool) Description() string {
|
||||
return "Fetch a URL and return its main text content as simplified Markdown. " +
|
||||
"HTTP URLs are upgraded to HTTPS. Cross-origin redirects are not followed; " +
|
||||
"the redirect target is returned so you can fetch it explicitly. Use the " +
|
||||
"optional prompt to note what you are looking for on the page."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *WebFetchTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch. http:// is upgraded to https://."},
|
||||
"prompt": {"type": "string", "description": "Optional: what to extract or look for on the page."}
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. A fetch has no local side effects and is
|
||||
// safe to run alongside other reads → parallel.
|
||||
func (t *WebFetchTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// errRedirectBlocked is returned by the client's CheckRedirect to stop a
|
||||
// cross-origin redirect; the target is carried so Execute can report it.
|
||||
type errRedirectBlocked struct{ target string }
|
||||
|
||||
func (e *errRedirectBlocked) Error() string { return "cross-origin redirect blocked to " + e.target }
|
||||
|
||||
// newWebFetchClient builds the default redirect-blocking client. A redirect is
|
||||
// allowed only when it stays on the same host (scheme+host); a cross-origin hop
|
||||
// stops with errRedirectBlocked carrying the target URL.
|
||||
func newWebFetchClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: webFetchTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
orig := via[0].URL
|
||||
if req.URL.Host != orig.Host || req.URL.Scheme != orig.Scheme {
|
||||
return &errRedirectBlocked{target: req.URL.String()}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. Fetch failures are encoded as error results (the
|
||||
// returned Go error is always nil), matching the file tools' contract.
|
||||
func (t *WebFetchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[webFetchArgs](args, "webfetch")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
raw := strings.TrimSpace(a.URL)
|
||||
if raw == "" {
|
||||
return errorResult("webfetch: url is required"), nil
|
||||
}
|
||||
|
||||
target, err := normalizeFetchURL(raw)
|
||||
if err != nil {
|
||||
return errorResult("webfetch: " + err.Error()), nil
|
||||
}
|
||||
|
||||
client := t.Client
|
||||
if client == nil {
|
||||
client = newWebFetchClient()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return errorResult("webfetch: " + err.Error()), nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "pigo-webfetch/1.0")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// A blocked cross-origin redirect is reported specially so the model can
|
||||
// choose to fetch the target explicitly. errors.As unwraps the *url.Error
|
||||
// http.Client wraps CheckRedirect failures in.
|
||||
var blocked *errRedirectBlocked
|
||||
if errors.As(err, &blocked) {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
fmt.Sprintf("webfetch: cross-origin redirect not followed.\nTarget: %s\nFetch it explicitly if you want its content.", blocked.target))},
|
||||
Details: map[string]any{"redirect": blocked.target, "followed": false},
|
||||
}, nil
|
||||
}
|
||||
return errorResult(fmt.Sprintf("webfetch: request failed: %v", err)), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return errorResult(fmt.Sprintf("webfetch: %s returned HTTP %d %s", target, resp.StatusCode, http.StatusText(resp.StatusCode))), nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, webFetchMaxBytes))
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("webfetch: reading response body: %v", err)), nil
|
||||
}
|
||||
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
var text string
|
||||
if strings.Contains(ctype, "html") || looksLikeHTML(body) {
|
||||
text = htmlToMarkdown(body)
|
||||
} else {
|
||||
text = string(body)
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
truncated := false
|
||||
if len(text) > webFetchMaxMarkdown {
|
||||
text = text[:webFetchMaxMarkdown]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Fetched %s (HTTP %d)\n", target, resp.StatusCode)
|
||||
if a.Prompt != "" {
|
||||
fmt.Fprintf(&b, "Prompt: %s\n", a.Prompt)
|
||||
}
|
||||
if truncated {
|
||||
b.WriteString("(content truncated)\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(text)
|
||||
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(b.String())},
|
||||
Details: map[string]any{"url": target, "status": resp.StatusCode, "truncated": truncated},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeFetchURL parses raw, upgrades an http scheme to https, and rejects
|
||||
// anything that is not an absolute http(s) URL with a host.
|
||||
func normalizeFetchURL(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid url: %v", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
u.Scheme = "https" // upgrade
|
||||
case "https":
|
||||
// ok
|
||||
case "":
|
||||
return "", fmt.Errorf("url must be absolute with an http(s) scheme: %q", raw)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported url scheme %q (want http or https)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("url has no host: %q", raw)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// looksLikeHTML sniffs whether body begins with an HTML marker, used when the
|
||||
// server omits or mislabels Content-Type.
|
||||
func looksLikeHTML(body []byte) bool {
|
||||
head := strings.ToLower(strings.TrimSpace(string(body[:min(512, len(body))])))
|
||||
return strings.HasPrefix(head, "<!doctype html") || strings.HasPrefix(head, "<html") || strings.Contains(head, "<body")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Tests for the webfetch tool (US-012, #128): URL normalization (http→https),
|
||||
// HTML→Markdown reduction, size/timeout bounds, cross-origin redirect blocking,
|
||||
// and structured errors on failure. A fake RoundTripper serves canned responses
|
||||
// so no network is touched.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// mustParse parses a URL or fails the test.
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// errorAsRedirect unwraps err onto *errRedirectBlocked.
|
||||
func errorAsRedirect(err error, target **errRedirectBlocked) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// makeResp builds a canned *http.Response with the given status/content-type/body.
|
||||
func makeResp(status int, ctype, body string) *http.Response {
|
||||
h := http.Header{}
|
||||
if ctype != "" {
|
||||
h.Set("Content-Type", ctype)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: h,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// execWebFetch runs the tool with a client whose transport is fn.
|
||||
func execWebFetch(t *testing.T, fn roundTripFunc, args string) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
tool := &WebFetchTool{Client: &http.Client{Transport: fn}}
|
||||
res, err := tool.Execute(context.Background(), "c1", json.RawMessage(args), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned Go error: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestWebFetchUpgradesHTTP checks an http:// URL is fetched over https://.
|
||||
func TestWebFetchUpgradesHTTP(t *testing.T) {
|
||||
var gotURL string
|
||||
res := execWebFetch(t, func(r *http.Request) (*http.Response, error) {
|
||||
gotURL = r.URL.String()
|
||||
return makeResp(200, "text/html", "<html><body><p>hi</p></body></html>"), nil
|
||||
}, `{"url":"http://example.com/page"}`)
|
||||
if !strings.HasPrefix(gotURL, "https://") {
|
||||
t.Errorf("request URL = %q, want https upgrade", gotURL)
|
||||
}
|
||||
if txt := agentcore.ContentToText(res.Content); !strings.Contains(txt, "hi") {
|
||||
t.Errorf("result missing body text: %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchHTMLToMarkdown checks basic HTML is reduced to Markdown.
|
||||
func TestWebFetchHTMLToMarkdown(t *testing.T) {
|
||||
body := `<html><body><h1>Title</h1><p>A <a href="https://x.io">link</a> here.</p><script>ignore()</script></body></html>`
|
||||
res := execWebFetch(t, func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(200, "text/html; charset=utf-8", body), nil
|
||||
}, `{"url":"https://example.com"}`)
|
||||
txt := agentcore.ContentToText(res.Content)
|
||||
if !strings.Contains(txt, "# Title") {
|
||||
t.Errorf("missing heading markdown in %q", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "[link](https://x.io)") {
|
||||
t.Errorf("missing link markdown in %q", txt)
|
||||
}
|
||||
if strings.Contains(txt, "ignore()") {
|
||||
t.Errorf("script content leaked into output: %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchNon2xxIsError checks a non-2xx status degrades to a structured
|
||||
// error result (not a panic, not a Go error).
|
||||
func TestWebFetchNon2xxIsError(t *testing.T) {
|
||||
res := execWebFetch(t, func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(404, "text/html", "not found"), nil
|
||||
}, `{"url":"https://example.com/missing"}`)
|
||||
txt := agentcore.ContentToText(res.Content)
|
||||
if !strings.Contains(txt, "HTTP 404") {
|
||||
t.Errorf("expected HTTP 404 error, got %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchPromptEchoed checks the optional prompt is echoed into the framing.
|
||||
func TestWebFetchPromptEchoed(t *testing.T) {
|
||||
res := execWebFetch(t, func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(200, "text/plain", "plain body"), nil
|
||||
}, `{"url":"https://example.com","prompt":"find the price"}`)
|
||||
if txt := agentcore.ContentToText(res.Content); !strings.Contains(txt, "Prompt: find the price") {
|
||||
t.Errorf("prompt not echoed: %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchRejectsBadScheme checks a non-http(s) scheme is rejected up front.
|
||||
func TestWebFetchRejectsBadScheme(t *testing.T) {
|
||||
res := execWebFetch(t, func(r *http.Request) (*http.Response, error) {
|
||||
t.Fatal("transport should not be called for a bad scheme")
|
||||
return nil, nil
|
||||
}, `{"url":"ftp://example.com/file"}`)
|
||||
if txt := agentcore.ContentToText(res.Content); !strings.Contains(txt, "unsupported url scheme") {
|
||||
t.Errorf("expected scheme rejection, got %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchMissingURL checks an empty url is rejected.
|
||||
func TestWebFetchMissingURL(t *testing.T) {
|
||||
tool := &WebFetchTool{}
|
||||
res, err := tool.Execute(context.Background(), "c1", json.RawMessage(`{"url":" "}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute Go error: %v", err)
|
||||
}
|
||||
if txt := agentcore.ContentToText(res.Content); !strings.Contains(txt, "url is required") {
|
||||
t.Errorf("expected url-required error, got %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFetchCrossOriginRedirectBlocked drives the real redirect-blocking
|
||||
// client (newWebFetchClient) via CheckRedirect: a cross-origin redirect must not
|
||||
// be followed, and the target is reported back.
|
||||
func TestWebFetchCrossOriginRedirectBlocked(t *testing.T) {
|
||||
client := newWebFetchClient()
|
||||
// Two hops: same-host allowed, cross-host blocked.
|
||||
same := mustParse(t, "https://a.example.com/1")
|
||||
cross := mustParse(t, "https://b.other.com/2")
|
||||
|
||||
// Same-origin redirect: allowed (nil error).
|
||||
viaSame := []*http.Request{{URL: mustParse(t, "https://a.example.com/0")}}
|
||||
if err := client.CheckRedirect(&http.Request{URL: same}, viaSame); err != nil {
|
||||
t.Errorf("same-origin redirect blocked unexpectedly: %v", err)
|
||||
}
|
||||
|
||||
// Cross-origin redirect: blocked with target carried.
|
||||
viaCross := []*http.Request{{URL: mustParse(t, "https://a.example.com/0")}}
|
||||
err := client.CheckRedirect(&http.Request{URL: cross}, viaCross)
|
||||
var blocked *errRedirectBlocked
|
||||
if err == nil || !errorAsRedirect(err, &blocked) {
|
||||
t.Fatalf("cross-origin redirect not blocked: %v", err)
|
||||
}
|
||||
if blocked.target != "https://b.other.com/2" {
|
||||
t.Errorf("blocked target = %q", blocked.target)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeFetchURL covers the scheme/host rules directly.
|
||||
func TestNormalizeFetchURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"http://x.com/a", "https://x.com/a", false},
|
||||
{"https://x.com", "https://x.com", false},
|
||||
{"x.com/a", "", true}, // no scheme
|
||||
{"ftp://x.com", "", true}, // bad scheme
|
||||
{"https://", "", true}, // no host
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := normalizeFetchURL(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("normalizeFetchURL(%q) = %q, want error", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("normalizeFetchURL(%q) error: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeFetchURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// This file implements the websearch backends: Tavily and Brave (credentialed
|
||||
// JSON APIs) plus a keyless DuckDuckGo HTML fallback. selectSearchBackend picks
|
||||
// the first backend whose credential is present, defaulting to DuckDuckGo.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// searchBackend is one pluggable search provider. name is used in result framing
|
||||
// and error messages; search runs the query and returns up to count normalized
|
||||
// hits.
|
||||
type searchBackend interface {
|
||||
name() string
|
||||
search(ctx context.Context, client *http.Client, query string, count int) ([]searchResult, error)
|
||||
}
|
||||
|
||||
// selectSearchBackend returns the first backend whose credential env var is set,
|
||||
// falling back to the keyless DuckDuckGo backend. The order encodes preference:
|
||||
// LLM-optimized Tavily first, then Brave, then the keyless fallback.
|
||||
func selectSearchBackend(getenv func(string) string) searchBackend {
|
||||
if k := strings.TrimSpace(getenv("TAVILY_API_KEY")); k != "" {
|
||||
return tavilyBackend{apiKey: k}
|
||||
}
|
||||
if k := strings.TrimSpace(getenv("BRAVE_API_KEY")); k != "" {
|
||||
return braveBackend{apiKey: k}
|
||||
}
|
||||
return duckDuckGoBackend{}
|
||||
}
|
||||
|
||||
// searchBodyLimit caps how much of a backend response body is read.
|
||||
const searchBodyLimit = 4 * 1024 * 1024
|
||||
|
||||
// --- Tavily ---------------------------------------------------------------
|
||||
|
||||
type tavilyBackend struct{ apiKey string }
|
||||
|
||||
func (b tavilyBackend) name() string { return "tavily" }
|
||||
|
||||
func (b tavilyBackend) search(ctx context.Context, client *http.Client, query string, count int) ([]searchResult, error) {
|
||||
reqBody, _ := json.Marshal(map[string]any{"query": query, "max_results": count})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.tavily.com/search", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+b.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, searchBodyLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
out := make([]searchResult, 0, len(decoded.Results))
|
||||
for _, r := range decoded.Results {
|
||||
out = append(out, searchResult{Title: r.Title, URL: r.URL, Snippet: r.Content})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- Brave ----------------------------------------------------------------
|
||||
|
||||
type braveBackend struct{ apiKey string }
|
||||
|
||||
func (b braveBackend) name() string { return "brave" }
|
||||
|
||||
func (b braveBackend) search(ctx context.Context, client *http.Client, query string, count int) ([]searchResult, error) {
|
||||
u := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", url.QueryEscape(query), count)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Subscription-Token", b.apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, searchBodyLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Web struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
} `json:"results"`
|
||||
} `json:"web"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
out := make([]searchResult, 0, len(decoded.Web.Results))
|
||||
for _, r := range decoded.Web.Results {
|
||||
out = append(out, searchResult{Title: stripHTMLTags(r.Title), URL: r.URL, Snippet: stripHTMLTags(r.Description)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- DuckDuckGo (keyless fallback) ----------------------------------------
|
||||
|
||||
type duckDuckGoBackend struct{}
|
||||
|
||||
func (duckDuckGoBackend) name() string { return "duckduckgo" }
|
||||
|
||||
func (duckDuckGoBackend) search(ctx context.Context, client *http.Client, query string, count int) ([]searchResult, error) {
|
||||
u := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A browser-like User-Agent avoids the endpoint serving an empty/blocked page.
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; pigo-websearch/1.0)")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, searchBodyLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
results, err := parseDuckDuckGoHTML(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) > count {
|
||||
results = results[:count]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// parseDuckDuckGoHTML extracts result links and snippets from the DuckDuckGo
|
||||
// HTML endpoint. Title/URL come from <a class="result__a">; the URL is wrapped in
|
||||
// a redirect carrying the real target in the uddg query param, which is decoded.
|
||||
// Snippets come from elements with class "result__snippet", matched to results by
|
||||
// position. A result with no snippet is still returned (snippet empty).
|
||||
func parseDuckDuckGoHTML(body []byte) ([]searchResult, error) {
|
||||
doc, err := html.Parse(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing html: %w", err)
|
||||
}
|
||||
var results []searchResult
|
||||
var snippets []string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "a" && hasClass(n, "result__a") {
|
||||
href := attr(n, "href")
|
||||
results = append(results, searchResult{Title: nodeText(n), URL: unwrapDDGURL(href)})
|
||||
}
|
||||
if n.Type == html.ElementNode && hasClass(n, "result__snippet") {
|
||||
snippets = append(snippets, nodeText(n))
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
for i := range results {
|
||||
if i < len(snippets) {
|
||||
results[i].Snippet = snippets[i]
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// unwrapDDGURL turns a DuckDuckGo redirect href ("//duckduckgo.com/l/?uddg=...")
|
||||
// into the real target by decoding the uddg param. A non-redirect href is
|
||||
// returned as-is (with a scheme added when protocol-relative).
|
||||
func unwrapDDGURL(href string) string {
|
||||
raw := href
|
||||
if strings.HasPrefix(raw, "//") {
|
||||
raw = "https:" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return href
|
||||
}
|
||||
if target := u.Query().Get("uddg"); target != "" {
|
||||
return target
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// --- HTML helpers ---------------------------------------------------------
|
||||
|
||||
// attr returns the value of the named attribute on n, or "".
|
||||
func attr(n *html.Node, name string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == name {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasClass reports whether n's class attribute contains the given class token.
|
||||
func hasClass(n *html.Node, class string) bool {
|
||||
for _, f := range strings.Fields(attr(n, "class")) {
|
||||
if f == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nodeText returns the concatenated, space-collapsed text content of n.
|
||||
func nodeText(n *html.Node) string {
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(x *html.Node) {
|
||||
if x.Type == html.TextNode {
|
||||
b.WriteString(x.Data)
|
||||
}
|
||||
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// stripHTMLTags removes inline markup (e.g. Brave's <strong> highlights) from a
|
||||
// snippet, leaving space-collapsed text. Malformed fragments are returned as-is.
|
||||
func stripHTMLTags(s string) string {
|
||||
if !strings.ContainsRune(s, '<') {
|
||||
return s
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader(s))
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return nodeText(doc)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// This file implements the websearch tool: run a web search and return the top
|
||||
// results (title, URL, snippet) as Markdown. pi has no such tool; this mirrors
|
||||
// Claude Code's WebSearch. It is provider-agnostic and auto-detects a backend by
|
||||
// available credentials so it works out of the box:
|
||||
//
|
||||
// - Tavily when TAVILY_API_KEY is set (LLM-optimized results).
|
||||
// - Brave when BRAVE_API_KEY is set (independent index).
|
||||
// - DuckDuckGo as a keyless fallback (HTML endpoint, no API key needed).
|
||||
//
|
||||
// The first backend whose credential is present wins; DuckDuckGo is always the
|
||||
// last-resort fallback. Optional allowed/blocked domain filters are applied
|
||||
// uniformly to every backend by post-filtering the result hosts, so behavior is
|
||||
// consistent regardless of which backend served the query.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// webSearchTimeout bounds a single search request. webSearchDefaultCount is used
|
||||
// when the caller omits count; webSearchMaxCount caps it so a run cannot pull an
|
||||
// unbounded result set into the model's context.
|
||||
const (
|
||||
webSearchTimeout = 15 * time.Second
|
||||
webSearchDefaultCount = 5
|
||||
webSearchMaxCount = 10
|
||||
)
|
||||
|
||||
// WebSearchTool runs a web search via the first available backend. The zero
|
||||
// value is usable: Client defaults to an http.Client with webSearchTimeout and
|
||||
// getenv defaults to os.Getenv (both injected in tests).
|
||||
type WebSearchTool struct {
|
||||
// Client performs backend HTTP requests. When nil, a default client bounded by
|
||||
// webSearchTimeout is built. Injected for tests to serve canned responses.
|
||||
Client *http.Client
|
||||
// getenv reads credentials for backend selection. When nil, os.Getenv is used.
|
||||
// Injected for tests so backend selection is deterministic without touching the
|
||||
// process environment.
|
||||
getenv func(string) string
|
||||
}
|
||||
|
||||
// webSearchArgs is the decoded argument shape for WebSearchTool.
|
||||
type webSearchArgs struct {
|
||||
// Query is the search query (required).
|
||||
Query string `json:"query"`
|
||||
// Count is the desired number of results (optional; clamped to webSearchMaxCount).
|
||||
Count int `json:"count,omitempty"`
|
||||
// AllowedDomains, when non-empty, keeps only results whose host matches one of
|
||||
// these domains (suffix match). BlockedDomains drops results whose host matches.
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty"`
|
||||
BlockedDomains []string `json:"blocked_domains,omitempty"`
|
||||
}
|
||||
|
||||
// searchResult is one normalized hit shared across backends.
|
||||
type searchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *WebSearchTool) Name() string { return "websearch" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *WebSearchTool) Description() string {
|
||||
return "Search the web and return the top results (title, URL, snippet). " +
|
||||
"Auto-selects a backend by available credentials (Tavily, Brave, or a " +
|
||||
"keyless DuckDuckGo fallback). Use allowed_domains/blocked_domains to " +
|
||||
"restrict results by host. Follow up with the webfetch tool to read a result."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *WebSearchTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The search query."},
|
||||
"count": {"type": "integer", "description": "Desired number of results (default 5, max 10).", "minimum": 1, "maximum": 10},
|
||||
"allowed_domains": {"type": "array", "items": {"type": "string"}, "description": "Only include results from these domains (suffix match)."},
|
||||
"blocked_domains": {"type": "array", "items": {"type": "string"}, "description": "Exclude results from these domains (suffix match)."}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. A search has no local side effects and is
|
||||
// safe to run alongside other reads → parallel.
|
||||
func (t *WebSearchTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. Backend failures are encoded as error results
|
||||
// (the returned Go error is always nil), matching the file tools' contract.
|
||||
func (t *WebSearchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[webSearchArgs](args, "websearch")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
query := strings.TrimSpace(a.Query)
|
||||
if query == "" {
|
||||
return errorResult("websearch: query is required"), nil
|
||||
}
|
||||
|
||||
count := a.Count
|
||||
if count <= 0 {
|
||||
count = webSearchDefaultCount
|
||||
}
|
||||
if count > webSearchMaxCount {
|
||||
count = webSearchMaxCount
|
||||
}
|
||||
|
||||
client := t.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: webSearchTimeout}
|
||||
}
|
||||
getenv := t.getenv
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
|
||||
backend := selectSearchBackend(getenv)
|
||||
// A domain-filtered query can discard most raw hits, so over-fetch before
|
||||
// filtering to still land near the requested count.
|
||||
fetchCount := count
|
||||
if len(a.AllowedDomains) > 0 || len(a.BlockedDomains) > 0 {
|
||||
fetchCount = min(webSearchMaxCount, count*3)
|
||||
}
|
||||
|
||||
results, err := backend.search(ctx, client, query, fetchCount)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("websearch: %s backend failed: %v", backend.name(), err)), nil
|
||||
}
|
||||
results = filterByDomain(results, a.AllowedDomains, a.BlockedDomains)
|
||||
if len(results) > count {
|
||||
results = results[:count]
|
||||
}
|
||||
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(renderSearchResults(query, backend.name(), results))},
|
||||
Details: map[string]any{"backend": backend.name(), "query": query, "count": len(results)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// renderSearchResults formats the hits as a numbered Markdown list, noting which
|
||||
// backend served the query so the model knows the source.
|
||||
func renderSearchResults(query, backend string, results []searchResult) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Search results for %q (via %s):\n", query, backend)
|
||||
if len(results) == 0 {
|
||||
b.WriteString("\n(no results)")
|
||||
return b.String()
|
||||
}
|
||||
for i, r := range results {
|
||||
fmt.Fprintf(&b, "\n%d. %s\n %s\n", i+1, strings.TrimSpace(r.Title), strings.TrimSpace(r.URL))
|
||||
if s := strings.TrimSpace(r.Snippet); s != "" {
|
||||
fmt.Fprintf(&b, " %s\n", s)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// filterByDomain keeps only results whose host suffix-matches an allowed domain
|
||||
// (when allowed is non-empty) and drops any whose host suffix-matches a blocked
|
||||
// domain. An unparseable URL is dropped only under an allow-list.
|
||||
func filterByDomain(results []searchResult, allowed, blocked []string) []searchResult {
|
||||
if len(allowed) == 0 && len(blocked) == 0 {
|
||||
return results
|
||||
}
|
||||
out := results[:0:0]
|
||||
for _, r := range results {
|
||||
host := hostOf(r.URL)
|
||||
if len(allowed) > 0 && !matchesAnyDomain(host, allowed) {
|
||||
continue
|
||||
}
|
||||
if matchesAnyDomain(host, blocked) {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hostOf extracts the lowercased host from a result URL, or "" if unparseable.
|
||||
func hostOf(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
// matchesAnyDomain reports whether host equals or is a subdomain of any domain.
|
||||
func matchesAnyDomain(host string, domains []string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if host == d || strings.HasSuffix(host, "."+d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Tests for the websearch tool: backend auto-selection by credential, per-backend
|
||||
// response parsing (Tavily JSON, Brave JSON with HTML highlights, DuckDuckGo HTML
|
||||
// with redirect-wrapped URLs), domain filtering, count clamping, and structured
|
||||
// errors. A fake RoundTripper serves canned responses so no network is touched.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// execWebSearch runs the tool with a fake transport and a fixed environment.
|
||||
func execWebSearch(t *testing.T, env map[string]string, fn roundTripFunc, args string) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
tool := &WebSearchTool{
|
||||
Client: &http.Client{Transport: fn},
|
||||
getenv: func(k string) string { return env[k] },
|
||||
}
|
||||
res, err := tool.Execute(context.Background(), "c1", json.RawMessage(args), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned Go error: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// resultText is defined in read_tool_test.go (same package).
|
||||
|
||||
func TestSelectSearchBackend(t *testing.T) {
|
||||
cases := []struct {
|
||||
env map[string]string
|
||||
want string
|
||||
}{
|
||||
{map[string]string{"TAVILY_API_KEY": "t"}, "tavily"},
|
||||
{map[string]string{"BRAVE_API_KEY": "b"}, "brave"},
|
||||
{map[string]string{"TAVILY_API_KEY": "t", "BRAVE_API_KEY": "b"}, "tavily"},
|
||||
{map[string]string{}, "duckduckgo"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selectSearchBackend(func(k string) string { return c.env[k] }).name()
|
||||
if got != c.want {
|
||||
t.Errorf("env %v: backend = %q, want %q", c.env, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchTavily(t *testing.T) {
|
||||
body := `{"results":[{"title":"Go","url":"https://go.dev","content":"The Go language"},{"title":"Docs","url":"https://pkg.go.dev","content":"packages"}]}`
|
||||
var gotAuth string
|
||||
res := execWebSearch(t, map[string]string{"TAVILY_API_KEY": "secret"},
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host != "api.tavily.com" {
|
||||
t.Errorf("unexpected host %q", r.URL.Host)
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
return makeResp(200, "application/json", body), nil
|
||||
}, `{"query":"go language"}`)
|
||||
|
||||
txt := resultText(res)
|
||||
if gotAuth != "Bearer secret" {
|
||||
t.Errorf("Authorization = %q, want Bearer secret", gotAuth)
|
||||
}
|
||||
if !strings.Contains(txt, "via tavily") || !strings.Contains(txt, "https://go.dev") || !strings.Contains(txt, "The Go language") {
|
||||
t.Errorf("unexpected result:\n%s", txt)
|
||||
}
|
||||
if bk, _ := res.Details.(map[string]any)["backend"].(string); bk != "tavily" {
|
||||
t.Errorf("Details.backend = %q, want tavily", bk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchBraveStripsHTML(t *testing.T) {
|
||||
body := `{"web":{"results":[{"title":"Rust <strong>lang</strong>","url":"https://rust-lang.org","description":"A <strong>systems</strong> language"}]}}`
|
||||
var gotToken string
|
||||
res := execWebSearch(t, map[string]string{"BRAVE_API_KEY": "tok"},
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host != "api.search.brave.com" {
|
||||
t.Errorf("unexpected host %q", r.URL.Host)
|
||||
}
|
||||
gotToken = r.Header.Get("X-Subscription-Token")
|
||||
return makeResp(200, "application/json", body), nil
|
||||
}, `{"query":"rust"}`)
|
||||
|
||||
txt := resultText(res)
|
||||
if gotToken != "tok" {
|
||||
t.Errorf("X-Subscription-Token = %q, want tok", gotToken)
|
||||
}
|
||||
if strings.Contains(txt, "<strong>") {
|
||||
t.Errorf("HTML tags not stripped:\n%s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "Rust lang") || !strings.Contains(txt, "A systems language") {
|
||||
t.Errorf("unexpected result:\n%s", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDuckDuckGo(t *testing.T) {
|
||||
html := `<div class="result">
|
||||
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x">First Title</a>
|
||||
<a class="result__snippet">First snippet</a>
|
||||
</div>
|
||||
<div class="result">
|
||||
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fb">Second Title</a>
|
||||
<a class="result__snippet">Second snippet</a>
|
||||
</div>`
|
||||
res := execWebSearch(t, map[string]string{},
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host != "html.duckduckgo.com" {
|
||||
t.Errorf("unexpected host %q", r.URL.Host)
|
||||
}
|
||||
return makeResp(200, "text/html", html), nil
|
||||
}, `{"query":"anything"}`)
|
||||
|
||||
txt := resultText(res)
|
||||
if !strings.Contains(txt, "https://example.com/a") || !strings.Contains(txt, "https://example.org/b") {
|
||||
t.Errorf("redirect URLs not decoded:\n%s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "First Title") || !strings.Contains(txt, "Second snippet") {
|
||||
t.Errorf("titles/snippets missing:\n%s", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainFilter(t *testing.T) {
|
||||
body := `{"results":[{"title":"A","url":"https://keep.com/x","content":"a"},{"title":"B","url":"https://drop.com/y","content":"b"}]}`
|
||||
res := execWebSearch(t, map[string]string{"TAVILY_API_KEY": "k"},
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(200, "application/json", body), nil
|
||||
}, `{"query":"q","allowed_domains":["keep.com"]}`)
|
||||
|
||||
txt := resultText(res)
|
||||
if strings.Contains(txt, "drop.com") {
|
||||
t.Errorf("blocked domain leaked:\n%s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "keep.com") {
|
||||
t.Errorf("allowed domain dropped:\n%s", txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchEmptyQuery(t *testing.T) {
|
||||
res := execWebSearch(t, map[string]string{},
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
t.Error("transport should not be called for empty query")
|
||||
return makeResp(200, "text/html", ""), nil
|
||||
}, `{"query":" "}`)
|
||||
if !strings.Contains(resultText(res), "query is required") {
|
||||
t.Errorf("want query-required error, got:\n%s", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchBackendError(t *testing.T) {
|
||||
res := execWebSearch(t, map[string]string{"TAVILY_API_KEY": "k"},
|
||||
func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(500, "application/json", "boom"), nil
|
||||
}, `{"query":"q"}`)
|
||||
if !strings.Contains(resultText(res), "tavily backend failed") {
|
||||
t.Errorf("want backend-failed error, got:\n%s", resultText(res))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// This file implements the write tool (US-016): create or overwrite a file at a
|
||||
// given path, creating parent directories as needed. Overwrites are reported so
|
||||
// the caller/model knows an existing file was replaced (parity with pi's write
|
||||
// behavior). Paths resolve against a Root and are rejected if they escape it.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// WriteTool writes text files under Root, creating parent directories as needed.
|
||||
type WriteTool struct {
|
||||
// Root bounds all writes; a path resolving outside Root is rejected. Empty
|
||||
// Root defaults to the current working directory.
|
||||
Root string
|
||||
// ExtraRoots are additional trusted directories a write may target even though
|
||||
// they lie outside Root. It exists for the skills directory so the model can
|
||||
// author or update skills (create a new SKILL.md, edit an existing one) that
|
||||
// live outside the workspace.
|
||||
ExtraRoots []string
|
||||
// Snap, when non-nil, records the file's prior content before it is written so
|
||||
// the /rewind command can roll the change back. It is shared with the edit tool.
|
||||
Snap *FileSnapshotRecorder
|
||||
}
|
||||
|
||||
// writeToolArgs is the decoded argument shape for WriteTool.
|
||||
type writeToolArgs struct {
|
||||
// Path is the file to write, relative to Root (or absolute within Root).
|
||||
Path string `json:"path"`
|
||||
// Content is the full file contents to write (overwrites any existing file).
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *WriteTool) Name() string { return "write" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *WriteTool) Description() string {
|
||||
return "Create or overwrite a file at the given path, creating parent " +
|
||||
"directories as needed. Overwriting an existing file is reported."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *WriteTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to write, relative to the workspace root."},
|
||||
"content": {"type": "string", "description": "Full file contents to write."}
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Writes mutate the filesystem → sequential
|
||||
// so a batch does not race concurrent writes to the same tree.
|
||||
func (t *WriteTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
|
||||
// resolveWithin boundary policy, so every file tool enforces the same
|
||||
// workspace-escape guard while writes can also reach trusted extra roots.
|
||||
func (t *WriteTool) resolvePath(p string) (string, error) {
|
||||
if len(t.ExtraRoots) == 0 {
|
||||
return resolveWithin(t.Root, p)
|
||||
}
|
||||
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. Write failures are encoded as error results;
|
||||
// the returned Go error is reserved for nothing here (argument decode also
|
||||
// degrades to a result), matching the read tool's contract.
|
||||
func (t *WriteTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[writeToolArgs](args, "write")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Path == "" {
|
||||
return errorResult("write: path is required"), nil
|
||||
}
|
||||
full, err := t.resolvePath(a.Path)
|
||||
if err != nil {
|
||||
return errorResult("write: " + err.Error()), nil
|
||||
}
|
||||
|
||||
// Detect overwrite before writing so the result can report it. A path that
|
||||
// points at a directory is an error, not an overwrite.
|
||||
overwrote := false
|
||||
if info, statErr := os.Stat(full); statErr == nil {
|
||||
if info.IsDir() {
|
||||
return errorResult(fmt.Sprintf("write: %q is a directory, not a file", a.Path)), nil
|
||||
}
|
||||
overwrote = true
|
||||
}
|
||||
|
||||
// Create parent directories as needed.
|
||||
if dir := filepath.Dir(full); dir != "" {
|
||||
if err := os.MkdirAll(dir, dirPerm); err != nil {
|
||||
return errorResult(fmt.Sprintf("write: cannot create parent directories for %q: %v", a.Path, err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the prior state before mutating so /rewind can restore it.
|
||||
t.Snap.Record(full)
|
||||
if err := os.WriteFile(full, []byte(a.Content), filePerm); err != nil {
|
||||
return errorResult(fmt.Sprintf("write: cannot write %q: %v", a.Path, err)), nil
|
||||
}
|
||||
verb := "Created"
|
||||
if overwrote {
|
||||
verb = "Overwrote"
|
||||
}
|
||||
msg := fmt.Sprintf("%s %s (%d bytes)", verb, a.Path, len(a.Content))
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||||
Details: map[string]any{"path": a.Path, "bytes": len(a.Content), "overwrote": overwrote},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runWrite(t *testing.T, tool *WriteTool, args map[string]any) agentcore.AgentToolResult {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("execute returned go error: %v", gerr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestWriteToolCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tool := &WriteTool{Root: dir}
|
||||
res := runWrite(t, tool, map[string]any{"path": "out.txt", "content": "hello"})
|
||||
if !strings.Contains(resultText(res), "Created") {
|
||||
t.Errorf("expected Created, got %q", resultText(res))
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "out.txt"))
|
||||
if err != nil || string(got) != "hello" {
|
||||
t.Errorf("file content = %q, err = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolCreatesParentDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tool := &WriteTool{Root: dir}
|
||||
res := runWrite(t, tool, map[string]any{"path": "a/b/c/deep.txt", "content": "x"})
|
||||
if strings.Contains(resultText(res), "error") {
|
||||
t.Errorf("unexpected error: %q", resultText(res))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "a", "b", "c", "deep.txt")); err != nil {
|
||||
t.Errorf("nested file not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "exists.txt")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
tool := &WriteTool{Root: dir}
|
||||
res := runWrite(t, tool, map[string]any{"path": "exists.txt", "content": "new"})
|
||||
if !strings.Contains(resultText(res), "Overwrote") {
|
||||
t.Errorf("expected Overwrote, got %q", resultText(res))
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "new" {
|
||||
t.Errorf("content = %q, want new", got)
|
||||
}
|
||||
// Details should flag the overwrite.
|
||||
details, ok := res.Details.(map[string]any)
|
||||
if !ok || details["overwrote"] != true {
|
||||
t.Errorf("details missing overwrote flag: %+v", res.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tool := &WriteTool{Root: dir}
|
||||
res := runWrite(t, tool, map[string]any{"path": "../escape.txt", "content": "x"})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Errorf("expected boundary error, got %q", resultText(res))
|
||||
}
|
||||
// The escape file must not exist.
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "escape.txt")); err == nil {
|
||||
t.Fatal("path traversal wrote outside the root!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolExtraRootsAllowsSkillAuthoring(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
skills := t.TempDir()
|
||||
|
||||
// Without ExtraRoots, authoring a skill outside the workspace is rejected.
|
||||
target := filepath.Join(skills, "newskill", "SKILL.md")
|
||||
bounded := &WriteTool{Root: work}
|
||||
res := runWrite(t, bounded, map[string]any{"path": target, "content": "x"})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
t.Fatal("write escaped the workspace without ExtraRoots!")
|
||||
}
|
||||
|
||||
// With the skills dir as an extra root, the new skill file (and its parent
|
||||
// dirs) is created.
|
||||
tool := &WriteTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res = runWrite(t, tool, map[string]any{"path": target, "content": "skill body"})
|
||||
if strings.Contains(resultText(res), "error") {
|
||||
t.Fatalf("unexpected error authoring skill: %q", resultText(res))
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil || string(got) != "skill body" {
|
||||
t.Fatalf("skill file content = %q, err = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolExtraRootsStillBlocksUntrustedPath(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
skills := t.TempDir()
|
||||
other := t.TempDir()
|
||||
target := filepath.Join(other, "escape.txt")
|
||||
|
||||
tool := &WriteTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res := runWrite(t, tool, map[string]any{"path": target, "content": "x"})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Errorf("expected boundary error for untrusted path, got %q", resultText(res))
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
t.Fatal("write escaped both roots!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolDirectoryTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "adir")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
tool := &WriteTool{Root: dir}
|
||||
res := runWrite(t, tool, map[string]any{"path": "adir", "content": "x"})
|
||||
if !strings.Contains(resultText(res), "is a directory") {
|
||||
t.Errorf("expected directory error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolMissingArgs(t *testing.T) {
|
||||
tool := &WriteTool{Root: t.TempDir()}
|
||||
res := runWrite(t, tool, map[string]any{"content": "x"})
|
||||
if !strings.Contains(resultText(res), "path is required") {
|
||||
t.Errorf("expected path-required error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolMode(t *testing.T) {
|
||||
tool := &WriteTool{}
|
||||
if tool.Name() != "write" {
|
||||
t.Errorf("name = %q", tool.Name())
|
||||
}
|
||||
if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
|
||||
t.Error("write should be sequential")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Errorf("schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user