// This file implements the /goal command (mirrors pi-goal / Claude Code's goal // mode): given a high-level objective, pigo runs the agent autonomously — // re-prompting it turn after turn from the loop's follow-up seam — until the // model declares the goal done (goal_complete), reports a true impasse // (goal_blocked), or a safety guard (max turns / no-progress) or the token // budget stops it. // // /goal is intercepted in the REPL loop rather than routed through a slash // Action closure because it must run agent streams and mutate the shared // context and goal state — none of which a pure string→string Action can do, // exactly like /compact and /fork. It reaches the session's collaborators and // mutable state through the cli.Host contract, so it need not import the // concrete replDeps aggregate that assembles them. // // Scope: core autonomous continuation plus an optional token budget. Goal state // lives only in the session's in-memory GoalState (host.Goal()); it is not // persisted across process restarts. package goal import ( "context" "fmt" "io" "strconv" "strings" "time" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/cli" "github.com/smallnest/pigo/internal/cli/run" "github.com/smallnest/pigo/internal/cli/ui" "github.com/smallnest/pigo/internal/compaction" "github.com/smallnest/pigo/internal/hooks" "github.com/smallnest/pigo/internal/provider" "github.com/smallnest/pigo/internal/runtime" "github.com/smallnest/pigo/internal/trust" ) // goalMaxAutomaticTurns caps how many autonomous continuations a single /goal // run will issue before pausing, a runaway guard (mirrors pi-goal's automaticTurns). const goalMaxAutomaticTurns = 25 // goalMaxNoProgress pauses the run after this many consecutive tool-free // continuations, so a model looping without acting cannot spin forever (mirrors // pi-goal's noProgressTurns). const goalMaxNoProgress = 3 // runGoal parses and dispatches a /goal invocation. setCancel publishes the // active run's cancel func so the REPL's SIGINT handler can interrupt an // autonomous run (same plumbing as a normal turn). func RunGoal(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, line string) { args := strings.TrimSpace(strings.TrimPrefix(line, "/goal")) switch { case args == "": printGoalStatus(out, host.Goal()) return case args == "clear": host.Goal().Clear() fmt.Fprintln(out, "goal cleared") return case args == "pause": snap := host.Goal().Snapshot() if snap.Status != agenttool.GoalActive && snap.Status != agenttool.GoalPaused { fmt.Fprintln(out, "no active goal to pause") return } host.Goal().SetStatus(agenttool.GoalPaused) fmt.Fprintln(out, "goal paused — run /goal resume to continue") return case args == "resume": snap := host.Goal().Snapshot() if snap.Status != agenttool.GoalPaused && snap.Status != agenttool.GoalBudgetLimited { fmt.Fprintln(out, "no paused goal to resume") return } host.Goal().Resume() fmt.Fprintf(out, "resuming goal: %s\n", ui.OneLine(snap.Objective)) runGoalLoop(setCancel, out, host) return } // Otherwise args is a new objective, optionally prefixed with --tokens N. objective, budget, err := parseGoalObjective(args) if err != nil { fmt.Fprintf(out, "pigo: %v\n", err) return } if strings.TrimSpace(objective) == "" { fmt.Fprintln(out, "usage: /goal [--tokens N] ") return } host.Goal().Start(newGoalID(), objective, budget) if budget > 0 { fmt.Fprintf(out, "goal set (token budget %d): %s\n", budget, ui.OneLine(objective)) } else { fmt.Fprintf(out, "goal set: %s\n", ui.OneLine(objective)) } runGoalLoop(setCancel, out, host) } // newGoalID returns a short unique id for a goal (used to key goal_complete's // exact-id contract in a future extension; here it just labels the goal). func newGoalID() string { return "goal-" + strconv.FormatInt(time.Now().UnixNano(), 36) } // parseGoalObjective splits an optional leading "--tokens N" flag from the // objective text. N accepts a bare integer or a k/m suffix (100k, 1m). The flag // must lead; anything after it (or the whole string when absent) is the // objective. func parseGoalObjective(args string) (objective string, budget int, err error) { rest := args if strings.HasPrefix(rest, "--tokens") { rest = strings.TrimSpace(strings.TrimPrefix(rest, "--tokens")) // The value is the next whitespace-delimited token. var valTok string if i := strings.IndexAny(rest, " \t"); i >= 0 { valTok = rest[:i] rest = strings.TrimSpace(rest[i+1:]) } else { valTok = rest rest = "" } budget, err = parseTokenBudget(valTok) if err != nil { return "", 0, err } } return rest, budget, nil } // parseTokenBudget parses a token count with an optional k/m (×1000/×1000000) // suffix, case-insensitive. It rejects a non-positive or malformed value. func parseTokenBudget(s string) (int, error) { s = strings.TrimSpace(strings.ToLower(s)) if s == "" { return 0, fmt.Errorf("--tokens requires a value (e.g. --tokens 100k)") } mult := 1 switch { case strings.HasSuffix(s, "k"): mult = 1000 s = strings.TrimSuffix(s, "k") case strings.HasSuffix(s, "m"): mult = 1000000 s = strings.TrimSuffix(s, "m") } n, convErr := strconv.Atoi(s) if convErr != nil || n <= 0 { return 0, fmt.Errorf("invalid --tokens value %q (want a positive number, optionally with k/m)", s) } return n * mult, nil } // goalContinuationPrompt is the follow-up injected each autonomous turn to keep // the model working toward the goal. The objective itself is re-stated every // turn by the GoalReminderProvider, so this only nudges continuation. const goalContinuationPrompt = "Continue working toward the goal. When every requirement is " + "verifiably met, call goal_complete with a summary. If you hit a true impasse you cannot work " + "around, call goal_blocked with concrete evidence. Otherwise keep going — do not stop or ask " + "the user whether to continue." // goalFollowUpDecision decides, from a goal snapshot, whether the autonomous // loop should issue another continuation turn. It is pure so the branch logic // (complete/blocked terminal states, token budget, turn cap, no-progress guard) // can be unit-tested without running a real agent stream. The returned status is // the terminal status to record when cont is false (GoalIdle means "leave the // status as-is" — used for the already-terminal complete/blocked cases). func goalFollowUpDecision(snap agenttool.GoalSnapshot) (cont bool, terminal agenttool.GoalStatus) { switch snap.Status { case agenttool.GoalComplete, agenttool.GoalBlocked: // A goal tool already ended the run; nothing to continue. return false, agenttool.GoalIdle } if snap.TokenBudget > 0 && snap.TokensUsed >= snap.TokenBudget { return false, agenttool.GoalBudgetLimited } if snap.Iterations >= goalMaxAutomaticTurns { return false, agenttool.GoalPaused } if snap.NoProgress >= goalMaxNoProgress { return false, agenttool.GoalPaused } return true, agenttool.GoalIdle } // runGoalLoop drives the autonomous goal run. It assembles a run just like // streamRun but with the goal tools (goal_complete/goal_blocked) added, the goal // reminder wired alongside the todo reminder, and a GetFollowUpMessages hook that // re-prompts the model each time the inner loop settles — until a goal tool ends // the run or goalFollowUpDecision trips a guard. On return it prints the outcome // and persists the turn. The run reuses the REPL's SIGINT cancel plumbing via // setCancel. func runGoalLoop(setCancel func(context.CancelFunc), out io.Writer, host cli.Host) { goalReg := goalToolRegistry(host.Registry(), host.Goal()) reminders := goalReminders(host.Registry(), host.Goal()) runCtx, cancel := context.WithCancel(context.Background()) setCancel(cancel) defer func() { cancel() setCancel(nil) }() // lastSeen tracks how many messages we have already accounted for, so each // settle folds in only the assistant turns produced since the previous one: // their output tokens (budget) and whether any tool ran (no-progress guard). lastSeen := len(host.AgentCtx().Messages) cfg := runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: host.Live().Model, Provider: host.Live().ProviderName, ThinkingLevel: host.Live().ThinkingLevel, Stream: provider.StreamFnFromProvider(host.Live().Provider), GetAPIKey: host.Creds().GetAPIKey, ContextWindow: host.Live().ContextWindow, Compaction: compaction.DefaultCompactionSettings, }, Batch: agenttool.BatchConfig{ ToolExecutorConfig: agenttool.ToolExecutorConfig{ Registry: goalReg, BeforeToolCall: trust.BeforeToolCall(host.Trust(), host.Cwd(), host.Input(), out, host.ConfirmMu()), }, }, Reminders: reminders, GetFollowUpMessages: func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage { // Account for the turns produced since the last settle. Auto-compaction // can shrink agentCtx.Messages in place (summary + tail) between settles, // dropping its length below lastSeen; clamp so the slice never goes out // of bounds. The compacted turn's tokens are then under-counted, which is // acceptable for a soft budget guard (a crash is not). if lastSeen > len(agentCtx.Messages) { lastSeen = len(agentCtx.Messages) } outputTokens, hadTool := goalTurnActivity(agentCtx.Messages[lastSeen:]) lastSeen = len(agentCtx.Messages) host.Goal().RecordIteration(outputTokens, hadTool) cont, terminal := goalFollowUpDecision(host.Goal().Snapshot()) if !cont { if terminal != agenttool.GoalIdle { host.Goal().SetStatus(terminal) } return nil } return []agentcore.AgentMessage{agentcore.UserMessage{ RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(goalContinuationPrompt)}, }} }, } // Wire the per-turn hook seams (PreToolUse/PostToolUse/Stop) onto this goal // run's cfg from the session dispatcher; a nil dispatcher is a no-op (FR-18). if d := host.Dispatcher(); d != nil { run.InstallSeams(&cfg, d, host.HookDeps()) } // The first turn is driven by the goal reminder alone (the objective is // injected as background context); no explicit user prompt is appended so the // objective is not duplicated in the durable history. stream := runtime.StartRun(runCtx, host.AgentCtx(), cfg) drainGoalStream(runCtx, out, host, stream) printGoalOutcome(out, host.Goal().Snapshot()) cli.PersistTurn(out, host) } // goalToolRegistry returns a registry holding every tool from base plus the two // goal-control tools, so the autonomous run can invoke goal_complete/goal_blocked // while keeping all the normal tools available. The base registry is left // unchanged (the goal tools are only present for the goal run). func goalToolRegistry(base *agenttool.ToolRegistry, state *agenttool.GoalState) *agenttool.ToolRegistry { reg := agenttool.NewToolRegistry() if base != nil { for _, t := range base.List() { _ = reg.Register(t) } } _ = reg.Register(&agenttool.GoalCompleteTool{State: state}) _ = reg.Register(&agenttool.GoalBlockedTool{State: state}) return reg } // goalReminders builds the per-turn reminder registry for a goal run: the goal // reminder (re-stating the objective every turn) plus the todo reminder when a // todo tool is present, so an autonomous run keeps both its objective and its // task list in view. func goalReminders(base *agenttool.ToolRegistry, state *agenttool.GoalState) *runtime.ReminderRegistry { reg := runtime.NewReminderRegistry(&runtime.GoalReminderProvider{State: state}) if base != nil { if t, ok := base.Get("todo"); ok { if tt, ok := t.(*agenttool.TodoTool); ok && tt.Store != nil { reg.Register(&runtime.TodoReminderProvider{Store: tt.Store}) } } } return reg } // goalTurnActivity sums the output tokens across the assistant messages in tail // and reports whether any tool ran in that window (an assistant tool call or a // tool result). It feeds RecordIteration's token-budget and no-progress inputs. func goalTurnActivity(tail []agentcore.AgentMessage) (outputTokens int, hadTool bool) { for _, m := range tail { switch msg := m.(type) { case agentcore.AssistantMessage: if msg.Usage != nil { outputTokens += msg.Usage.OutputTokens } if len(msg.ToolCalls()) > 0 { hadTool = true } case agentcore.ToolResultMessage: hadTool = true } } return outputTokens, hadTool } // drainGoalStream prints the streamed assistant text and tool activity of a goal // run to out, mirroring streamRun's rendering. It blocks until the run ends. // chainGoalEvent returns the OnEvent observer for a goal run: the plugin // notifier, with the SessionEnd/PreCompact hook notifier chained after it when // hooks are configured, mirroring the REPL's OnEvent composition. func chainGoalEvent(host cli.Host) func(agentcore.AgentEvent) { notifier := host.NotifierHandle() d := host.Dispatcher() if d == nil { return notifier } deps := host.HookDeps() hookEvent := hooks.NewHookNotifier(d, deps.SessionID, deps.ProjectDir).Handle if notifier == nil { return hookEvent } return func(ev agentcore.AgentEvent) { notifier(ev) hookEvent(ev) } } func drainGoalStream(ctx context.Context, out io.Writer, host cli.Host, stream *runtime.LoopEventStream) { var reply strings.Builder flushReply := func() { if reply.Len() == 0 { return } rendered := ui.RenderMarkdown(reply.String()) fmt.Fprint(out, rendered) if !strings.HasSuffix(rendered, "\n") { fmt.Fprintln(out) } reply.Reset() } _, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{ OnEvent: chainGoalEvent(host), OnText: func(delta string) { reply.WriteString(delta) }, OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) { flushReply() for _, c := range msg.ToolCalls() { fmt.Fprintf(out, " %s %s\n", ui.Colorize(ui.Enabled(), ui.Green, "→ tool:"), ui.ToolCallLabel(c)) } for _, tr := range results { ui.RenderToolResult(out, tr) } }, }) flushReply() if err != nil { if ctx.Err() != nil { fmt.Fprintln(out, "^C interrupted — goal paused (run /goal resume to continue)") host.Goal().SetStatus(agenttool.GoalPaused) } else { fmt.Fprintf(out, "error: %v\n", err) } } } // printGoalOutcome prints a one-line result banner after a goal run settles, // keyed on the terminal status the run reached. func printGoalOutcome(out io.Writer, snap agenttool.GoalSnapshot) { color := ui.Enabled() switch snap.Status { case agenttool.GoalComplete: fmt.Fprintf(out, "%s goal complete: %s\n", ui.Colorize(color, ui.Green, "✓"), snap.Summary) case agenttool.GoalBlocked: fmt.Fprintf(out, "%s goal blocked: %s\n", ui.Colorize(color, ui.Red, "⚠"), snap.BlockReason) case agenttool.GoalBudgetLimited: fmt.Fprintf(out, "%s goal paused — token budget reached (%d / %d). Run /goal resume to continue.\n", ui.Colorize(color, ui.Yellow, "⏸"), snap.TokensUsed, snap.TokenBudget) case agenttool.GoalPaused: fmt.Fprintf(out, "%s goal paused after %d turns. Run /goal resume to continue, or /goal clear to drop it.\n", ui.Colorize(color, ui.Yellow, "⏸"), snap.Iterations) } } // printGoalStatus prints a summary of the current goal state for a bare /goal. func printGoalStatus(out io.Writer, goal *agenttool.GoalState) { snap := goal.Snapshot() if snap.Status == agenttool.GoalIdle { fmt.Fprintln(out, "no goal set — run /goal to start one") return } fmt.Fprintf(out, "goal: %s\n", snap.Objective) fmt.Fprintf(out, "status: %s\n", snap.Status) fmt.Fprintf(out, "iterations: %d\n", snap.Iterations) if snap.TokenBudget > 0 { fmt.Fprintf(out, "tokens: %d / %d\n", snap.TokensUsed, snap.TokenBudget) } else { fmt.Fprintf(out, "tokens: %d (no budget)\n", snap.TokensUsed) } if !snap.StartedAt.IsZero() { fmt.Fprintf(out, "elapsed: %s\n", time.Since(snap.StartedAt).Round(time.Second)) } if snap.Summary != "" { fmt.Fprintf(out, "summary: %s\n", snap.Summary) } if snap.BlockReason != "" { fmt.Fprintf(out, "blocked: %s\n", snap.BlockReason) } }