first commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user