mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-15 04:20:52 +00:00
* fix(memory): auto-backup + overwrite warning for memory files
When an agent writes to a memory file that already has content:
1. Old content is backed up to memory/.prev/{filename} (best-effort)
2. Write succeeds normally (no blocking)
3. Tool result includes warning with previous content (truncated to
4000 chars) so the agent can notice and merge if needed
Backup files are hidden from list_files and not indexed/embedded.
No database migration needed - uses existing PutDocument.
* fix(memory): support append mode + fix UTF-8 truncation in overwrite warning
- Add appendMode parameter to MemoryInterceptor.WriteFile() so
write_file(append=true) actually appends to memory files instead
of silently replacing content. Uses "\n\n---\n\n" separator.
- Fix UTF-8 truncation in overwrite warning: use []rune instead of
byte slicing to avoid cutting multi-byte characters.
- Fix char count in warning message: use len([]rune()) not len().
- Remove .prev/ backup mechanism (prevBackupPath, isPrevPath, backup
writes, ListFiles filter) in favor of simpler approach: append
mode prevents data loss, warning enables self-correction.
---------
Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
Co-authored-by: viettranx <viettranx@gmail.com>
256 lines
8.7 KiB
Go
256 lines
8.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// WriteFileTool writes content to a file, optionally through a sandbox container.
|
|
type WriteFileTool struct {
|
|
workspace string
|
|
restrict bool
|
|
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
|
|
sandboxMgr sandbox.Manager
|
|
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
|
|
memIntc *MemoryInterceptor // nil = no memory routing
|
|
permStore store.ConfigPermissionStore // nil = no group write restriction
|
|
workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation
|
|
}
|
|
|
|
// DenyPaths adds path prefixes that write_file must reject.
|
|
func (t *WriteFileTool) DenyPaths(prefixes ...string) {
|
|
t.deniedPrefixes = append(t.deniedPrefixes, prefixes...)
|
|
}
|
|
|
|
// SetContextFileInterceptor enables virtual FS routing for context files.
|
|
func (t *WriteFileTool) SetContextFileInterceptor(intc *ContextFileInterceptor) {
|
|
t.contextFileIntc = intc
|
|
}
|
|
|
|
// SetMemoryInterceptor enables virtual FS routing for memory files.
|
|
func (t *WriteFileTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
|
|
t.memIntc = intc
|
|
}
|
|
|
|
// SetConfigPermStore enables group write permission checks.
|
|
func (t *WriteFileTool) SetConfigPermStore(s store.ConfigPermissionStore) {
|
|
t.permStore = s
|
|
}
|
|
|
|
// SetWorkspaceInterceptor enables team workspace validation and event broadcasting.
|
|
func (t *WriteFileTool) SetWorkspaceInterceptor(intc *WorkspaceInterceptor) {
|
|
t.workspaceIntc = intc
|
|
}
|
|
|
|
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
|
|
return &WriteFileTool{workspace: workspace, restrict: restrict}
|
|
}
|
|
|
|
func NewSandboxedWriteFileTool(workspace string, restrict bool, mgr sandbox.Manager) *WriteFileTool {
|
|
return &WriteFileTool{workspace: workspace, restrict: restrict, sandboxMgr: mgr}
|
|
}
|
|
|
|
// SetSandboxKey is a no-op; sandbox key is now read from ctx (thread-safe).
|
|
func (t *WriteFileTool) SetSandboxKey(key string) {}
|
|
|
|
func (t *WriteFileTool) Name() string { return "write_file" }
|
|
func (t *WriteFileTool) Description() string {
|
|
return "Write content to a file, creating directories as needed. " +
|
|
"IMPORTANT: content longer than ~12000 characters may be truncated by the API. " +
|
|
"For large files, use the edit tool to build the file in sections, or split into multiple write_file calls with append=true."
|
|
}
|
|
func (t *WriteFileTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"path": map[string]any{
|
|
"type": "string",
|
|
"description": "File path (relative to workspace, or absolute)",
|
|
},
|
|
"content": map[string]any{
|
|
"type": "string",
|
|
"description": "Content to write",
|
|
},
|
|
"append": map[string]any{
|
|
"type": "boolean",
|
|
"description": "Append content to the file instead of overwriting. Use this to build large files in chunks.",
|
|
},
|
|
"deliver": map[string]any{
|
|
"type": "boolean",
|
|
"description": "Deliver this file to the user as an attachment. Defaults to true. Set to false for intermediate/temporary files (e.g. config, cache, temp scripts).",
|
|
},
|
|
},
|
|
"required": []string{"path", "content"},
|
|
}
|
|
}
|
|
|
|
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
path, _ := args["path"].(string)
|
|
content, _ := args["content"].(string)
|
|
appendMode, _ := args["append"].(bool)
|
|
deliver := true
|
|
if v, ok := args["deliver"].(bool); ok {
|
|
deliver = v
|
|
}
|
|
if path == "" {
|
|
return ErrorResult("path is required")
|
|
}
|
|
|
|
// Group write permission check
|
|
if t.permStore != nil {
|
|
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
}
|
|
|
|
// Virtual FS: route context files to DB
|
|
if t.contextFileIntc != nil {
|
|
if handled, err := t.contextFileIntc.WriteFile(ctx, path, content); handled {
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write context file: %v", err))
|
|
}
|
|
return SilentResult(fmt.Sprintf("Context file written: %s (%d bytes)", path, len(content)))
|
|
}
|
|
}
|
|
|
|
// Virtual FS: route memory files to DB
|
|
if t.memIntc != nil {
|
|
if mwr, err := t.memIntc.WriteFile(ctx, path, content, appendMode); mwr.Handled {
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write memory file: %v", err))
|
|
}
|
|
msg := fmt.Sprintf("Memory file written: %s (%d bytes)", path, len(content))
|
|
if mwr.KGTriggered {
|
|
msg += "\n\n[Knowledge graph extraction triggered in background. The knowledge system may take a moment to fully update with new entities and relationships.]"
|
|
}
|
|
if mwr.PreviousContent != "" {
|
|
prev := mwr.PreviousContent
|
|
prevRunes := []rune(prev)
|
|
if len(prevRunes) > 4000 {
|
|
prev = string(prevRunes[:4000]) + "\n... (truncated)"
|
|
}
|
|
msg += fmt.Sprintf("\n\n⚠️ WARNING: This file had existing content (%d chars) that was replaced. "+
|
|
"If the old content below contains information not present in your new version, "+
|
|
"please re-write the file to merge both.\n\n"+
|
|
"--- PREVIOUS CONTENT ---\n%s\n--- END PREVIOUS CONTENT ---",
|
|
len([]rune(mwr.PreviousContent)), prev)
|
|
}
|
|
return SilentResult(msg)
|
|
}
|
|
}
|
|
|
|
// Sandbox routing (sandboxKey from ctx — thread-safe)
|
|
sandboxKey := ToolSandboxKeyFromCtx(ctx)
|
|
if t.sandboxMgr != nil && sandboxKey != "" {
|
|
return t.executeInSandbox(ctx, path, content, sandboxKey, deliver)
|
|
}
|
|
|
|
// Host execution — use per-user workspace from context if available
|
|
workspace := ToolWorkspaceFromCtx(ctx)
|
|
if workspace == "" {
|
|
workspace = t.workspace
|
|
}
|
|
allowed := allowedWithTeamWorkspace(ctx, nil)
|
|
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Team workspace validation + delete-on-empty.
|
|
if t.workspaceIntc != nil {
|
|
isDelete, intcErr := t.workspaceIntc.HandleWrite(ctx, resolved, content)
|
|
if intcErr != nil {
|
|
return ErrorResult(intcErr.Error())
|
|
}
|
|
if isDelete {
|
|
if err := os.Remove(resolved); err != nil && !os.IsNotExist(err) {
|
|
return ErrorResult(fmt.Sprintf("failed to delete file: %v", err))
|
|
}
|
|
t.workspaceIntc.AfterWrite(ctx, resolved, "delete")
|
|
return SilentResult(fmt.Sprintf("File deleted: %s", path))
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
|
|
}
|
|
|
|
if appendMode {
|
|
f, err := os.OpenFile(resolved, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to open file for append: %v", err))
|
|
}
|
|
_, err = f.WriteString(content)
|
|
f.Close()
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
|
|
}
|
|
} else if err := os.WriteFile(resolved, []byte(content), 0644); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
if t.workspaceIntc != nil {
|
|
t.workspaceIntc.AfterWrite(ctx, resolved, "write")
|
|
}
|
|
|
|
verb := "written"
|
|
if appendMode {
|
|
verb = "appended"
|
|
}
|
|
msg := fmt.Sprintf("File %s: %s (%d bytes)", verb, path, len(content))
|
|
if deliver {
|
|
msg += ". File will be automatically delivered to the user — do NOT send it again via message tool."
|
|
}
|
|
result := SilentResult(msg)
|
|
result.Deliverable = content
|
|
if deliver {
|
|
result.Media = []bus.MediaFile{{Path: resolved}}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, sandboxKey string, deliver bool) *Result {
|
|
bridge, err := t.getFsBridge(ctx, sandboxKey)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
|
|
}
|
|
|
|
if err := bridge.WriteFile(ctx, path, content); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
msg := fmt.Sprintf("File written: %s (%d bytes)", path, len(content))
|
|
if deliver {
|
|
msg += ". File will be automatically delivered to the user — do NOT send it again via message tool."
|
|
}
|
|
result := SilentResult(msg)
|
|
result.Deliverable = content
|
|
if deliver {
|
|
// Sandbox workspace is bind-mounted — resolve to host path for delivery
|
|
workspace := ToolWorkspaceFromCtx(ctx)
|
|
if workspace == "" {
|
|
workspace = t.workspace
|
|
}
|
|
hostPath := filepath.Join(workspace, path)
|
|
result.Media = []bus.MediaFile{{Path: hostPath}}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) {
|
|
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil
|
|
}
|