341 lines
13 KiB
Go
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")
|
|
}
|