first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+425
View File
@@ -0,0 +1,425 @@
// 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] <objective>")
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 <objective> 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)
}
}