first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user