126 lines
4.6 KiB
Go
126 lines
4.6 KiB
Go
// 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
|
|
}
|