// 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 // 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 } }