Changeset 0.21.1.1 (#87)

This commit is contained in:
2026-03-01 16:40:26 +00:00
parent 70aa78e486
commit 09c7281552
20 changed files with 853 additions and 102 deletions

497
server/tools/workspace.go Normal file
View File

@@ -0,0 +1,497 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/workspace"
)
// ── Late Registration ────────────────────────
// Workspace tools need the workspace.FS and store for execution.
// Registered from main.go after workspace FS init.
// RegisterWorkspaceTools registers all workspace tools with captured dependencies.
func RegisterWorkspaceTools(stores store.Stores, wfs *workspace.FS) {
Register(&workspaceLsTool{stores: stores, wfs: wfs})
Register(&workspaceReadTool{stores: stores, wfs: wfs})
Register(&workspaceWriteTool{stores: stores, wfs: wfs})
Register(&workspaceRmTool{stores: stores, wfs: wfs})
Register(&workspaceMvTool{stores: stores, wfs: wfs})
Register(&workspacePatchTool{stores: stores, wfs: wfs})
}
// WorkspaceToolNames returns the names of all workspace tools.
// Used by the completion handler to filter them out when no workspace is bound.
func WorkspaceToolNames() []string {
return []string{
"workspace_ls", "workspace_read", "workspace_write",
"workspace_rm", "workspace_mv", "workspace_patch",
}
}
// ── Shared ─────────────────────────────────
// loadWorkspace resolves the workspace from the execution context.
func loadWorkspace(ctx context.Context, stores store.Stores, execCtx ExecutionContext) (*models.Workspace, error) {
if execCtx.WorkspaceID == "" {
return nil, fmt.Errorf("no workspace bound to this channel")
}
w, err := stores.Workspaces.GetByID(ctx, execCtx.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("workspace not found: %w", err)
}
return w, nil
}
const (
// maxReadBytes is the soft limit for workspace_read (50 KB).
maxReadBytes = 50 * 1024
// maxPatchFileBytes is the limit for files that can be patched (1 MB).
maxPatchFileBytes = 1024 * 1024
)
// ═══════════════════════════════════════════
// workspace_ls
// ═══════════════════════════════════════════
type workspaceLsTool struct {
stores store.Stores
wfs *workspace.FS
}
func (t *workspaceLsTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_ls",
DisplayName: "List Files",
Description: "List files and directories in the workspace. Returns name, type, size, and content type for each entry. Use with path=\"\" or path=\".\" to list root.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"path": Prop("string", "Directory path to list (empty or \".\" for root)"),
"recursive": Prop("boolean", "If true, list all files recursively. Default false."),
}, nil),
}
}
func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Path string `json:"path"`
Recursive bool `json:"recursive"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
entries, err := t.wfs.ListDir(ctx, w, args.Path, args.Recursive)
if err != nil {
return "", fmt.Errorf("list failed: %w", err)
}
type entry struct {
Path string `json:"path"`
IsDir bool `json:"is_directory"`
ContentType string `json:"content_type,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
}
result := make([]entry, 0, len(entries))
for _, e := range entries {
result = append(result, entry{
Path: e.Path,
IsDir: e.IsDirectory,
ContentType: e.ContentType,
SizeBytes: e.SizeBytes,
})
}
b, _ := json.Marshal(map[string]interface{}{
"path": args.Path,
"entries": result,
"count": len(result),
})
return string(b), nil
}
// ═══════════════════════════════════════════
// workspace_read
// ═══════════════════════════════════════════
type workspaceReadTool struct {
stores store.Stores
wfs *workspace.FS
}
func (t *workspaceReadTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_read",
DisplayName: "Read File",
Description: "Read the contents of a file in the workspace. Text files are returned as content; binary files return a summary. Large files are truncated at ~50KB with an indicator.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"path": Prop("string", "File path relative to workspace root"),
}, []string{"path"}),
}
}
func (t *workspaceReadTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
// Stat first for content type and directory check
info, err := t.wfs.Stat(ctx, w, args.Path)
if err != nil {
return "", fmt.Errorf("file not found: %w", err)
}
if info.IsDirectory {
return "", fmt.Errorf("cannot read a directory; use workspace_ls instead")
}
// Binary detection
if !isTextContentType(info.ContentType) {
b, _ := json.Marshal(map[string]interface{}{
"path": args.Path,
"content_type": info.ContentType,
"size_bytes": info.SizeBytes,
"binary": true,
"message": fmt.Sprintf("Binary file (%s), %d bytes", info.ContentType, info.SizeBytes),
})
return string(b), nil
}
// Read content
r, size, err := t.wfs.ReadFile(ctx, w, args.Path)
if err != nil {
return "", fmt.Errorf("read failed: %w", err)
}
defer r.Close()
// Read with truncation guard
buf := make([]byte, maxReadBytes+1)
n, readErr := io.ReadFull(r, buf)
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
return "", fmt.Errorf("read error: %w", readErr)
}
truncated := n > maxReadBytes
if truncated {
n = maxReadBytes
}
resp := map[string]interface{}{
"path": args.Path,
"content": string(buf[:n]),
"content_type": info.ContentType,
"size_bytes": size,
}
if truncated {
resp["truncated"] = true
resp["message"] = fmt.Sprintf("File truncated at %d bytes (total: %d bytes)", maxReadBytes, size)
}
b, _ := json.Marshal(resp)
return string(b), nil
}
// isTextContentType returns true for text/* and known source code types.
func isTextContentType(ct string) bool {
if strings.HasPrefix(ct, "text/") {
return true
}
switch ct {
case "application/json", "application/xml", "application/javascript",
"application/x-yaml", "application/toml", "application/x-sh",
"application/sql", "application/graphql":
return true
}
return false
}
// ═══════════════════════════════════════════
// workspace_write
// ═══════════════════════════════════════════
type workspaceWriteTool struct {
stores store.Stores
wfs *workspace.FS
}
func (t *workspaceWriteTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_write",
DisplayName: "Write File",
Description: "Create or overwrite a file in the workspace. Parent directories are created automatically. Returns the path and size of the written file.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"path": Prop("string", "File path relative to workspace root"),
"content": Prop("string", "File content to write"),
}, []string{"path", "content"}),
}
}
func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(args.Content), int64(len(args.Content))); err != nil {
return "", fmt.Errorf("write failed: %w", err)
}
size := len(args.Content)
log.Printf("📝 workspace_write: %s (%d bytes)", args.Path, size)
b, _ := json.Marshal(map[string]interface{}{
"path": args.Path,
"size_bytes": size,
"message": fmt.Sprintf("Written %d bytes to %s", size, args.Path),
})
return string(b), nil
}
// ═══════════════════════════════════════════
// workspace_rm
// ═══════════════════════════════════════════
type workspaceRmTool struct {
stores store.Stores
wfs *workspace.FS
}
func (t *workspaceRmTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_rm",
DisplayName: "Delete File",
Description: "Delete a file or directory from the workspace. For non-empty directories, set recursive=true. This cannot be undone.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"path": Prop("string", "File or directory path to delete"),
"recursive": Prop("boolean", "Required for non-empty directories. Default false."),
}, []string{"path"}),
}
}
func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Path string `json:"path"`
Recursive bool `json:"recursive"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
if err := t.wfs.DeleteFile(ctx, w, args.Path, args.Recursive); err != nil {
return "", fmt.Errorf("delete failed: %w", err)
}
log.Printf("🗑️ workspace_rm: %s (recursive=%v)", args.Path, args.Recursive)
b, _ := json.Marshal(map[string]interface{}{
"path": args.Path,
"message": fmt.Sprintf("Deleted %s", args.Path),
})
return string(b), nil
}
// ═══════════════════════════════════════════
// workspace_mv
// ═══════════════════════════════════════════
type workspaceMvTool struct {
stores store.Stores
wfs *workspace.FS
}
func (t *workspaceMvTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_mv",
DisplayName: "Move/Rename File",
Description: "Move or rename a file or directory within the workspace. Destination parent directories are created automatically.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"source": Prop("string", "Current file path"),
"destination": Prop("string", "New file path"),
}, []string{"source", "destination"}),
}
}
func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Source string `json:"source"`
Destination string `json:"destination"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Source == "" || args.Destination == "" {
return "", fmt.Errorf("source and destination are required")
}
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
if err := t.wfs.MoveFile(ctx, w, args.Source, args.Destination); err != nil {
return "", fmt.Errorf("move failed: %w", err)
}
log.Printf("📦 workspace_mv: %s → %s", args.Source, args.Destination)
b, _ := json.Marshal(map[string]interface{}{
"source": args.Source,
"destination": args.Destination,
"message": fmt.Sprintf("Moved %s → %s", args.Source, args.Destination),
})
return string(b), nil
}
// ═══════════════════════════════════════════
// workspace_patch
// ═══════════════════════════════════════════
type workspacePatchTool struct {
stores store.Stores
wfs *workspace.FS
}
func (t *workspacePatchTool) Definition() ToolDef {
return ToolDef{
Name: "workspace_patch",
DisplayName: "Patch File",
Description: "Apply find-and-replace operations to a text file. Each operation's 'find' string must match exactly once in the file. Operations are applied sequentially. Use empty 'replace' to delete matched text.",
Category: "workspace",
Parameters: JSONSchema(map[string]interface{}{
"path": Prop("string", "File path to patch"),
"operations": map[string]interface{}{
"type": "array",
"description": "List of find/replace operations to apply sequentially",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"find": Prop("string", "Exact string to find (must match exactly once)"),
"replace": Prop("string", "String to replace with (empty to delete)"),
},
"required": []string{"find", "replace"},
},
},
}, []string{"path", "operations"}),
}
}
func (t *workspacePatchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Path string `json:"path"`
Operations []struct {
Find string `json:"find"`
Replace string `json:"replace"`
} `json:"operations"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
if len(args.Operations) == 0 {
return "", fmt.Errorf("at least one operation is required")
}
w, err := loadWorkspace(ctx, t.stores, execCtx)
if err != nil {
return "", err
}
// Read current content
r, size, err := t.wfs.ReadFile(ctx, w, args.Path)
if err != nil {
return "", fmt.Errorf("read failed: %w", err)
}
defer r.Close()
if size > maxPatchFileBytes {
return "", fmt.Errorf("file too large for patching (%d bytes, max %d)", size, maxPatchFileBytes)
}
data, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("read error: %w", err)
}
content := string(data)
// Apply operations sequentially
applied := make([]string, 0, len(args.Operations))
for i, op := range args.Operations {
if op.Find == "" {
return "", fmt.Errorf("operation %d: find string is empty", i+1)
}
count := strings.Count(content, op.Find)
if count == 0 {
return "", fmt.Errorf("operation %d: find string not found in file", i+1)
}
if count > 1 {
return "", fmt.Errorf("operation %d: find string matches %d times (must match exactly once)", i+1, count)
}
content = strings.Replace(content, op.Find, op.Replace, 1)
applied = append(applied, fmt.Sprintf("op %d: replaced %d chars → %d chars", i+1, len(op.Find), len(op.Replace)))
}
// Write back
if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(content), int64(len(content))); err != nil {
return "", fmt.Errorf("write failed: %w", err)
}
log.Printf("🔧 workspace_patch: %s (%d ops, %d bytes)", args.Path, len(args.Operations), len(content))
b, _ := json.Marshal(map[string]interface{}{
"path": args.Path,
"operations": applied,
"size_bytes": len(content),
"message": fmt.Sprintf("Applied %d patch(es) to %s", len(args.Operations), args.Path),
})
return string(b), nil
}