375 lines
12 KiB
Go
375 lines
12 KiB
Go
// 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
|
|
}
|