// This file implements the blackboard tool (US-coop): a shared-file-system // coordination primitive for the pigo coop runner (see coop/). A single pigo // agent works on the task in $BB: its workspace lives at $BB/workspace, and it // creates the DONE marker through this tool. The blackboard ($BB) holds // task.md, the workspace/, and a DONE marker. // // Raw file tools cannot safely create the DONE marker: the agent's file tools // are rooted at its own workspace, and a plain write could race the supervisor. // So the blackboard is a dedicated tool with three atomic operations: // // blackboard action=read [path=...] global snapshot, or one file's contents // blackboard action=post file=... content=... atomically append a message // blackboard action=done summary=... atomically create the DONE marker // // Append and marker creation are atomic at the OS level (O_APPEND single-write // for messages, O_CREATE|O_EXCL for DONE), so writes never interleave or // clobber each other. Every path is validated against the blackboard root to // forbid traversal. package agenttool import ( "context" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/smallnest/pigo/internal/agentcore" ) // maxBlackboardMessageBytes caps a single post. The cap serves two purposes: // keeps each append a single atomic write, and keeps the tool result from // ballooning the context. const maxBlackboardMessageBytes = 32 * 1024 // maxBlackboardReadBytes caps how much of one file blackboard read returns. const maxBlackboardReadBytes = 32 * 1024 // BlackboardTool is the task blackboard for the pigo coop runner. It is // wired into the tool set only when the BB environment variable points at a // blackboard root (see run.SetupEnv), so ordinary pigo runs never see it. type BlackboardTool struct { // Root is the blackboard root directory (the value of $BB). Must be set. Root string } // Name implements AgentTool. func (t *BlackboardTool) Name() string { return "blackboard" } // Description implements AgentTool. func (t *BlackboardTool) Description() string { return "Read and write the task blackboard used by the pigo coop runner (see " + "coop/). This is the ONLY tool that may touch shared blackboard files " + "atomically. Actions: read (no path: global snapshot of task.md, workspace " + "listing, DONE state; with path like \"workspace/exploit.py\": contents of " + "that one file); post (atomically append a progress note; file must be a " + "bare .md name under messages/, e.g. \"round--.md\"); done " + "(atomically create the DONE marker with a final delivery summary, only when " + "the deliverable is truly complete; fails if DONE already exists). The " + "blackboard root, current round and your name are available in the " + "environment as BB, ROUND, NAME." } // Schema implements AgentTool. func (t *BlackboardTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "action": {"type": "string", "enum": ["read", "post", "done"], "description": "read | post | done"}, "path": {"type": "string", "description": "For read: a path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot."}, "file": {"type": "string", "description": "For post: bare file name under messages/, must end in .md (e.g. round-1-a.md)."}, "content": {"type": "string", "description": "For post: the message body (max 32 KiB)."}, "summary": {"type": "string", "description": "For done: final delivery summary written into DONE."} }, "required": ["action"], "additionalProperties": false }`) } // ExecutionMode implements AgentTool. It mutates shared files → sequential. func (t *BlackboardTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionSequential } type blackboardArgs struct { Action string `json:"action"` Path string `json:"path"` File string `json:"file"` Content string `json:"content"` Summary string `json:"summary"` } // Execute implements AgentTool. func (t *BlackboardTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[blackboardArgs](args, "blackboard") if bad != nil { return *bad, nil } if t.Root == "" { return errorResult("blackboard: no blackboard root configured"), nil } switch a.Action { case "read": return t.read(a) case "post": return t.post(a) case "done": return t.done(a) default: return errorResult(fmt.Sprintf("blackboard: unknown action %q (want read|post|done)", a.Action)), nil } } // read returns either one file's contents (when a.Path is set) or a global // snapshot of the blackboard: task.md, message list, both workspace listings, // and the DONE state. func (t *BlackboardTool) read(a blackboardArgs) (agentcore.AgentToolResult, error) { if strings.TrimSpace(a.Path) != "" { return t.readFile(a.Path) } var b strings.Builder if data, err := os.ReadFile(filepath.Join(t.Root, "task.md")); err == nil { b.WriteString("# task.md\n") b.WriteString(truncateToBudget(string(data), maxBlackboardReadBytes)) b.WriteString("\n") } else { b.WriteString("# task.md\n\n") } b.WriteString("\n# messages/ (" + strings.Join(readDirNames(t.Root, "messages"), ", ") + ")\n") for _, name := range readDirListing(t.Root, "messages") { b.WriteString(" - " + name + "\n") } b.WriteString("\n# workspace/ (your workspace)\n") for _, name := range readDirListing(t.Root, "workspace") { b.WriteString(" - " + name + "\n") } done := "" if data, err := os.ReadFile(filepath.Join(t.Root, "DONE")); err == nil { done = truncateToBudget(string(data), 4096) } b.WriteString("\n# DONE\n") if done == "" { b.WriteString("\n") } else { b.WriteString("EXISTS:\n" + done + "\n") } if env := blackboardEnvSummary(); env != "" { b.WriteString("\n# environment\n" + env) } return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(b.String())}}, nil } // readFile returns the contents of one file under the blackboard root, capped at // maxBlackboardReadBytes. Paths are validated (no traversal) and must point // inside the root. func (t *BlackboardTool) readFile(p string) (agentcore.AgentToolResult, error) { full, err := t.safePath(p) if err != nil { return errorResult("blackboard read: " + err.Error()), nil } info, err := os.Stat(full) if err != nil { return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil } if info.IsDir() { return errorResult(fmt.Sprintf("blackboard read: %s is a directory; only files can be read", p)), nil } data, err := os.ReadFile(full) if err != nil { return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil } return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent("# " + p + "\n" + truncateToBudget(string(data), maxBlackboardReadBytes))}, }, nil } // post atomically appends a message to $BB/messages/. The file name must // be a bare *.md name (no separators) so a message can never escape the // messages directory. Appending is a single O_APPEND write → atomic under // concurrent agents. func (t *BlackboardTool) post(a blackboardArgs) (agentcore.AgentToolResult, error) { name := strings.TrimSpace(a.File) if !validMessageName(name) { return errorResult("blackboard post: file must be a bare name ending in .md (e.g. \"round-1-a.md\"), no path separators, no \"..\""), nil } content := strings.TrimSpace(a.Content) if content == "" { return errorResult("blackboard post: content must not be empty"), nil } if len(content) > maxBlackboardMessageBytes { return errorResult(fmt.Sprintf("blackboard post: content too large (%d bytes, max %d)", len(content), maxBlackboardMessageBytes)), nil } dir := filepath.Join(t.Root, "messages") if err := os.MkdirAll(dir, 0o755); err != nil { return errorResult("blackboard post: " + err.Error()), nil } // O_APPEND + a single Write is atomic on POSIX: concurrent posts never // interleave bytes. The newline separates this message from the previous one. f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return errorResult("blackboard post: " + err.Error()), nil } _, werr := f.WriteString(content) cerr := f.Close() if werr != nil { return errorResult("blackboard post: write: " + werr.Error()), nil } if cerr != nil { return errorResult("blackboard post: close: " + cerr.Error()), nil } return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("Message appended to messages/%s", name))}, }, nil } // done atomically creates the DONE marker with a delivery summary. O_CREATE|O_EXCL // guarantees exactly one agent can create it; a second attempt reports the // existing marker rather than overwriting it. func (t *BlackboardTool) done(a blackboardArgs) (agentcore.AgentToolResult, error) { summary := strings.TrimSpace(a.Summary) if summary == "" { return errorResult("blackboard done: summary must not be empty (include the final delivery summary)"), nil } header := "Blackboard cooperation DONE\n" header += "created: " + time.Now().UTC().Format(time.RFC3339) + "\n\n" content := header + summary target := filepath.Join(t.Root, "DONE") f, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { if os.IsExist(err) { existing, _ := os.ReadFile(target) return errorResult("blackboard done: DONE already exists — cooperation already finished:\n" + truncateToBudget(string(existing), 4096)), nil } return errorResult("blackboard done: " + err.Error()), nil } if _, werr := f.WriteString(content); werr != nil { f.Close() return errorResult("blackboard done: write: " + werr.Error()), nil } if cerr := f.Close(); cerr != nil { return errorResult("blackboard done: close: " + cerr.Error()), nil } return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent("DONE marker created. Cooperation finished.")}, Terminate: terminatePtr(), }, nil } // safePath resolves p against the blackboard root and rejects anything that // escapes it (.., absolute paths, symlinks are not followed beyond validation of // the lexical path). func (t *BlackboardTool) safePath(p string) (string, error) { if strings.TrimSpace(p) == "" { return "", fmt.Errorf("empty path") } clean := filepath.Clean(p) if filepath.IsAbs(clean) { return "", fmt.Errorf("path %q must be relative to the blackboard root", p) } rootClean := filepath.Clean(t.Root) full := filepath.Join(rootClean, clean) if full != rootClean && !strings.HasPrefix(full, rootClean+string(filepath.Separator)) { return "", fmt.Errorf("path %q escapes the blackboard root", p) } return full, nil } // validMessageName checks a post file name: a bare *.md name with no directory // components and no "." or ".." tricks. func validMessageName(name string) bool { if name == "" || !strings.HasSuffix(name, ".md") { return false } if strings.ContainsAny(name, `/\`) || name == "." || name == ".." { return false } base := strings.TrimSuffix(name, ".md") if base == "" || strings.HasPrefix(base, ".") || strings.Contains(base, "..") { return false } return true } // readDirNames lists direct child names of a directory under the root (missing // or unreadable → empty slice). func readDirNames(root, sub string) []string { entries, err := os.ReadDir(filepath.Join(root, sub)) if err != nil { return nil } names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names) return names } // readDirListing lists direct children of a directory under the root with size // and modification time, sorted by name (missing or unreadable → empty slice). func readDirListing(root, sub string) []string { entries, err := os.ReadDir(filepath.Join(root, sub)) if err != nil { return nil } out := make([]string, 0, len(entries)) for _, e := range entries { var size string if info, err := e.Info(); err == nil && !info.IsDir() { size = fmt.Sprintf(" (%d bytes, %s)", info.Size(), info.ModTime().UTC().Format("15:04:05")) } else if err == nil { size = " (dir)" } out = append(out, e.Name()+size) } sort.Strings(out) return out } // blackboardEnvSummary renders the BB/ROUND/NAME environment values so the model // can address messages and understand the round. Returns "" when none are set. func blackboardEnvSummary() string { var b strings.Builder for _, k := range []string{"BB", "ROUND", "NAME"} { if v := strings.TrimSpace(os.Getenv(k)); v != "" { fmt.Fprintf(&b, " %s=%s\n", k, v) } } return strings.TrimRight(b.String(), "\n") }