// This file implements the goal state and the two goal-control tools that power // the /goal command (mirrors pi-goal / Claude Code's goal mode): given a high-level // objective, the agent runs autonomously — re-prompted turn after turn — until // it either declares the goal done (goal_complete), hits a true impasse // (goal_blocked), or a safety guard / token budget stops it. // // The tools live here (rather than in the REPL) because they are ordinary // AgentTools the model invokes, and because the runtime's GoalReminderProvider // needs to read the same state — mirroring how TodoTool/TodoStore pairs with // TodoReminderProvider. GoalState is the shared, concurrency-safe handle both // the tools (which may run in a batch) and the REPL/reminder (which reads it // each turn) touch. package agenttool import ( "context" "encoding/json" "strings" "sync" "time" "github.com/smallnest/pigo/internal/agentcore" ) // GoalStatus is the lifecycle state of the active goal. type GoalStatus string const ( // GoalIdle means no goal is set (the zero value). GoalIdle GoalStatus = "" // GoalActive means the agent is autonomously working toward the goal. GoalActive GoalStatus = "active" // GoalPaused means autonomous continuation stopped (a safety guard fired, or // the user paused it); it can be resumed. GoalPaused GoalStatus = "paused" // GoalBlocked means the agent hit a true impasse (goal_blocked was called). GoalBlocked GoalStatus = "blocked" // GoalComplete means the agent declared the goal done (goal_complete). GoalComplete GoalStatus = "complete" // GoalBudgetLimited means the token budget was exhausted before completion. GoalBudgetLimited GoalStatus = "budget_limited" ) // GoalState holds the current goal for a REPL session. It is safe for concurrent // use so the goal tools (which may run in a batch) and the REPL/reminder reader // can touch it without racing. A single state is shared for a session's // lifetime; /goal clear resets it to the idle zero value. type GoalState struct { mu sync.RWMutex id string objective string summary string // set by goal_complete blockReason string // set by goal_blocked status GoalStatus iterations int // autonomous continuations issued so far noProgress int // consecutive settles with no tool activity tokenBudget int // 0 = unlimited tokensUsed int startedAt time.Time } // NewGoalState returns an empty (idle) goal state. func NewGoalState() *GoalState { return &GoalState{} } // GoalSnapshot is an immutable copy of the goal state for display/decisions. type GoalSnapshot struct { ID string Objective string Summary string BlockReason string Status GoalStatus Iterations int NoProgress int TokenBudget int TokensUsed int StartedAt time.Time } // Start (re)initializes the state for a new objective, moving it to active. It // resets all counters so a fresh goal never inherits a prior goal's tallies. func (s *GoalState) Start(id, objective string, tokenBudget int) { s.mu.Lock() defer s.mu.Unlock() s.id = id s.objective = objective s.summary = "" s.blockReason = "" s.status = GoalActive s.iterations = 0 s.noProgress = 0 s.tokenBudget = tokenBudget s.tokensUsed = 0 s.startedAt = time.Now() } // Snapshot returns a copy of the current state, safe to read without a lock. func (s *GoalState) Snapshot() GoalSnapshot { s.mu.RLock() defer s.mu.RUnlock() return GoalSnapshot{ ID: s.id, Objective: s.objective, Summary: s.summary, BlockReason: s.blockReason, Status: s.status, Iterations: s.iterations, NoProgress: s.noProgress, TokenBudget: s.tokenBudget, TokensUsed: s.tokensUsed, StartedAt: s.startedAt, } } // Clear resets the state to idle (no goal). It zeroes the fields individually // rather than replacing the whole struct so the embedded mutex (currently held) // is preserved — overwriting it while locked would corrupt the lock. func (s *GoalState) Clear() { s.mu.Lock() defer s.mu.Unlock() s.id = "" s.objective = "" s.summary = "" s.blockReason = "" s.status = GoalIdle s.iterations = 0 s.noProgress = 0 s.tokenBudget = 0 s.tokensUsed = 0 s.startedAt = time.Time{} } // SetStatus transitions the goal to a new status (used by the REPL when a safety // guard fires or the user pauses/resumes). func (s *GoalState) SetStatus(status GoalStatus) { s.mu.Lock() defer s.mu.Unlock() s.status = status } // Resume reactivates a paused or budget-limited goal and clears the transient // safety-guard counters (iterations, no-progress) that stopped it, so the run // gets a fresh allowance rather than immediately re-tripping the same guard. The // token budget is intentionally reset too (tokensUsed → 0): resuming past an // exhausted budget is an explicit user decision to grant another window. The // objective and id are preserved. It is a no-op-safe wrapper — callers gate on // the current status before invoking it. func (s *GoalState) Resume() { s.mu.Lock() defer s.mu.Unlock() s.status = GoalActive s.iterations = 0 s.noProgress = 0 s.tokensUsed = 0 } // ID returns the current goal id (empty when idle). func (s *GoalState) ID() string { s.mu.RLock() defer s.mu.RUnlock() return s.id } // RecordIteration increments the autonomous-continuation counter and folds the // given output-token delta into the running total. hadToolActivity resets the // no-progress counter when true, else increments it — so a run of tool-free // turns can trip the no-progress guard. func (s *GoalState) RecordIteration(outputTokens int, hadToolActivity bool) { s.mu.Lock() defer s.mu.Unlock() s.iterations++ s.tokensUsed += outputTokens if hadToolActivity { s.noProgress = 0 } else { s.noProgress++ } } // MarkComplete records the completion summary and moves the goal to complete. func (s *GoalState) MarkComplete(summary string) { s.mu.Lock() defer s.mu.Unlock() s.summary = summary s.status = GoalComplete } // MarkBlocked records the block reason and moves the goal to blocked. func (s *GoalState) MarkBlocked(reason string) { s.mu.Lock() defer s.mu.Unlock() s.blockReason = reason s.status = GoalBlocked } // terminate is the shared *bool=true value returned by both goal tools to end // the run immediately (the loop terminates when every result in a batch has // Terminate=true; a goal tool is expected to be the sole call in its turn). func terminatePtr() *bool { b := true; return &b } // contradictorySummary reports whether a goal_complete summary plainly claims the // goal is NOT done — a guard against the model closing a goal it just admitted // is unfinished. The check is a conservative substring match on well-known // negative phrasings (English), matching pi-goal's "plainly contradictory // summary" rejection. func contradictorySummary(summary string) bool { lower := strings.ToLower(summary) for _, bad := range []string{ "not complete", "not done", "incomplete", "tests still fail", "tests fail", "still failing", "could not", "couldn't", "unable to", "unfinished", "did not finish", "failed to", "cannot complete", "still fails", "test failure", } { if strings.Contains(lower, bad) || strings.Contains(summary, bad) { return true } } return false } // GoalCompleteTool lets the model declare the active goal finished. It records // the summary, moves the state to complete, and terminates the run. type GoalCompleteTool struct { // State is the session goal state. Must be non-nil. State *GoalState } type goalCompleteArgs struct { Summary string `json:"summary"` } // Name implements AgentTool. func (t *GoalCompleteTool) Name() string { return "goal_complete" } // Description implements AgentTool. func (t *GoalCompleteTool) Description() string { return "Declare the current goal COMPLETE. Call this ONLY after verifying, " + "requirement by requirement, that the objective is fully met — treat the " + "working tree, tests, and actual runtime behavior as authoritative, not " + "the prior conversation. Provide a concise summary of what was accomplished. " + "Do NOT call this if any requirement is unmet, tests fail, or work remains; " + "use goal_blocked for a true impasse instead. Calling this ends the run." } // Schema implements AgentTool. func (t *GoalCompleteTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "summary": {"type": "string", "description": "Concise summary of what was accomplished to satisfy the goal."} }, "required": ["summary"], "additionalProperties": false }`) } // ExecutionMode implements AgentTool. It mutates shared goal state → sequential. func (t *GoalCompleteTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionSequential } // Execute implements AgentTool. It validates the summary (non-empty and not // plainly contradictory), records completion, and terminates the run. Invalid // input degrades to an error result (not a Go error) so the model can retry. func (t *GoalCompleteTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[goalCompleteArgs](args, "goal_complete") if bad != nil { return *bad, nil } if t.State == nil { return errorResult("goal_complete: no active goal"), nil } summary := strings.TrimSpace(a.Summary) if summary == "" { return errorResult("goal_complete: summary must not be empty"), nil } if contradictorySummary(summary) { return errorResult("goal_complete: summary indicates the goal is NOT complete; " + "keep working, or call goal_blocked with evidence if truly stuck"), nil } snap := t.State.Snapshot() if snap.Status == GoalIdle { return errorResult("goal_complete: no active goal"), nil } t.State.MarkComplete(summary) return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent("Goal marked complete: " + summary)}, Terminate: terminatePtr(), }, nil } // GoalBlockedTool lets the model report a true impasse it cannot work around. It // records the reason, moves the state to blocked, and terminates the run. type GoalBlockedTool struct { // State is the session goal state. Must be non-nil. State *GoalState } type goalBlockedArgs struct { Reason string `json:"reason"` Evidence string `json:"evidence"` } // Name implements AgentTool. func (t *GoalBlockedTool) Name() string { return "goal_blocked" } // Description implements AgentTool. func (t *GoalBlockedTool) Description() string { return "Report that the current goal is BLOCKED by a true impasse you cannot " + "resolve (e.g. missing credentials, an external dependency you cannot " + "install, contradictory requirements). Provide a concrete reason and the " + "evidence that establishes the blocker. Use this only as a last resort — " + "prefer trying a different approach first. Calling this ends the run." } // Schema implements AgentTool. func (t *GoalBlockedTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "reason": {"type": "string", "description": "Concise statement of what blocks the goal."}, "evidence": {"type": "string", "description": "Concrete evidence establishing the blocker (error output, missing file, etc.)."} }, "required": ["reason", "evidence"], "additionalProperties": false }`) } // ExecutionMode implements AgentTool. It mutates shared goal state → sequential. func (t *GoalBlockedTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionSequential } // Execute implements AgentTool. It validates the reason/evidence, records the // block, and terminates the run. func (t *GoalBlockedTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[goalBlockedArgs](args, "goal_blocked") if bad != nil { return *bad, nil } if t.State == nil { return errorResult("goal_blocked: no active goal"), nil } reason := strings.TrimSpace(a.Reason) if reason == "" { return errorResult("goal_blocked: reason must not be empty"), nil } if strings.TrimSpace(a.Evidence) == "" { return errorResult("goal_blocked: evidence must not be empty"), nil } snap := t.State.Snapshot() if snap.Status == GoalIdle { return errorResult("goal_blocked: no active goal"), nil } t.State.MarkBlocked(reason) return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent("Goal marked blocked: " + reason)}, Terminate: terminatePtr(), }, nil }