first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user