ralph.rb 1.2.4355354345 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/gem-push.yml +2 -2
- data/Gemfile +1 -1
- data/Gemfile.lock +53 -0
- data/README.md +3 -8
- data/lib/ralph/cli.rb +101 -183
- data/lib/ralph/display.rb +110 -0
- data/lib/ralph/events.rb +117 -0
- data/lib/ralph/iteration.rb +70 -190
- data/lib/ralph/loop.rb +115 -174
- data/lib/ralph/metrics.rb +88 -0
- data/lib/ralph/opencode.rb +66 -0
- data/lib/ralph/prompt/build.rb +60 -0
- data/lib/ralph/prompt/plan.rb +49 -0
- data/lib/ralph/version.rb +1 -1
- data/lib/ralph.rb +0 -3
- data/plans/00-complete-implementation.md +128 -0
- data/plans/01-cli-implementation.md +65 -0
- data/plans/02-loop-implementation.md +78 -0
- data/plans/03-agents-implementation.md +76 -0
- data/plans/04-metrics-implementation.md +98 -0
- data/plans/05-prompts-implementation.md +66 -0
- data/plans/README.md +68 -0
- data/ralph.jpg +0 -0
- data/reading/ralph-playbook.md +2119 -0
- data/specs/README.md +5 -15
- data/specs/agents.md +426 -120
- data/specs/cli.md +31 -210
- data/specs/loop.md +76 -0
- data/specs/metrics.md +51 -0
- data/specs/prompts.md +137 -0
- metadata +21 -38
- data/lib/ralph/agents/base.rb +0 -132
- data/lib/ralph/agents/claude_code.rb +0 -24
- data/lib/ralph/agents/codex.rb +0 -25
- data/lib/ralph/agents/open_code.rb +0 -30
- data/lib/ralph/agents.rb +0 -24
- data/lib/ralph/config.rb +0 -40
- data/lib/ralph/git/file_snapshot.rb +0 -60
- data/lib/ralph/helpers.rb +0 -76
- data/lib/ralph/output/active_loop_error.rb +0 -13
- data/lib/ralph/output/banner.rb +0 -29
- data/lib/ralph/output/completion_deferred.rb +0 -12
- data/lib/ralph/output/completion_detected.rb +0 -17
- data/lib/ralph/output/config_summary.rb +0 -31
- data/lib/ralph/output/context_consumed.rb +0 -11
- data/lib/ralph/output/iteration.rb +0 -45
- data/lib/ralph/output/max_iterations_reached.rb +0 -16
- data/lib/ralph/output/no_plugin_warning.rb +0 -14
- data/lib/ralph/output/nonzero_exit_warning.rb +0 -11
- data/lib/ralph/output/plugin_error.rb +0 -12
- data/lib/ralph/output/status.rb +0 -176
- data/lib/ralph/output/struggle_warning.rb +0 -18
- data/lib/ralph/output/task_completion.rb +0 -12
- data/lib/ralph/output/tasks_file_created.rb +0 -11
- data/lib/ralph/prompt_template.rb +0 -183
- data/lib/ralph/storage/context.rb +0 -58
- data/lib/ralph/storage/history.rb +0 -117
- data/lib/ralph/storage/state.rb +0 -178
- data/lib/ralph/storage/tasks.rb +0 -244
- data/lib/ralph/threads/heartbeat.rb +0 -44
- data/lib/ralph/threads/stream_reader.rb +0 -50
- data/original/bin/ralph.js +0 -13
- data/original/ralph.ts +0 -1706
- data/specs/iteration.md +0 -173
- data/specs/output.md +0 -104
- data/specs/storage/local-data-structure.md +0 -246
- data/specs/tasks.md +0 -295
data/lib/ralph/iteration.rb
CHANGED
|
@@ -1,219 +1,99 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ralph
|
|
4
|
+
# Represents a single execution cycle within the loop. Runs the agent with
|
|
5
|
+
# the prompt, monitors events, detects signals, and can be cancelled at
|
|
6
|
+
# any time. Tracks the outcome of the iteration (task-done, all-done,
|
|
7
|
+
# context guard, error, etc.).
|
|
4
8
|
class Iteration
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@duration_ms = duration_ms
|
|
20
|
-
@files_modified = files_modified
|
|
21
|
-
@completion_detected = completion_detected
|
|
22
|
-
@errors = errors
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def exit_code = agent_result&.exit_code
|
|
26
|
-
def stdout_text = agent_result&.stdout_text || ""
|
|
27
|
-
def stderr_text = agent_result&.stderr_text || ""
|
|
28
|
-
def tool_counts = agent_result&.tool_counts || {}
|
|
29
|
-
def combined_output = agent_result&.combined_output || ""
|
|
30
|
-
|
|
31
|
-
def completed? = status == :completed
|
|
32
|
-
def continuing? = status == :continuing
|
|
33
|
-
def failed? = status == :failed
|
|
34
|
-
def fatal? = status == :fatal
|
|
35
|
-
def error? = status == :error
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
include ::Ralph::Helpers
|
|
39
|
-
|
|
40
|
-
attr_reader :struggle_indicators, :iteration_start
|
|
41
|
-
|
|
42
|
-
def initialize(loop_context)
|
|
43
|
-
@loop = loop_context
|
|
44
|
-
@config = @loop.config
|
|
45
|
-
@agent = @loop.agent
|
|
46
|
-
@state = @loop.state
|
|
47
|
-
@struggle_indicators = @loop.struggle_indicators
|
|
48
|
-
|
|
49
|
-
# Streaming configuration
|
|
50
|
-
@compact_tools = !@config.verbose_tools
|
|
51
|
-
@tool_summary_interval_ms = 3000
|
|
52
|
-
@heartbeat_interval_ms = 10_000
|
|
53
|
-
|
|
54
|
-
@stream_tool_counts = Hash.new(0)
|
|
55
|
-
@mutex = Mutex.new
|
|
56
|
-
@timing = { last_printed_at: now_ms, last_activity_at: now_ms }
|
|
57
|
-
@last_tool_summary_at = 0
|
|
58
|
-
|
|
59
|
-
@iteration_start = now_ms
|
|
9
|
+
OUTCOMES = %i[task_done all_done context_limit duration_limit error].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :number, :outcome
|
|
12
|
+
|
|
13
|
+
def initialize(number:, prompt_text:, model:, task_done_string:, all_done_string:, metrics:, display:)
|
|
14
|
+
@number = number
|
|
15
|
+
@prompt_text = prompt_text
|
|
16
|
+
@model = model
|
|
17
|
+
@task_done_string = task_done_string
|
|
18
|
+
@all_done_string = all_done_string
|
|
19
|
+
@metrics = metrics
|
|
20
|
+
@display = display
|
|
21
|
+
@outcome = nil
|
|
22
|
+
@agent = nil
|
|
60
23
|
end
|
|
61
24
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if @config.stream_output
|
|
68
|
-
heartbeat = Threads::Heartbeat.new(iteration_start, @heartbeat_interval_ms, @timing, @mutex)
|
|
69
|
-
end
|
|
25
|
+
# Run the iteration. Yields control back to the loop via the check block
|
|
26
|
+
# which determines if the iteration should be cancelled due to external
|
|
27
|
+
# limits (context, duration). Returns the outcome symbol.
|
|
28
|
+
def run(max_context: nil, duration_exceeded: nil)
|
|
29
|
+
@agent = Opencode.new(model: @model)
|
|
70
30
|
|
|
71
|
-
@agent.
|
|
72
|
-
@
|
|
73
|
-
|
|
74
|
-
model: @config.model,
|
|
75
|
-
stream_output: @config.stream_output,
|
|
76
|
-
disable_plugins: @config.disable_plugins,
|
|
77
|
-
allow_all_permissions: @config.allow_all_permissions,
|
|
78
|
-
).then do |agent_result|
|
|
79
|
-
heartbeat&.stop
|
|
31
|
+
@agent.run(@prompt_text) do |event|
|
|
32
|
+
@metrics.process(event)
|
|
33
|
+
@display.show_event(event)
|
|
80
34
|
|
|
81
|
-
if
|
|
82
|
-
|
|
35
|
+
if event.is_a?(Events::Text)
|
|
36
|
+
check_signals(event.text)
|
|
83
37
|
end
|
|
84
38
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
puts agent_result.stdout_text
|
|
89
|
-
end
|
|
39
|
+
if @outcome
|
|
40
|
+
@agent.cancel
|
|
41
|
+
break
|
|
90
42
|
end
|
|
91
43
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
:fatal
|
|
101
|
-
elsif agent_result.exit_code != 0
|
|
102
|
-
:failed
|
|
103
|
-
elsif completion_detected
|
|
104
|
-
:completed
|
|
105
|
-
else
|
|
106
|
-
:continuing
|
|
107
|
-
end
|
|
108
|
-
)
|
|
44
|
+
if max_context && @metrics.current_context >= max_context
|
|
45
|
+
@outcome = :context_limit
|
|
46
|
+
@display.show_iteration_cancelled(
|
|
47
|
+
"context limit reached (#{@metrics.current_context}/#{max_context})"
|
|
48
|
+
)
|
|
49
|
+
@agent.cancel
|
|
50
|
+
break
|
|
51
|
+
end
|
|
109
52
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
completion_detected: completion_detected,
|
|
116
|
-
errors: @agent.extract_errors(combined_output)
|
|
117
|
-
).tap do |result|
|
|
118
|
-
update_struggle_indicators(result)
|
|
53
|
+
if duration_exceeded&.call
|
|
54
|
+
@outcome = :duration_limit
|
|
55
|
+
@display.show_iteration_cancelled("duration limit reached")
|
|
56
|
+
@agent.cancel
|
|
57
|
+
break
|
|
119
58
|
end
|
|
120
59
|
end
|
|
60
|
+
rescue Errno::ENOENT => error
|
|
61
|
+
@outcome = :error
|
|
62
|
+
@display.show_iteration_error("agent not found: #{error.message}")
|
|
63
|
+
rescue IOError, Errno::EPIPE => error
|
|
64
|
+
@outcome = :error
|
|
65
|
+
@display.show_iteration_error("agent communication error: #{error.message}")
|
|
121
66
|
rescue StandardError => error
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
# process may have exited
|
|
127
|
-
end
|
|
128
|
-
@config.current_pid = nil
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
Output::Iteration::Error.call(@loop, error)
|
|
132
|
-
|
|
133
|
-
sleep 2
|
|
134
|
-
|
|
135
|
-
Result.new(
|
|
136
|
-
status: :error,
|
|
137
|
-
agent_result: nil,
|
|
138
|
-
duration_ms: now_ms - (iteration_start || now_ms), # this is so fucking wrong
|
|
139
|
-
files_modified: [],
|
|
140
|
-
completion_detected: false,
|
|
141
|
-
errors: [error.message]
|
|
142
|
-
)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# ---------- Warnings and error detection ----------
|
|
146
|
-
|
|
147
|
-
def handle_iteration_error(error, iteration_start)
|
|
67
|
+
@outcome = :error
|
|
68
|
+
@display.show_iteration_error("unexpected error: #{error.message}")
|
|
69
|
+
ensure
|
|
70
|
+
@outcome ||= :unknown
|
|
148
71
|
end
|
|
149
72
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
@struggle_indicators[:no_progress_iterations] >= 3 ||
|
|
154
|
-
@struggle_indicators[:short_iterations] >= 3
|
|
73
|
+
# Whether this iteration ended because the agent signaled task-done
|
|
74
|
+
def task_done?
|
|
75
|
+
@outcome == :task_done
|
|
155
76
|
end
|
|
156
77
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def update_struggle_indicators(result)
|
|
162
|
-
if result.files_modified.empty?
|
|
163
|
-
@struggle_indicators[:no_progress_iterations] += 1
|
|
164
|
-
else
|
|
165
|
-
@struggle_indicators[:no_progress_iterations] = 0
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
if result.duration_ms < 30_000
|
|
169
|
-
@struggle_indicators[:short_iterations] += 1
|
|
170
|
-
else
|
|
171
|
-
@struggle_indicators[:short_iterations] = 0
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
if result.errors.empty?
|
|
175
|
-
@struggle_indicators[:repeated_errors] = {}
|
|
176
|
-
else
|
|
177
|
-
result.errors.each do |error|
|
|
178
|
-
key = error[0, 100]
|
|
179
|
-
@struggle_indicators[:repeated_errors][key] = (@struggle_indicators[:repeated_errors][key] || 0) + 1
|
|
180
|
-
end
|
|
181
|
-
end
|
|
78
|
+
# Whether this iteration ended because the agent signaled all-done
|
|
79
|
+
def all_done?
|
|
80
|
+
@outcome == :all_done
|
|
182
81
|
end
|
|
183
82
|
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if @compact_tools && @stream_tool_counts.any?
|
|
188
|
-
now = now_ms
|
|
189
|
-
if force || (now - @last_tool_summary_at >= @tool_summary_interval_ms)
|
|
190
|
-
format_tool_summary(@stream_tool_counts).then do |summary|
|
|
191
|
-
unless summary.empty?
|
|
192
|
-
puts "| Tools #{summary}"
|
|
193
|
-
@timing[:last_printed_at] = now_ms
|
|
194
|
-
@last_tool_summary_at = now_ms
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
83
|
+
# Whether this iteration ended due to an error
|
|
84
|
+
def error?
|
|
85
|
+
@outcome == :error
|
|
199
86
|
end
|
|
200
87
|
|
|
201
|
-
|
|
202
|
-
@mutex.synchronize { @timing[:last_activity_at] = now_ms }
|
|
203
|
-
|
|
204
|
-
@mutex.synchronize { @stream_tool_counts[tool] += 1 } if tool
|
|
88
|
+
private
|
|
205
89
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
warn line
|
|
213
|
-
else
|
|
214
|
-
puts line
|
|
90
|
+
def check_signals(text)
|
|
91
|
+
if text
|
|
92
|
+
if text.include?(@all_done_string)
|
|
93
|
+
@outcome = :all_done
|
|
94
|
+
elsif @task_done_string && text.include?(@task_done_string)
|
|
95
|
+
@outcome = :task_done
|
|
215
96
|
end
|
|
216
|
-
@mutex.synchronize { @timing[:last_printed_at] = now_ms }
|
|
217
97
|
end
|
|
218
98
|
end
|
|
219
99
|
end
|
data/lib/ralph/loop.rb
CHANGED
|
@@ -1,196 +1,137 @@
|
|
|
1
|
-
# _ _ _
|
|
2
|
-
# _ __ __ _| |_ __ | |__ __ _(_) __ _ __ _ _ _ _ __ ___
|
|
3
|
-
# | '__/ _` | | '_ \| '_ \ \ \ /\ / / |/ _` |/ _` | | | | '_ ` _ \
|
|
4
|
-
# | | | (_| | | |_) | | | | \ V V /| | (_| | (_| | |_| | | | | | |
|
|
5
|
-
# |_| \__,_|_| .__/|_| |_| \_/\_/ |_|\__, |\__, |\__,_|_| |_| |_|
|
|
6
|
-
# |_| |___/ |___/
|
|
7
|
-
#
|
|
8
1
|
# frozen_string_literal: true
|
|
9
2
|
|
|
10
3
|
module Ralph
|
|
4
|
+
# The core iteration engine. Runs opencode in a loop, restarting fresh
|
|
5
|
+
# iterations whenever context grows too large or time limits are hit.
|
|
6
|
+
# The loop ends when:
|
|
7
|
+
# - the agent emits the all-done completion string
|
|
8
|
+
# - max iterations are reached
|
|
9
|
+
# - total duration is exceeded
|
|
10
|
+
#
|
|
11
|
+
# An iteration ends early (and a fresh one begins) when:
|
|
12
|
+
# - the agent emits the task-done string (build mode only)
|
|
13
|
+
# - context limit is exceeded
|
|
11
14
|
class Loop
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@
|
|
18
|
-
@
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
@prompt = @config.prompt
|
|
31
|
-
@existing_state = Storage::State.load
|
|
32
|
-
@state.save
|
|
15
|
+
attr_reader :metrics, :iteration_number, :completed, :iteration_outcomes
|
|
16
|
+
|
|
17
|
+
def initialize(options)
|
|
18
|
+
@prompt = options[:prompt]
|
|
19
|
+
@model = options[:model]
|
|
20
|
+
@max_iterations = options[:max_iterations]
|
|
21
|
+
@duration_limit = options[:duration]
|
|
22
|
+
@max_context = options[:max_context]
|
|
23
|
+
@all_done_string = resolve_all_done_string(options)
|
|
24
|
+
@task_done_string = resolve_task_done_string
|
|
25
|
+
@metrics = Metrics.new
|
|
26
|
+
@iteration_number = 0
|
|
27
|
+
@completed = false
|
|
28
|
+
@iteration_outcomes = []
|
|
29
|
+
@started_at = nil
|
|
30
|
+
@display = Display.new(self)
|
|
33
31
|
end
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
# Run the main loop until a termination condition is met.
|
|
37
34
|
def run
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
exit 1
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
Output::Banner.call(self)
|
|
44
|
-
|
|
45
|
-
# |..................................................|
|
|
46
|
-
# |--------------------------------------------------|
|
|
47
|
-
# |==================================================|
|
|
48
|
-
# |**************************************************|
|
|
49
|
-
# |##################################################|
|
|
50
|
-
# | Main loop |
|
|
51
|
-
# |##################################################|
|
|
52
|
-
# |**************************************************|
|
|
53
|
-
# |==================================================|
|
|
54
|
-
# |--------------------------------------------------|
|
|
55
|
-
# |..................................................|
|
|
56
|
-
#
|
|
57
|
-
# © 2026 Nathan K.
|
|
58
|
-
# Honestly, I've no idea where this graphic
|
|
59
|
-
# wonder came from. It's MIT lisenced now, thought.
|
|
60
|
-
|
|
61
|
-
setup_signal_handler
|
|
35
|
+
@started_at = now_seconds
|
|
36
|
+
@display.show_start(prompt_text)
|
|
62
37
|
|
|
63
38
|
loop do
|
|
64
|
-
if
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if should_continue
|
|
78
|
-
@state.iteration += 1
|
|
79
|
-
@state.save
|
|
80
|
-
sleep 1
|
|
81
|
-
else
|
|
82
|
-
break
|
|
83
|
-
end
|
|
39
|
+
break if should_stop_loop?
|
|
40
|
+
|
|
41
|
+
@iteration_number += 1
|
|
42
|
+
@metrics.new_iteration
|
|
43
|
+
@display.show_iteration_start
|
|
44
|
+
|
|
45
|
+
iteration = run_iteration
|
|
46
|
+
|
|
47
|
+
@iteration_outcomes << { number: iteration.number, outcome: iteration.outcome }
|
|
48
|
+
@display.show_iteration_end
|
|
49
|
+
|
|
50
|
+
if iteration.all_done?
|
|
51
|
+
@completed = true
|
|
84
52
|
end
|
|
85
53
|
end
|
|
86
|
-
end
|
|
87
54
|
|
|
88
|
-
|
|
55
|
+
@display.show_summary
|
|
56
|
+
@completed
|
|
57
|
+
end
|
|
89
58
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
59
|
+
# Total elapsed wall-clock seconds since the loop started
|
|
60
|
+
def elapsed_seconds
|
|
61
|
+
if @started_at
|
|
62
|
+
now_seconds - @started_at
|
|
63
|
+
else
|
|
64
|
+
0.0
|
|
65
|
+
end
|
|
66
|
+
end
|
|
94
67
|
|
|
95
|
-
|
|
96
|
-
when :fatal
|
|
97
|
-
@history.record(
|
|
98
|
-
state_iteration: @state.iteration,
|
|
99
|
-
iteration_start: iteration.iteration_start,
|
|
100
|
-
result: result,
|
|
101
|
-
struggle_indicators: iteration.struggle_indicators
|
|
102
|
-
)
|
|
103
|
-
Output::PluginError.call
|
|
104
|
-
@state.clear
|
|
105
|
-
exit 1
|
|
106
|
-
|
|
107
|
-
when :failed
|
|
108
|
-
@history.record(
|
|
109
|
-
state_iteration: @state.iteration,
|
|
110
|
-
iteration_start: iteration.iteration_start,
|
|
111
|
-
result: result,
|
|
112
|
-
struggle_indicators: iteration.struggle_indicators
|
|
113
|
-
)
|
|
114
|
-
Output::NonzeroExitWarning.call(self, result)
|
|
115
|
-
|
|
116
|
-
if @config.tasks_mode
|
|
117
|
-
if check_completion(result.combined_output, @config.task_promise)
|
|
118
|
-
Output::TaskCompletion.call(self)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
when :completed
|
|
123
|
-
@history.record(
|
|
124
|
-
state_iteration: @state.iteration,
|
|
125
|
-
iteration_start: iteration.iteration_start,
|
|
126
|
-
result: result,
|
|
127
|
-
struggle_indicators: iteration.struggle_indicators
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
if @state.iteration >= @config.min_iterations
|
|
131
|
-
Output::CompletionDetected.call(self)
|
|
132
|
-
@state.clear
|
|
133
|
-
Storage::History.clear_history
|
|
134
|
-
@context.clear
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
when :continuing
|
|
138
|
-
@history.record(
|
|
139
|
-
state_iteration: @state.iteration,
|
|
140
|
-
iteration_start: iteration.iteration_start,
|
|
141
|
-
result: result,
|
|
142
|
-
struggle_indicators: iteration.struggle_indicators
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
if @state.iteration > 2 && iteration.struggling?
|
|
146
|
-
Output::StruggleWarning.call(self)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
if @config.tasks_mode
|
|
150
|
-
if check_completion(result.combined_output, @config.task_promise)
|
|
151
|
-
Output::TaskCompletion.call(self)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
if iteration.context_at_start.present?
|
|
156
|
-
Output::ContextConsumed.call
|
|
157
|
-
iteration.context_at_start.clear
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
when :error
|
|
161
|
-
@history.record_error(
|
|
162
|
-
state_iteration: @state.iteration,
|
|
163
|
-
iteration_start: iteration.iteration_start,
|
|
164
|
-
error: result.errors.first || "Unknown error"
|
|
165
|
-
)
|
|
166
|
-
end
|
|
68
|
+
private
|
|
167
69
|
|
|
168
|
-
|
|
70
|
+
# Read all-done string from the prompt object if it responds to it,
|
|
71
|
+
# falling back to the CLI --completion option or the default.
|
|
72
|
+
def resolve_all_done_string(options)
|
|
73
|
+
if options[:completion]
|
|
74
|
+
options[:completion]
|
|
75
|
+
elsif @prompt.respond_to?(:all_done) && @prompt.all_done
|
|
76
|
+
@prompt.all_done
|
|
77
|
+
else
|
|
78
|
+
Prompt::Build::DEFAULT_ALL_DONE
|
|
169
79
|
end
|
|
80
|
+
end
|
|
170
81
|
|
|
171
|
-
|
|
172
|
-
|
|
82
|
+
# Read task-done string from the prompt object. Plan prompts return nil,
|
|
83
|
+
# meaning the loop will not watch for task-done signals.
|
|
84
|
+
def resolve_task_done_string
|
|
85
|
+
if @prompt.respond_to?(:task_done)
|
|
86
|
+
@prompt.task_done
|
|
87
|
+
else
|
|
88
|
+
nil
|
|
173
89
|
end
|
|
90
|
+
end
|
|
174
91
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
@state.clear
|
|
191
|
-
warn 'Loop cancelled.'
|
|
192
|
-
exit 0
|
|
193
|
-
end
|
|
92
|
+
def prompt_text
|
|
93
|
+
@prompt.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def should_stop_loop?
|
|
97
|
+
if @completed
|
|
98
|
+
true
|
|
99
|
+
elsif @max_iterations && @iteration_number >= @max_iterations
|
|
100
|
+
@display.show_termination("max iterations reached (#{@max_iterations})")
|
|
101
|
+
true
|
|
102
|
+
elsif duration_exceeded?
|
|
103
|
+
@display.show_termination("duration limit reached (#{@duration_limit}s)")
|
|
104
|
+
true
|
|
105
|
+
else
|
|
106
|
+
false
|
|
194
107
|
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_iteration
|
|
111
|
+
iteration = Iteration.new(
|
|
112
|
+
number: @iteration_number,
|
|
113
|
+
prompt_text: prompt_text,
|
|
114
|
+
model: @model,
|
|
115
|
+
task_done_string: @task_done_string,
|
|
116
|
+
all_done_string: @all_done_string,
|
|
117
|
+
metrics: @metrics,
|
|
118
|
+
display: @display
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
iteration.run(
|
|
122
|
+
max_context: @max_context,
|
|
123
|
+
duration_exceeded: -> { duration_exceeded? }
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
iteration
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def duration_exceeded?
|
|
130
|
+
@duration_limit && elapsed_seconds >= @duration_limit
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def now_seconds
|
|
134
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
end
|
|
195
136
|
end
|
|
196
137
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
# Tracks token usage and context size from opencode JSON stream events.
|
|
5
|
+
#
|
|
6
|
+
# Context formula per step: input + cache.read + cache.write
|
|
7
|
+
# Each step's cache.read ~= previous step's (cache.read + cache.write).
|
|
8
|
+
class Metrics
|
|
9
|
+
attr_reader :steps, :iteration_steps, :total_input_tokens, :total_output_tokens
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@steps = []
|
|
13
|
+
@iteration_steps = []
|
|
14
|
+
@total_input_tokens = 0
|
|
15
|
+
@total_output_tokens = 0
|
|
16
|
+
@iteration_count = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Process a parsed event. Only StepFinish events carry token data.
|
|
20
|
+
def process(event)
|
|
21
|
+
if event.is_a?(Events::StepFinish)
|
|
22
|
+
record = {
|
|
23
|
+
input: event.input_tokens,
|
|
24
|
+
output: event.output_tokens,
|
|
25
|
+
reasoning: event.reasoning_tokens,
|
|
26
|
+
cache_read: event.cache_read,
|
|
27
|
+
cache_write: event.cache_write,
|
|
28
|
+
context: event.context_size,
|
|
29
|
+
timestamp: event.timestamp
|
|
30
|
+
}
|
|
31
|
+
@steps << record
|
|
32
|
+
@iteration_steps << record
|
|
33
|
+
@total_input_tokens += event.input_tokens
|
|
34
|
+
@total_output_tokens += event.output_tokens
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Current context size from the most recent step_finish
|
|
39
|
+
def current_context
|
|
40
|
+
if @steps.any?
|
|
41
|
+
@steps.last[:context]
|
|
42
|
+
else
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Total tokens consumed across all steps (input + output)
|
|
48
|
+
def tokens_consumed
|
|
49
|
+
@steps.sum { |step| step[:input] + step[:output] + step[:cache_read] + step[:cache_write] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Tokens consumed in the current iteration only
|
|
53
|
+
def iteration_tokens
|
|
54
|
+
@iteration_steps.sum { |step| step[:input] + step[:output] + step[:cache_read] + step[:cache_write] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Number of LLM steps completed
|
|
58
|
+
def step_count
|
|
59
|
+
@steps.length
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Signal a new iteration -- resets per-iteration tracking
|
|
63
|
+
def new_iteration
|
|
64
|
+
@iteration_count += 1
|
|
65
|
+
@iteration_steps = []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Context growth rate: average tokens added per step
|
|
69
|
+
def context_growth_rate
|
|
70
|
+
if @steps.length >= 2
|
|
71
|
+
first_context = @steps.first[:context]
|
|
72
|
+
last_context = @steps.last[:context]
|
|
73
|
+
(last_context - first_context).to_f / (@steps.length - 1)
|
|
74
|
+
else
|
|
75
|
+
0.0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reset all metrics (used for full restart)
|
|
80
|
+
def reset
|
|
81
|
+
@steps = []
|
|
82
|
+
@iteration_steps = []
|
|
83
|
+
@total_input_tokens = 0
|
|
84
|
+
@total_output_tokens = 0
|
|
85
|
+
@iteration_count = 0
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|