first commit
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user