mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-10 11:03:32 +00:00
e2015835b4
- Add reviewer role to team system (backend + UI) for evaluate_loop workflows - Fix handoff/evaluate_loop/delegate_search system prompt entries (were showing as custom tool) - Filter inactive agents from delegation queries (DelegateTargets, SearchDelegateTargets) - Fix agent link direction display (flip outbound→inbound when viewed from target side) - Improve builtin tool seed descriptions with detailed action-oriented text - Add i18n support for builtin tool descriptions (en/vi/zh frontend locale files) - Notify LLM when KG extraction triggered on memory write
186 lines
6.0 KiB
Go
186 lines
6.0 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
|
|
groupWriterCache *store.GroupWriterCache // nil = no group write restriction
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetGroupWriterCache enables group write permission checks.
|
|
func (t *WriteFileTool) SetGroupWriterCache(c *store.GroupWriterCache) {
|
|
t.groupWriterCache = c
|
|
}
|
|
|
|
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"
|
|
}
|
|
func (t *WriteFileTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"path": map[string]any{
|
|
"type": "string",
|
|
"description": "Path to the file to write",
|
|
},
|
|
"content": map[string]any{
|
|
"type": "string",
|
|
"description": "Content to write",
|
|
},
|
|
"deliver": map[string]any{
|
|
"type": "boolean",
|
|
"description": "If true, deliver this file to the user as an attachment (image, document, etc.)",
|
|
},
|
|
},
|
|
"required": []string{"path", "content"},
|
|
}
|
|
}
|
|
|
|
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
path, _ := args["path"].(string)
|
|
content, _ := args["content"].(string)
|
|
deliver, _ := args["deliver"].(bool)
|
|
if path == "" {
|
|
return ErrorResult("path is required")
|
|
}
|
|
|
|
// Group write permission check
|
|
if t.groupWriterCache != nil {
|
|
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); 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); 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.]"
|
|
}
|
|
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
|
|
}
|
|
resolved, err := resolvePath(path, workspace, t.restrict)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
|
|
}
|
|
|
|
if err := os.WriteFile(resolved, []byte(content), 0644); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
result := SilentResult(fmt.Sprintf("File written: %s (%d bytes)", path, len(content)))
|
|
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))
|
|
}
|
|
|
|
result := SilentResult(fmt.Sprintf("File written: %s (%d bytes)", path, len(content)))
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil
|
|
}
|