Files
BlackBean/pigo/internal/agenttool/blackboard_tool.go
T
2026-08-14 23:41:57 +08:00

341 lines
13 KiB
Go

// 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-<ROUND>-<NAME>.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<missing>\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("<not created yet — cooperation is still in progress>\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/<file>. 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")
}