// Package run holds the run-assembly layer (US-005, #362): the shared setup that // both the interactive REPL and the headless driver need — resolving the // provider, building the tool set rooted at the working directory, discovering // skills and plugins, and constructing the loop RunConfig. Pulling it out of // cmd/pigo lets the subpackages assemble a run through one exported API instead // of duplicating the wiring. package run import ( "fmt" "io" "os" "path/filepath" "strings" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/builtinskills" "github.com/smallnest/pigo/internal/hooks" "github.com/smallnest/pigo/internal/memory" "github.com/smallnest/pigo/internal/plugin" "github.com/smallnest/pigo/internal/provider" "github.com/smallnest/pigo/internal/runtime" "github.com/smallnest/pigo/internal/trust" ) // Env is the environment every run shares: the working directory, the tool set // rooted at it, the resolved provider, and the system prompt. It is assembled // once (SetupEnv) and consumed by whichever driver runs. type Env struct { Cwd string Tools []agentcore.AgentTool Provider provider.Provider ProviderName string SysPrompt string // Skills is the discovered skill set (loaded once here, empty under // --no-skills). It is threaded into the REPL so each skill is registered as a // /skill-name command, and the model-invocable subset is already injected into // SysPrompt. Skills []*runtime.Skill // Plugins holds any loaded external plugins so the caller can Close them when // the run ends. It is nil when no plugins were discovered. Plugins *plugin.Manager // Memory is the persistent memory store opened once for the run (issue #481), // or nil when persistent memory is disabled (memory.enabled=false), tools are // disabled (--no-tools), or the store could not be opened (a non-fatal // failure). When non-nil the caller MUST Close it when the run ends. The store // is also handed to the memory_search tool (in Tools) and, through it, the // per-turn memory reminder provider, so this field exists mainly so the owner // can close the DB — downstream wiring reaches the store via the tool. Memory *memory.Store } // SetupEnv resolves the provider for model/baseURL, builds the tool set rooted // at the working directory, and constructs the system prompt — the setup the // REPL and headless drivers both need. systemPrompt, when non-empty, replaces // the default base instruction (mirrors pi's --system-prompt); appendSystemPrompt // entries are each resolved (a path to an existing file is read, otherwise the // value is literal text) and layered onto the end of the prompt (mirrors pi's // --append-system-prompt). apiKey is the resolved credential (CLI --api-key or // config.toml) used as the override for sub-agent credential resolution so // dispatched task children authenticate the same way the parent does. policy is // the --allowed-tools/--disallowed-tools boundary; it is validated against the // fully assembled tool set and then applied, so an unknown tool name is a usage // error rather than a silently ineffective boundary. It returns an error rather // than exiting so the caller owns exit-code mapping. func SetupEnv(model, baseURL, protocol, providerName, apiKey string, noTools, noSkills bool, systemPrompt string, appendSystemPrompt []string, memEnabled bool, policy ToolPolicy) (Env, error) { cwd, _ := os.Getwd() prov, resolvedName, err := provider.ResolveProvider(model, baseURL, protocol, providerName, os.Getenv) if err != nil { return Env{}, err } appends, err := resolveAppendInstructions(appendSystemPrompt) if err != nil { return Env{}, err } tools := BuiltinTools(cwd, noTools) // Open the persistent memory store once (issue #481) and expose it as the // memory_search tool so the agent can recall earlier context. Memory is a // tool, so it is skipped under --no-tools; memory.enabled=false disables it // too. Opening is non-fatal: a failure logs and leaves memory off, matching // the "fall back to file-based auto-memory" contract. var memStore *memory.Store if !noTools { if store, err := OpenMemoryStore(memEnabled); err != nil { fmt.Fprintf(os.Stderr, "pigo: memory disabled: %v\n", err) } else if store != nil { memStore = store tools = append(tools, &agenttool.MemorySearchTool{Store: store}) } } // Wire the generic task tool (US-002, #454) unless tools are disabled. It // dispatches general-purpose sub-agents that reuse the resolved provider // stream/model. Each spawn gets a fresh child RunConfig whose registry is the // builtins with "task" removed (the nesting guard, so a child cannot fan out // again), and all task calls in a run share one semaphore capping concurrency. if !noTools { sem := runtime.NewSubagentSemaphore() // The child resolves credentials the same way the parent does: env/OAuth via // a fresh store, plus the CLI/config api key as an override. Without the // override a child would get an empty key whenever auth comes from config.toml // or --api-key (not an env var), leaving every sub-agent unauthenticated. childCreds := provider.NewCredentialStore(nil) childCreds.SetOverride(resolvedName, apiKey) factory := func() runtime.RunConfig { childTools := ChildToolSet(cwd, policy) return runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: model, Provider: resolvedName, Stream: provider.StreamFnFromProvider(prov), GetAPIKey: childCreds.GetAPIKey, }, Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: ToolRegistry(childTools)}}, } } tools = append(tools, runtime.NewTaskTool(factory, sem)) } // Wire the blackboard tool (coop/): present only when the BB environment // variable names a blackboard root. It is the atomic shared-file primitive // of the pigo coop runner (task.md / workspace / DONE); without BB it is // absent so ordinary runs are unaffected. Like memory, it is a tool, so // --no-tools disables it. if !noTools { if bb := strings.TrimSpace(os.Getenv("BB")); bb != "" { tools = append(tools, &agenttool.BlackboardTool{Root: bb}) } } // Discover external plugins (US-016) and append their tools. Plugin loading // is fault-tolerant: a plugin that fails to start is logged and skipped, and // disabling tools (--no-tools) skips plugin discovery entirely. var mgr *plugin.Manager if !noTools { if m, err := plugin.Discover(PluginsDir(), os.Stderr, os.Stderr); err == nil { tools = append(tools, m.Tools()...) mgr = m } else { fmt.Fprintf(os.Stderr, "pigo: plugin discovery failed: %v\n", err) } } // Enforce the --allowed-tools/--disallowed-tools boundary now that the set is // complete. Validation must happen here rather than at flag-parse time: plugin // and memory tool names only exist at runtime, so an earlier check would reject // legitimate names. Filtering here — at the registration layer, before the // BeforeToolCall confirmation gate — is what makes the boundary structural: a // removed tool is never advertised and never dispatchable, so --approve cannot // widen it. if err := ValidateToolPolicy(tools, policy); err != nil { return Env{}, err } tools = ApplyToolPolicy(tools, policy) if len(tools) == 0 && !noTools && !policy.IsZero() { fmt.Fprintln(os.Stderr, "pigo: warning: the tool policy removed every tool; the model will run without tools") } // --no-tools already disables everything, so a tool policy alongside it has // no effect — and because the set is empty, ValidateToolPolicy above skipped // name validation, meaning a typo here would otherwise pass unnoticed. Say so // rather than letting the user believe a boundary is in force. if noTools && !policy.IsZero() { fmt.Fprintln(os.Stderr, "pigo: warning: --no-tools disables all tools; --allowed-tools/--disallowed-tools are ignored (and unvalidated)") } // Load skills once (shared between prompt injection and /skill-name // registration). A partial parse error still yields the skills that DID load, // so one malformed file is a non-fatal warning rather than a hard failure. skills, err := LoadSkills(noSkills) if err != nil { fmt.Fprintf(os.Stderr, "pigo: skills: %v\n", err) } // The model can only load a skill's body when the read tool is present, so // advertise skills in the prompt only then (mirrors pi's selectedTools check). sysPrompt, err := runtime.BuildSystemPrompt(runtime.PromptConfig{ BaseInstruction: systemPrompt, WorkingDir: cwd, Root: cwd, AppendInstructions: appends, Skills: skills, ReadToolAvailable: hasReadTool(tools), }) if err != nil { return Env{}, err } return Env{ Cwd: cwd, Tools: tools, Provider: prov, ProviderName: resolvedName, SysPrompt: sysPrompt, Skills: skills, Plugins: mgr, Memory: memStore, }, nil } // hasReadTool reports whether the read tool is present in the tool set. Skills // are advertised in the system prompt only when it is, since the model needs the // read tool to load a skill's body on demand. func hasReadTool(tools []agentcore.AgentTool) bool { for _, t := range tools { if t.Name() == "read" { return true } } return false } // resolveAppendInstructions maps each --append-system-prompt value to the text // to append. Following pi, a value that names an existing regular file is read // and its contents are appended; any other value (a non-existent path, or a // directory) is treated as literal text. Only a value that stats as a regular // file but then fails to read (e.g. a permission error) is reported, so a // genuinely broken file path is not silently appended verbatim. func resolveAppendInstructions(values []string) ([]string, error) { if len(values) == 0 { return nil, nil } out := make([]string, 0, len(values)) for _, v := range values { info, statErr := os.Stat(v) if statErr == nil && !info.IsDir() { data, err := os.ReadFile(v) if err != nil { return nil, fmt.Errorf("read --append-system-prompt file %q: %w", v, err) } out = append(out, string(data)) continue } out = append(out, v) } return out, nil } // BuiltinTools returns the default file/shell tool set rooted at cwd, or nil // when tools are disabled. The todo tool is stateful: a single TodoStore is // created here and held by the one TodoTool instance, so the task list persists // across calls within a run (a later write replaces the plan). func BuiltinTools(cwd string, disabled bool) []agentcore.AgentTool { if disabled { return nil } // A single recorder is shared by the write and edit tools so /rewind can roll // back every mutation from a turn regardless of which tool made it. snap := agenttool.NewFileSnapshotRecorder() // A single job store is shared by bash, bash_output and kill_bash so a // background command launched by bash is visible to the drain/kill tools. jobs := agenttool.NewBashJobStore() return []agentcore.AgentTool{ &agenttool.ReadTool{Root: cwd, ExtraRoots: ReadableExtraRoots()}, &agenttool.WriteTool{Root: cwd, ExtraRoots: ReadableExtraRoots(), Snap: snap}, &agenttool.EditTool{Root: cwd, ExtraRoots: ReadableExtraRoots(), Snap: snap}, &agenttool.GrepTool{Root: cwd}, &agenttool.FindTool{Root: cwd}, &agenttool.BashTool{Dir: cwd, Jobs: jobs}, &agenttool.BashOutputTool{Jobs: jobs}, &agenttool.BashKillTool{Jobs: jobs}, &agenttool.TodoTool{Store: agenttool.NewTodoStore()}, &agenttool.WebFetchTool{}, &agenttool.WebSearchTool{}, } } // BuiltinToolsExcept returns the default builtin tool set (BuiltinTools) with // any tool whose name matches one of the except names removed. It backs the // nesting guard for the generic task tool: a child sub-agent's registry is built // with "task" excluded so a child can never spawn further sub-agents, capping // delegation depth at one. With no except names it is equivalent to BuiltinTools. func BuiltinToolsExcept(cwd string, disabled bool, except ...string) []agentcore.AgentTool { all := BuiltinTools(cwd, disabled) if len(except) == 0 || len(all) == 0 { return all } skip := make(map[string]struct{}, len(except)) for _, n := range except { skip[n] = struct{}{} } out := make([]agentcore.AgentTool, 0, len(all)) for _, t := range all { if _, ok := skip[t.Name()]; ok { continue } out = append(out, t) } return out } // ReadableExtraRoots returns trusted directories the file tools may reach beyond // the workspace root. The skills directory is included so the model can load the // absolute SKILL.md paths pigo advertises in the system prompt, and author or // update skills there (they otherwise resolve outside the workspace and are // rejected). An empty skills dir is dropped, so this stays a no-op when the home // directory cannot be resolved. func ReadableExtraRoots() []string { if dir := SkillsDir(); dir != "" { return []string{dir} } return nil } // ToolRegistry builds a registry from the given tools (skipping any that fail to // register, e.g. a bad schema, which should not happen for built-ins). func ToolRegistry(tools []agentcore.AgentTool) *agenttool.ToolRegistry { reg := agenttool.NewToolRegistry() for _, t := range tools { _ = reg.Register(t) } return reg } // TodoReminders builds the per-turn system-reminder registry for a tool set // (US-002): it locates the stateful TodoTool and registers a TodoReminderProvider // over its shared store, so the model is reminded of unfinished tasks each turn. // It also registers a MemoryReminderProvider over the memory_search tool's store // (issue #481) when present, so relevant persisted memory is recalled each turn // (this is the recall channel used after auto-compaction/rebuild). Returns nil // when neither provider applies (e.g. --no-tools), leaving injection disabled. func TodoReminders(tools []agentcore.AgentTool) *runtime.ReminderRegistry { var providers []runtime.ReminderProvider for _, t := range tools { switch tool := t.(type) { case *agenttool.TodoTool: if tool.Store != nil { providers = append(providers, &runtime.TodoReminderProvider{Store: tool.Store}) } case *agenttool.MemorySearchTool: if tool.Store != nil { providers = append(providers, &runtime.MemoryReminderProvider{Store: tool.Store}) } } } if len(providers) == 0 { return nil } return runtime.NewReminderRegistry(providers...) } // MemoryRootFromTools returns the persistent memory root the run's memory_search // tool is backed by (its Store.Root()), or "" when persistent memory is not wired // into this tool set (memory.enabled=false, --no-tools, or the store failed to // open). It is the canonical source of the memory root for checkpoint persistence // and context rebuild (/sessions//checkpoint.md): callers resolve the // root through the opened store rather than re-deriving it from the session store. func MemoryRootFromTools(tools []agentcore.AgentTool) string { for _, t := range tools { if mt, ok := t.(*agenttool.MemorySearchTool); ok && mt.Store != nil { return mt.Store.Root() } } return "" } // MemoryStoreFromTools returns the persistent memory Store backing the run's // memory_search tool, or nil when persistent memory is not wired into this tool // set. It lets status commands (/memory) inspect the live store without // re-opening the database. func MemoryStoreFromTools(tools []agentcore.AgentTool) *memory.Store { for _, t := range tools { if mt, ok := t.(*agenttool.MemorySearchTool); ok && mt.Store != nil { return mt.Store } } return nil } // SnapshotRecorderFromTools returns the shared FileSnapshotRecorder backing the // run's write/edit tools, or nil when file tools are disabled (--no-tools). The // REPL uses it to commit a per-turn restore point and to serve /rewind. func SnapshotRecorderFromTools(tools []agentcore.AgentTool) *agenttool.FileSnapshotRecorder { for _, t := range tools { switch tool := t.(type) { case *agenttool.WriteTool: if tool.Snap != nil { return tool.Snap } case *agenttool.EditTool: if tool.Snap != nil { return tool.Snap } } } return nil } // BashJobStoreFromTools returns the shared BashJobStore backing the run's bash / // bash_output / kill_bash tools, or nil when the shell tool is disabled. The // REPL uses it to kill any still-running background jobs on exit so they are not // orphaned. func BashJobStoreFromTools(tools []agentcore.AgentTool) *agenttool.BashJobStore { for _, t := range tools { if bt, ok := t.(*agenttool.BashTool); ok && bt.Jobs != nil { return bt.Jobs } } return nil } // MemoryDir returns the persistent memory root directory: $PIGO_HOME/memory, or // ~/.pigo/memory by default (a single global store so cross-project "global" // memories are searchable, mirroring the session store's ~/.pigo base). It // returns "" when the home directory cannot be resolved and no override is set. func MemoryDir() string { dir := os.Getenv("PIGO_HOME") if dir == "" { home, err := os.UserHomeDir() if err != nil { return "" } dir = filepath.Join(home, ".pigo") } return filepath.Join(dir, "memory") } // OpenMemoryStore opens the persistent memory store under MemoryDir() (index DB // at /index.db). It returns (nil, nil) — not an error — when persistent // memory is disabled (memEnabled=false) or the home dir is unresolvable, so the // caller degrades to file-based auto-memory without treating the off state as a // failure. A genuine open failure is returned as an error for the caller to log // non-fatally. func OpenMemoryStore(memEnabled bool) (*memory.Store, error) { if !memEnabled { return nil, nil } root := MemoryDir() if root == "" { return nil, nil } dbPath := filepath.Join(root, "index.db") return memory.Open(dbPath, root, "") } // SkillsDir returns the directory skills are loaded from. It defaults to // ~/.agents/skills, overridable via PIGO_SKILLS_DIR. An empty string is returned // when the home directory cannot be resolved and no override is set. func SkillsDir() string { if dir := os.Getenv("PIGO_SKILLS_DIR"); dir != "" { return dir } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".agents", "skills") } // LoadSkills discovers skills from SkillsDir() once, for both prompt injection // and /skill-name registration. Under --no-skills it is a no-op. Built-in skills // are bootstrapped into the skills dir first, then the directory is loaded. func LoadSkills(noSkills bool) ([]*runtime.Skill, error) { if noSkills { return nil, nil } var blog io.Writer if os.Getenv("PIGO_DEBUG") != "" { blog = os.Stderr } builtinskills.Bootstrap(ConfigDir(), SkillsDir(), blog) dir := SkillsDir() if dir == "" { return nil, nil } return runtime.LoadSkillsDir(dir) } // PluginsDir returns the directory external plugins are discovered from: // $PIGO_HOME/plugins, or ~/.pigo/plugins by default. An empty string is returned // when the home directory cannot be resolved and no override is set (Discover // then treats it as "no plugins"). func PluginsDir() string { dir := os.Getenv("PIGO_HOME") if dir == "" { home, err := os.UserHomeDir() if err != nil { return "" } dir = filepath.Join(home, ".pigo") } return filepath.Join(dir, "plugins") } // ConfigDir returns the directory pigo reads its global config layer from: // $PIGO_HOME, or ~/.pigo by default. An empty string is returned when the home // directory cannot be resolved and no override is set (the caller then treats // the global layer as absent). func ConfigDir() string { dir := os.Getenv("PIGO_HOME") if dir == "" { home, err := os.UserHomeDir() if err != nil { return "" } dir = filepath.Join(home, ".pigo") } return dir } // ResolveThinkingLevel resolves the effective reasoning-effort level through the // layered config chain (US-023): default < global < project < env < CLI flag. // The global layer is $PIGO_HOME/config.json (or ~/.pigo/config.json); the // project layer is ./.pigo/config.json in the working directory. A malformed // layer file or an invalid resolved value is a hard error, surfaced to the // caller for exit-code mapping. cliLevel is the raw --thinking-level flag ("" = // unset, so lower layers show through). func ResolveThinkingLevel(cliLevel string) (agentcore.ThinkingLevel, error) { def := runtime.DefaultConfigLayer() layers := []*runtime.ConfigLayer{&def} if dir := ConfigDir(); dir != "" { global, err := runtime.LoadConfigLayer(filepath.Join(dir, "config.json")) if err != nil { return "", err } layers = append(layers, global) } project, err := runtime.LoadConfigLayer(filepath.Join(".pigo", "config.json")) if err != nil { return "", err } layers = append(layers, project) env := runtime.EnvConfigLayer(os.Getenv) layers = append(layers, &env) if v := strings.TrimSpace(cliLevel); v != "" { cli := runtime.ConfigLayer{ThinkingLevel: &v} layers = append(layers, &cli) } cfg, err := runtime.ResolveConfig(layers...) if err != nil { return "", err } return cfg.ThinkingLevel, nil } // ResolveHookSet resolves the effective hook set through the same layered config // chain as ResolveThinkingLevel (default < global < project < env), with one // difference required by FR-14: the project layer (./.pigo/config.json under // cwd) is only merged when the directory is trusted. An untrusted directory // therefore contributes no hooks, so a checked-out repo cannot run arbitrary // commands until the user trusts it. A malformed layer file is a hard error, // surfaced to the caller. The returned set is empty (len 0) when no layer // defines hooks, which InstallHooks treats as "no hooks" (FR-18). func ResolveHookSet(cwd string, trusted bool) (hooks.HookSet, error) { def := runtime.DefaultConfigLayer() layers := []*runtime.ConfigLayer{&def} if dir := ConfigDir(); dir != "" { global, err := runtime.LoadConfigLayer(filepath.Join(dir, "config.json")) if err != nil { return nil, err } layers = append(layers, global) } if trusted { project, err := runtime.LoadConfigLayer(filepath.Join(cwd, ".pigo", "config.json")) if err != nil { return nil, err } layers = append(layers, project) } env := runtime.EnvConfigLayer(os.Getenv) layers = append(layers, &env) cfg, err := runtime.ResolveConfig(layers...) if err != nil { return nil, err } return cfg.Hooks, nil } // Trusted reports whether cwd is a trusted directory per the shared trust store // ($PIGO_HOME/trust.json). It is the trust gate for the non-interactive drivers // (headless / TUI / sub-agent) that have no live trust.Manager to consult, so // ResolveHookSet can honor FR-14 uniformly. A missing or unreadable store is // treated as untrusted (fail closed): a directory only runs project-layer hooks // after the user has explicitly trusted it. func Trusted(cwd string) bool { m, err := trust.NewManager(trust.DefaultPath()) if err != nil || m == nil { return false } return m.IsTrusted(cwd) } // NewConfig builds the loop configuration shared by every driver: the provider // stream, the dynamic API-key resolver, and the tool registry. It is the single // definition of "how a run is wired", so the REPL (streamRun) and the headless // driver cannot drift apart. func NewConfig(model, providerName string, thinking agentcore.ThinkingLevel, prov provider.Provider, creds *provider.CredentialStore, reg *agenttool.ToolRegistry, reminders *runtime.ReminderRegistry) runtime.RunConfig { return runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: model, Provider: providerName, ThinkingLevel: thinking, Stream: provider.StreamFnFromProvider(prov), GetAPIKey: creds.GetAPIKey, }, Batch: agenttool.BatchConfig{ ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}, }, Reminders: reminders, } }