204 lines
8.1 KiB
Go
204 lines
8.1 KiB
Go
// This file implements system-prompt assembly (US-021, #40), the pigo port of
|
|
// pi's prompt construction. A run's system prompt is built from three layers,
|
|
// in order:
|
|
//
|
|
// 1. a base instruction (who the agent is and how it should behave),
|
|
// 2. an environment block (working directory, OS/arch, current date), and
|
|
// 3. every AGENTS.md found on the path from a root directory down to the
|
|
// working directory, concatenated general-to-specific.
|
|
//
|
|
// The AGENTS.md ordering mirrors zero/pi's monorepo behavior: a repo-root
|
|
// AGENTS.md states broad conventions, and a nested package's AGENTS.md refines
|
|
// them, so the more specific file appears later and takes precedence in the
|
|
// model's reading.
|
|
package runtime
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// agentsFileName is the per-directory instruction file injected into the system
|
|
// prompt, general-to-specific from the root down to the working directory.
|
|
const agentsFileName = "AGENTS.md"
|
|
|
|
// PromptConfig configures system-prompt assembly. The zero value is usable: it
|
|
// produces the base instruction plus an environment block for the process
|
|
// working directory, with no AGENTS.md injection.
|
|
type PromptConfig struct {
|
|
// BaseInstruction is the leading text of the system prompt. When empty,
|
|
// DefaultBaseInstruction is used.
|
|
BaseInstruction string
|
|
// WorkingDir is the directory the run operates in. When empty, the process
|
|
// working directory (os.Getwd) is used. It anchors both the environment
|
|
// block and the lower bound of the AGENTS.md walk.
|
|
WorkingDir string
|
|
// Root bounds the AGENTS.md walk at its top. AGENTS.md files are injected for
|
|
// every directory from Root down to WorkingDir, inclusive. When empty, only
|
|
// WorkingDir's own AGENTS.md (if any) is considered — no ancestor walk.
|
|
Root string
|
|
// AppendInstructions are appended verbatim to the end of the assembled
|
|
// prompt, in order, each preceded by a blank line. This is the sink for
|
|
// --append-system-prompt (mirrors pi): extra guidance layered after the base
|
|
// instruction, environment block, and AGENTS.md. Empty entries are skipped.
|
|
AppendInstructions []string
|
|
// Now supplies the timestamp for the environment block. When nil, time.Now
|
|
// is used. Injected for deterministic tests.
|
|
Now func() time.Time
|
|
// ReadFile reads a file's contents. When nil, os.ReadFile is used. Injected
|
|
// for tests so AGENTS.md layout can be faked without touching disk.
|
|
ReadFile func(path string) ([]byte, error)
|
|
// Skills are the model-invocable skills to advertise in an <available_skills>
|
|
// block at the end of the prompt (mirrors pi's progressive disclosure). Only their
|
|
// name/description/location are injected; the model loads a skill's body with
|
|
// the read tool on demand. Skills flagged disable-model-invocation are
|
|
// filtered out by FormatSkillsForPrompt. Empty means no skills block.
|
|
Skills []*Skill
|
|
// ReadToolAvailable signals whether the read tool is in the current tool set.
|
|
// Skills are advertised only when it is true, since the model needs the read
|
|
// tool to load a skill's body; otherwise the block is omitted entirely.
|
|
ReadToolAvailable bool
|
|
}
|
|
|
|
// DefaultBaseInstruction is the leading system-prompt text used when
|
|
// PromptConfig.BaseInstruction is empty.
|
|
const DefaultBaseInstruction = "You are pigo, a helpful coding agent. " +
|
|
"Use the available tools to inspect files and accomplish the user's request precisely and concisely.\n\n" +
|
|
todoGuide + "\n\n" +
|
|
taskGuide
|
|
|
|
// todoGuide instructs the model on how to drive the todo tool. It is appended to
|
|
// the default base instruction so multi-step work is planned and its progress is
|
|
// made visible to the user (US-011).
|
|
const todoGuide = "When a task has multiple steps or is non-trivial, use the todo tool to plan " +
|
|
"and track your work. Submit the entire task list each call (it replaces the previous " +
|
|
"list); each item has a content string and a status of pending, in_progress, or completed. " +
|
|
"Keep exactly one item in_progress at a time, and mark an item completed as soon as it is " +
|
|
"done before starting the next. Skip the todo tool for trivial single-step requests."
|
|
|
|
// taskGuide instructs the model on how to use the generic `task` tool (US-008,
|
|
// #458). The task tool dispatches an independent sub-agent that runs its own
|
|
// agent loop with a fresh context and returns its final report, so it is the
|
|
// mechanism for delegation and fan-out. The key affordance advertised here is
|
|
// that emitting MULTIPLE task calls in a single assistant message runs those
|
|
// sub-agents in parallel, letting skills like /graph achieve real concurrency.
|
|
const taskGuide = "When work splits into independent subtasks, delegate them with the task tool: each " +
|
|
"task call dispatches an independent sub-agent that completes its subtask on a fresh context and " +
|
|
"returns its final report. To fan out, emit MULTIPLE task calls in a single message — they run in " +
|
|
"parallel. Give each a complete, self-contained prompt, since a sub-agent shares none of this " +
|
|
"conversation's context. Do the work directly for a single, sequential, or trivial task."
|
|
|
|
// BuildSystemPrompt assembles the full system prompt from cfg: base instruction,
|
|
// environment block, then AGENTS.md files ordered general-to-specific from Root
|
|
// down to WorkingDir. Missing AGENTS.md files are skipped silently; only a
|
|
// present-but-unreadable file (a real I/O error other than not-exist) is
|
|
// reported.
|
|
func BuildSystemPrompt(cfg PromptConfig) (string, error) {
|
|
base := cfg.BaseInstruction
|
|
if base == "" {
|
|
base = DefaultBaseInstruction
|
|
}
|
|
wd := cfg.WorkingDir
|
|
if wd == "" {
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
wd = cwd
|
|
}
|
|
}
|
|
now := cfg.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
readFile := cfg.ReadFile
|
|
if readFile == nil {
|
|
readFile = os.ReadFile
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(base)
|
|
|
|
b.WriteString("\n\nEnvironment:\n")
|
|
fmt.Fprintf(&b, "- Working directory: %s\n", wd)
|
|
fmt.Fprintf(&b, "- OS: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
|
fmt.Fprintf(&b, "- Date: %s", now().Format("2006-01-02"))
|
|
|
|
dirs := agentsDirChain(cfg.Root, wd)
|
|
for _, dir := range dirs {
|
|
path := filepath.Join(dir, agentsFileName)
|
|
data, err := readFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return "", fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
content := strings.TrimSpace(string(data))
|
|
if content == "" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "\n\n# Project instructions (%s)\n%s", path, content)
|
|
}
|
|
|
|
// Appended instructions (--append-system-prompt) come last so they layer on
|
|
// top of the base instruction, environment, and AGENTS.md. Each is separated
|
|
// by a blank line; empty entries are skipped.
|
|
for _, extra := range cfg.AppendInstructions {
|
|
extra = strings.TrimSpace(extra)
|
|
if extra == "" {
|
|
continue
|
|
}
|
|
b.WriteString("\n\n")
|
|
b.WriteString(extra)
|
|
}
|
|
|
|
// Advertise model-invocable skills last (progressive disclosure), but only
|
|
// when the read tool is available — the model needs it to load a skill's
|
|
// body. FormatSkillsForPrompt returns "" when no visible skill remains, so
|
|
// this leaves a skill-free prompt byte-for-byte unchanged.
|
|
if cfg.ReadToolAvailable {
|
|
b.WriteString(FormatSkillsForPrompt(cfg.Skills))
|
|
}
|
|
|
|
return b.String(), nil
|
|
}
|
|
|
|
// agentsDirChain returns the directories whose AGENTS.md should be injected, in
|
|
// general-to-specific order (root first, working directory last). When root is
|
|
// empty or is not an ancestor of wd, only wd is returned. When wd is empty, the
|
|
// chain is empty.
|
|
func agentsDirChain(root, wd string) []string {
|
|
if wd == "" {
|
|
return nil
|
|
}
|
|
wd = filepath.Clean(wd)
|
|
if root == "" {
|
|
return []string{wd}
|
|
}
|
|
root = filepath.Clean(root)
|
|
|
|
// Walk up from wd to root, collecting each directory, then reverse so the
|
|
// root comes first (general → specific). If root is never reached, wd is not
|
|
// under root, so fall back to wd alone.
|
|
var up []string
|
|
cur := wd
|
|
for {
|
|
up = append(up, cur)
|
|
if cur == root {
|
|
// Reverse in place: root-first.
|
|
for i, j := 0, len(up)-1; i < j; i, j = i+1, j-1 {
|
|
up[i], up[j] = up[j], up[i]
|
|
}
|
|
return up
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
// Reached filesystem root without hitting `root`: wd not under root.
|
|
return []string{wd}
|
|
}
|
|
cur = parent
|
|
}
|
|
}
|