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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/gem-push.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +53 -0
  5. data/README.md +3 -8
  6. data/lib/ralph/cli.rb +101 -183
  7. data/lib/ralph/display.rb +110 -0
  8. data/lib/ralph/events.rb +117 -0
  9. data/lib/ralph/iteration.rb +70 -190
  10. data/lib/ralph/loop.rb +115 -174
  11. data/lib/ralph/metrics.rb +88 -0
  12. data/lib/ralph/opencode.rb +66 -0
  13. data/lib/ralph/prompt/build.rb +60 -0
  14. data/lib/ralph/prompt/plan.rb +49 -0
  15. data/lib/ralph/version.rb +1 -1
  16. data/lib/ralph.rb +0 -3
  17. data/plans/00-complete-implementation.md +128 -0
  18. data/plans/01-cli-implementation.md +65 -0
  19. data/plans/02-loop-implementation.md +78 -0
  20. data/plans/03-agents-implementation.md +76 -0
  21. data/plans/04-metrics-implementation.md +98 -0
  22. data/plans/05-prompts-implementation.md +66 -0
  23. data/plans/README.md +68 -0
  24. data/ralph.jpg +0 -0
  25. data/reading/ralph-playbook.md +2119 -0
  26. data/specs/README.md +5 -15
  27. data/specs/agents.md +426 -120
  28. data/specs/cli.md +31 -210
  29. data/specs/loop.md +76 -0
  30. data/specs/metrics.md +51 -0
  31. data/specs/prompts.md +137 -0
  32. metadata +21 -38
  33. data/lib/ralph/agents/base.rb +0 -132
  34. data/lib/ralph/agents/claude_code.rb +0 -24
  35. data/lib/ralph/agents/codex.rb +0 -25
  36. data/lib/ralph/agents/open_code.rb +0 -30
  37. data/lib/ralph/agents.rb +0 -24
  38. data/lib/ralph/config.rb +0 -40
  39. data/lib/ralph/git/file_snapshot.rb +0 -60
  40. data/lib/ralph/helpers.rb +0 -76
  41. data/lib/ralph/output/active_loop_error.rb +0 -13
  42. data/lib/ralph/output/banner.rb +0 -29
  43. data/lib/ralph/output/completion_deferred.rb +0 -12
  44. data/lib/ralph/output/completion_detected.rb +0 -17
  45. data/lib/ralph/output/config_summary.rb +0 -31
  46. data/lib/ralph/output/context_consumed.rb +0 -11
  47. data/lib/ralph/output/iteration.rb +0 -45
  48. data/lib/ralph/output/max_iterations_reached.rb +0 -16
  49. data/lib/ralph/output/no_plugin_warning.rb +0 -14
  50. data/lib/ralph/output/nonzero_exit_warning.rb +0 -11
  51. data/lib/ralph/output/plugin_error.rb +0 -12
  52. data/lib/ralph/output/status.rb +0 -176
  53. data/lib/ralph/output/struggle_warning.rb +0 -18
  54. data/lib/ralph/output/task_completion.rb +0 -12
  55. data/lib/ralph/output/tasks_file_created.rb +0 -11
  56. data/lib/ralph/prompt_template.rb +0 -183
  57. data/lib/ralph/storage/context.rb +0 -58
  58. data/lib/ralph/storage/history.rb +0 -117
  59. data/lib/ralph/storage/state.rb +0 -178
  60. data/lib/ralph/storage/tasks.rb +0 -244
  61. data/lib/ralph/threads/heartbeat.rb +0 -44
  62. data/lib/ralph/threads/stream_reader.rb +0 -50
  63. data/original/bin/ralph.js +0 -13
  64. data/original/ralph.ts +0 -1706
  65. data/specs/iteration.md +0 -173
  66. data/specs/output.md +0 -104
  67. data/specs/storage/local-data-structure.md +0 -246
  68. data/specs/tasks.md +0 -295
@@ -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
- # Statuses:
6
- # :completed — completion promise detected, iteration succeeded
7
- # :continuing — no completion detected, keep looping
8
- # :failed — non-zero exit code from the agent process
9
- # :fatal — fatal error detected in agent output (unrecoverable)
10
- # :error iteration raised an exception
11
- class Result
12
- STATUSES = %i[completed continuing failed fatal error].freeze
13
-
14
- attr_reader :status, :agent_result, :duration_ms, :files_modified, :completion_detected, :errors
15
-
16
- def initialize(status:, agent_result:, duration_ms:, files_modified:, completion_detected:, errors:)
17
- @status = status
18
- @agent_result = agent_result
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
- def context_at_start = @_context_at_start ||= @loop.context
63
-
64
- def run
65
- snapshot_before = Git::FileSnapshot.capture
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.execute(
72
- @loop.prompt.build_iteration(@state, @agent),
73
- on_line: method(:handle_line),
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 @config.stream_output
82
- @mutex.synchronize { maybe_print_tool_summary(force: true) }
35
+ if event.is_a?(Events::Text)
36
+ check_signals(event.text)
83
37
  end
84
38
 
85
- unless @config.stream_output
86
- if agent_result
87
- warn agent_result.stderr_text unless agent_result.stderr_text.empty?
88
- puts agent_result.stdout_text
89
- end
39
+ if @outcome
40
+ @agent.cancel
41
+ break
90
42
  end
91
43
 
92
- snapshot_after = Git::FileSnapshot.capture
93
-
94
- combined_output = agent_result.combined_output
95
- completion_detected = check_completion(combined_output, @config.completion_promise)
96
- fatal_error = @agent.detect_fatal_error(combined_output)
97
-
98
- status = (
99
- if fatal_error
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
- Result.new(
111
- status: status,
112
- agent_result: agent_result,
113
- duration_ms: now_ms - iteration_start,
114
- files_modified: snapshot_before.modified_since(snapshot_after),
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
- if @config.current_pid
123
- begin
124
- Process.kill('TERM', @config.current_pid)
125
- rescue StandardError
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
- # Returns true when the agent appears to be stuck.
151
- # Should only be called after iteration > 2 for meaningful results.
152
- def struggling?
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
- private
158
-
159
- # ---------- Struggle tracking ----------
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
- # ---------- Agent execution ----------
185
-
186
- def maybe_print_tool_summary(force: false)
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
- def handle_line(line, is_error, tool)
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
- if tool && @compact_tools
207
- @mutex.synchronize { maybe_print_tool_summary }
208
- else
209
- if line.empty?
210
- puts ''
211
- elsif is_error
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
- include ::Ralph::Helpers
13
-
14
- attr_reader :config, :agent, :state, :history, :context, :tasks, :prompt, :struggle_indicators
15
-
16
- def initialize(config, state, history, context, tasks)
17
- @config = config
18
- @agent = Agents.resolve(@config.chosen_agent)
19
- @state = state
20
- @history = history
21
- @context = context
22
- @tasks = tasks
23
-
24
- @struggle_indicators = {
25
- repeated_errors: {},
26
- no_progress_iterations: 0,
27
- short_iterations: 0
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
- def existing_state = @existing_state
36
-
33
+ # Run the main loop until a termination condition is met.
37
34
  def run
38
- if existing_state&.active
39
- Output::ActiveLoopError.call(existing_state, path: Storage::State.path)
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 @config.stopping
65
- break
66
- elsif max_iterations_reached?
67
- Output::MaxIterationsReached.call(self)
68
- @state.clear
69
- break
70
- else
71
- Output::Iteration::Header.call(self)
72
-
73
- iteration = Iteration.new(self)
74
- result = iteration.run
75
- should_continue = process_result(result, iteration)
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
- private
55
+ @display.show_summary
56
+ @completed
57
+ end
89
58
 
90
- def process_result(result, iteration)
91
- unless result.error?
92
- Output::Iteration::Summary.call(self, result)
93
- end
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
- case result.status
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
- result.status != :completed || @state.iteration < @config.min_iterations
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
- def max_iterations_reached?
172
- @config.max_iterations > 0 && @state.iteration > @config.max_iterations
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
- def setup_signal_handler
176
- Signal.trap('INT') do
177
- if @config.stopping
178
- warn "\nForce stopping..."
179
- exit 1
180
- end
181
- @config.stopping = true
182
- warn "\nGracefully stopping Ralph loop..."
183
- if @config.current_pid
184
- begin
185
- Process.kill('TERM', @config.current_pid)
186
- rescue StandardError
187
- # process may have exited
188
- end
189
- end
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