Class: Clacky::GoalManager

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/agent/goal_manager.rb

Overview

Drives the /goal Ralph-style loop for a single session.

Responsibilities:

- own the GoalState lifecycle (set / pause / resume / clear)
- after each agent turn, ask a lightweight judge whether the goal is
done, then decide whether to feed the loop another turn
- enforce the turn budget and auto-pause on repeated judge failures

The judge is a single stateless LLM call routed through the same Client the agent already holds (optionally on a cheaper model name). It never touches conversation history — it only sees the goal text and the agent's last response.

Constant Summary collapse

MAX_CONSECUTIVE_JUDGE_FAILURES =

After this many consecutive judge-output parse failures the loop auto-pauses instead of burning the whole turn budget on a judge model that cannot follow the JSON reply contract.

3
JUDGE_MAX_TOKENS =
512
JUDGE_SYSTEM_PROMPT =
<<~PROMPT.freeze
  You are a strict judge deciding whether an autonomous agent has achieved a user's stated goal.
  You receive the goal text and the agent's most recent response.

  Decide one verdict:
  - "done": the goal is fully satisfied, OR the agent clearly explains the goal is
    unachievable / blocked / needs user input (treat a genuine block as done).
  - "continue": not done, and there is a concrete next step the agent can take now.
    This is the default when in doubt.

  Reply with ONLY a single-line JSON object, no prose, no code fences:
  {"verdict": "done", "reason": "<one short sentence>"}
  {"verdict": "continue", "reason": "<one short sentence>"}
PROMPT
CONTINUATION_PROMPT_TEMPLATE =
<<~PROMPT.freeze
  [Continuing toward your standing goal]
  Goal: %<goal>s

  Continue working toward this goal. Take the next concrete step.
  If you believe the goal is complete, state so explicitly and stop.
  If you are blocked and need input from the user, say so clearly and stop.
PROMPT

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(judge_client:, judge_model:, state: nil) ⇒ GoalManager

Returns a new instance of GoalManager.



52
53
54
55
56
57
# File 'lib/clacky/agent/goal_manager.rb', line 52

def initialize(judge_client:, judge_model:, state: nil)
  @judge_client = judge_client
  @judge_model = judge_model
  @state = state
  @consecutive_judge_failures = 0
end

Instance Attribute Details

#stateObject (readonly)

Returns the value of attribute state.



50
51
52
# File 'lib/clacky/agent/goal_manager.rb', line 50

def state
  @state
end

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


59
60
61
# File 'lib/clacky/agent/goal_manager.rb', line 59

def active?
  @state&.active? || false
end

#clearObject



97
98
99
# File 'lib/clacky/agent/goal_manager.rb', line 97

def clear
  @state = nil
end

#continuation_promptObject



224
225
226
# File 'lib/clacky/agent/goal_manager.rb', line 224

def continuation_prompt
  format(CONTINUATION_PROMPT_TEMPLATE, goal: @state.goal)
end

#evaluate_after_turn(last_response) ⇒ Hash

Judge the finished turn and decide the loop's next move.

Parameters:

  • last_response (String)

    the agent's final assistant text this turn

Returns:

  • (Hash)

    decision: { should_continue: Boolean, # caller should run another turn continuation_prompt: String|nil, # prompt to feed the next turn status: String, # goal status after this turn verdict: "done"|"continue", reason: String, message: String # user-visible one-liner }



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/clacky/agent/goal_manager.rb', line 134

def evaluate_after_turn(last_response)
  return inactive_decision unless @state&.active?

  @state.turns_used += 1
  @state.last_turn_at = Time.now.to_f

  last_response = last_response.to_s
  if last_response.strip.empty?
    # Nothing to judge — treat as a transient blip, keep going if budget allows.
    return continue_or_pause("empty response (nothing to evaluate)")
  end

  verdict, reason = judge(@state.goal, last_response)
  @state.last_verdict = verdict
  @state.last_reason = reason

  if verdict.nil?
    @consecutive_judge_failures += 1
    if @consecutive_judge_failures >= MAX_CONSECUTIVE_JUDGE_FAILURES
      return pause_decision(
        "judge returned unusable output #{@consecutive_judge_failures} turns in a row"
      )
    end
    return continue_or_pause("judge output unusable — continuing")
  end

  @consecutive_judge_failures = 0

  if verdict == "done"
    @state.status = "done"
    return {
      should_continue: false,
      continuation_prompt: nil,
      status: "done",
      verdict: "done",
      reason: reason,
      message: "✓ Goal achieved: #{reason}"
    }
  end

  continue_or_pause(reason)
end

#has_goal?Boolean

Returns:

  • (Boolean)


63
64
65
# File 'lib/clacky/agent/goal_manager.rb', line 63

def has_goal?
  @state && (@state.active? || @state.paused?)
end

#pause(reason: "user-paused") ⇒ Object



81
82
83
84
85
86
# File 'lib/clacky/agent/goal_manager.rb', line 81

def pause(reason: "user-paused")
  return nil unless @state
  @state.status = "paused"
  @state.paused_reason = reason
  @state
end

#resume(reset_budget: true) ⇒ Object



88
89
90
91
92
93
94
95
# File 'lib/clacky/agent/goal_manager.rb', line 88

def resume(reset_budget: true)
  return nil unless @state
  @state.status = "active"
  @state.paused_reason = nil
  @state.turns_used = 0 if reset_budget
  @consecutive_judge_failures = 0
  @state
end

#set(goal, max_turns: nil) ⇒ Object

--- lifecycle ----------------------------------------------------------

Raises:

  • (ArgumentError)


69
70
71
72
73
74
75
76
77
78
79
# File 'lib/clacky/agent/goal_manager.rb', line 69

def set(goal, max_turns: nil)
  goal = goal.to_s.strip
  raise ArgumentError, "goal text is empty" if goal.empty?

  @state = GoalState.new(
    goal: goal,
    max_turns: max_turns || GoalState::DEFAULT_MAX_TURNS
  )
  @consecutive_judge_failures = 0
  @state
end

#status_lineObject

Human-readable one-liner for /goal status and status bars.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/clacky/agent/goal_manager.rb', line 102

def status_line
  return "No active goal. Set one with /goal <text>." unless @state

  s = @state
  meta = "#{s.turns_used}/#{s.max_turns} turns"
  case s.status
  when "active"
    "⊙ Goal (active, #{meta}): #{s.goal}"
  when "paused"
    extra = s.paused_reason ? "#{s.paused_reason}" : ""
    "⏸ Goal (paused, #{meta}#{extra}): #{s.goal}"
  when "done"
    "✓ Goal done (#{meta}): #{s.goal}"
  else
    "Goal (#{s.status}, #{meta}): #{s.goal}"
  end
end

#to_hObject

--- persistence bridge -------------------------------------------------



179
180
181
# File 'lib/clacky/agent/goal_manager.rb', line 179

def to_h
  @state&.to_h
end