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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5554c13454fc43d04f41b7f969653cb7d51243eb40df245aefad31d82b38ab3a
|
|
4
|
+
data.tar.gz: 97909bf34b37d86ed11815c332f6ef6ba747c82c5ae60ded0e4b184a1d4a8120
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cab0f8e38f76a14251de705f0404ed443b9d597ddd93c3ba51a4a46f8ba8db5849d5819570034c95d70e9fbe95a4a4bf576df35564d0405c393eaaf7a1765e5
|
|
7
|
+
data.tar.gz: 2c9e36d50e6f304a018cac0deaba455421f67b5199e36b756dcffe9adb35fb443f9eaf09d8279231c3c85df10adbff1b88407a4d826cb2f3a7acb0a594c339ff
|
data/Gemfile
CHANGED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
ralph.rb (2.0.0)
|
|
5
|
+
|
|
6
|
+
GEM
|
|
7
|
+
remote: https://rubygems.org/
|
|
8
|
+
specs:
|
|
9
|
+
ast (2.4.3)
|
|
10
|
+
json (2.18.1)
|
|
11
|
+
language_server-protocol (3.17.0.5)
|
|
12
|
+
lint_roller (1.1.0)
|
|
13
|
+
minitest (5.27.0)
|
|
14
|
+
parallel (1.27.0)
|
|
15
|
+
parser (3.3.10.1)
|
|
16
|
+
ast (~> 2.4.1)
|
|
17
|
+
racc
|
|
18
|
+
prism (1.9.0)
|
|
19
|
+
racc (1.8.1)
|
|
20
|
+
rainbow (3.1.1)
|
|
21
|
+
rake (13.3.1)
|
|
22
|
+
regexp_parser (2.11.3)
|
|
23
|
+
rubocop (1.84.1)
|
|
24
|
+
json (~> 2.3)
|
|
25
|
+
language_server-protocol (~> 3.17.0.2)
|
|
26
|
+
lint_roller (~> 1.1.0)
|
|
27
|
+
parallel (~> 1.10)
|
|
28
|
+
parser (>= 3.3.0.2)
|
|
29
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
30
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
31
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
32
|
+
ruby-progressbar (~> 1.7)
|
|
33
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
34
|
+
rubocop-ast (1.49.0)
|
|
35
|
+
parser (>= 3.3.7.2)
|
|
36
|
+
prism (~> 1.7)
|
|
37
|
+
ruby-progressbar (1.13.0)
|
|
38
|
+
unicode-display_width (3.2.0)
|
|
39
|
+
unicode-emoji (~> 4.1)
|
|
40
|
+
unicode-emoji (4.2.0)
|
|
41
|
+
|
|
42
|
+
PLATFORMS
|
|
43
|
+
ruby
|
|
44
|
+
x86_64-linux
|
|
45
|
+
|
|
46
|
+
DEPENDENCIES
|
|
47
|
+
minitest (~> 5.0)
|
|
48
|
+
rake (~> 13.0)
|
|
49
|
+
ralph.rb!
|
|
50
|
+
rubocop (~> 1.21)
|
|
51
|
+
|
|
52
|
+
BUNDLED WITH
|
|
53
|
+
2.7.2
|
data/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<h1 align="center">
|
|
3
|
-
<h3 align="center">Autonomous Agentic Loop for
|
|
2
|
+
<h1 align="center">Ralph.rb</h1>
|
|
3
|
+
<h3 align="center">Autonomous Agentic Loop for OpenCode</h3>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
7
|
-
<img src="
|
|
7
|
+
<img src="ralph.jpg" alt="Ralph Wiggum - Iterative AI coding loop for Opencode" />
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
@@ -13,11 +13,6 @@
|
|
|
13
13
|
<em>Based on the <a href="https://ghuntley.com/ralph/">Ralph Wiggum technique</a> by Geoffrey Huntley</em>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
-
<p align="center">
|
|
17
|
-
<a href="https://github.com/Th0rgal/ralph-wiggum/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
|
|
18
|
-
<a href="https://github.com/Th0rgal/ralph-wiggum"><img src="https://img.shields.io/badge/built%20with-Bun%20%2B%20TypeScript-f472b6.svg" alt="Built with Bun + TypeScript"></a>
|
|
19
|
-
<a href="https://github.com/Th0rgal/ralph-wiggum/releases"><img src="https://img.shields.io/github/v/release/Th0rgal/ralph-wiggum?include_prereleases" alt="Release"></a>
|
|
20
|
-
</p>
|
|
21
16
|
|
|
22
17
|
<p align="center">
|
|
23
18
|
<a href="#supported-agents">Supported Agents</a> •
|
data/lib/ralph/cli.rb
CHANGED
|
@@ -1,222 +1,140 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ralph
|
|
4
|
+
# Command-line interface for ralph. Parses subcommands (build/plan),
|
|
5
|
+
# arguments, reads stdin when piped, and launches the Loop.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ralph build "focus on auth" --model=opus-4.5 --max-iterations=10
|
|
9
|
+
# ralph plan "user authentication system"
|
|
10
|
+
# ralph --max-iterations=10 # equivalent to: ralph build --max-iterations=10
|
|
11
|
+
# cat prompt.md | ralph build --model=opus-4.5
|
|
4
12
|
class CLI
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
--add-task "desc" Add a new task to the list
|
|
21
|
-
--remove-task N Remove task at index N (including subtasks)
|
|
22
|
-
|
|
23
|
-
Options:
|
|
24
|
-
BANNER
|
|
25
|
-
|
|
26
|
-
o.on("--agent AGENT", Agents.valid_agent_names, "AI agent: #{Agents.valid_agent_names.join(', ')} (default: opencode)") do |v|
|
|
27
|
-
@config.chosen_agent = v
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
o.on("--min-iterations N", Integer, "Minimum iterations before completion (default: 1)") do |v|
|
|
31
|
-
@config.min_iterations = v
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
o.on("--max-iterations N", Integer, "Maximum iterations before stopping (default: unlimited)") do |v|
|
|
35
|
-
@config.max_iterations = v
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
o.on("--completion-promise TEXT", "Phrase that signals completion (default: COMPLETE)") do |v|
|
|
39
|
-
@config.completion_promise = v
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
o.on("-t", "--tasks", "Enable Tasks Mode for structured task tracking") do
|
|
43
|
-
@config.tasks_mode = true
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
o.on("--task-promise TEXT", "Phrase that signals task completion (default: READY_FOR_NEXT_TASK)") do |v|
|
|
47
|
-
@config.task_promise = v
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
o.on("--model MODEL", "Model to use (agent-specific)") do |v|
|
|
51
|
-
@config.model = v
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
o.on("--[no-]stream", "Stream agent output in real-time (default: on)") do |v|
|
|
55
|
-
@config.stream_output = v
|
|
56
|
-
end
|
|
13
|
+
SUBCOMMANDS = %w[build plan].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :subcommand
|
|
16
|
+
|
|
17
|
+
def initialize(argv)
|
|
18
|
+
@argv = argv.dup
|
|
19
|
+
@subcommand = extract_subcommand
|
|
20
|
+
@options = {
|
|
21
|
+
model: nil,
|
|
22
|
+
max_iterations: nil,
|
|
23
|
+
duration: nil,
|
|
24
|
+
max_context: nil,
|
|
25
|
+
completion: nil
|
|
26
|
+
}
|
|
27
|
+
end
|
|
57
28
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
29
|
+
def run
|
|
30
|
+
parse_options
|
|
31
|
+
prompt_object = build_prompt_object
|
|
32
|
+
@options[:prompt] = prompt_object
|
|
61
33
|
|
|
62
|
-
|
|
63
|
-
@config.disable_plugins = true
|
|
64
|
-
end
|
|
34
|
+
Ralph::Loop.new(@options).run
|
|
65
35
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
36
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => error
|
|
37
|
+
$stderr.puts "Error: #{error.message}"
|
|
38
|
+
$stderr.puts parser.help
|
|
39
|
+
exit 1
|
|
40
|
+
end
|
|
69
41
|
|
|
70
|
-
|
|
71
|
-
o.on("-v", "--version", "Show version") do
|
|
72
|
-
puts "ralph #{VERSION}"
|
|
73
|
-
exit 0
|
|
74
|
-
end
|
|
42
|
+
private
|
|
75
43
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
44
|
+
def extract_subcommand
|
|
45
|
+
if @argv.first && SUBCOMMANDS.include?(@argv.first)
|
|
46
|
+
@argv.shift
|
|
47
|
+
else
|
|
48
|
+
"build"
|
|
79
49
|
end
|
|
50
|
+
end
|
|
80
51
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
52
|
+
def parse_options
|
|
53
|
+
parser.parse!(@argv)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parser
|
|
57
|
+
@parser ||= OptionParser.new do |option_parser|
|
|
58
|
+
option_parser.banner = "Usage: ralph [build|plan] [text] [options]"
|
|
59
|
+
option_parser.separator ""
|
|
60
|
+
option_parser.separator "Subcommands:"
|
|
61
|
+
option_parser.separator " build Implement tasks from the plan (default)"
|
|
62
|
+
option_parser.separator " plan Gap analysis and plan generation"
|
|
63
|
+
option_parser.separator ""
|
|
64
|
+
option_parser.separator "Options:"
|
|
65
|
+
|
|
66
|
+
option_parser.on("--model=MODEL", "Model to use (e.g. opus-4.5)") do |value|
|
|
67
|
+
@options[:model] = value
|
|
96
68
|
end
|
|
97
69
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
o.on("--clear-context", "Clear any pending context") do
|
|
102
|
-
Storage::Context.new.then do |context|
|
|
103
|
-
if context.present?
|
|
104
|
-
context.clear
|
|
105
|
-
puts "✅ Context cleared"
|
|
106
|
-
else
|
|
107
|
-
puts "ℹ️ No pending context to clear"
|
|
108
|
-
end
|
|
70
|
+
option_parser.on("--max-iterations=N", Integer, "Maximum number of iterations") do |value|
|
|
71
|
+
@options[:max_iterations] = value
|
|
109
72
|
end
|
|
110
73
|
|
|
111
|
-
|
|
112
|
-
|
|
74
|
+
option_parser.on("--duration=SECONDS", Integer, "Maximum total duration in seconds") do |value|
|
|
75
|
+
@options[:duration] = value
|
|
76
|
+
end
|
|
113
77
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
Storage::Tasks.new.load_tasks.then do |tasks|
|
|
117
|
-
if tasks
|
|
118
|
-
tasks.display_with_indices
|
|
119
|
-
else
|
|
120
|
-
puts "No tasks file found. Use --add-task to create your first task."
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
rescue StandardError => e
|
|
124
|
-
$stderr.puts "Error reading tasks file: #{e}"
|
|
125
|
-
exit 1
|
|
78
|
+
option_parser.on("--max-context=N", Integer, "Maximum context tokens before iteration restart") do |value|
|
|
79
|
+
@options[:max_context] = value
|
|
126
80
|
end
|
|
127
81
|
|
|
128
|
-
|
|
129
|
-
|
|
82
|
+
option_parser.on("--completion=STRING", "Completion string the agent emits when done") do |value|
|
|
83
|
+
@options[:completion] = value
|
|
84
|
+
end
|
|
130
85
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
puts "✅ Task added: \"#{description}\""
|
|
135
|
-
rescue StandardError => e
|
|
136
|
-
$stderr.puts "Error adding task: #{e}"
|
|
137
|
-
exit 1
|
|
86
|
+
option_parser.on("-h", "--help", "Show this help message") do
|
|
87
|
+
puts option_parser
|
|
88
|
+
exit 0
|
|
138
89
|
end
|
|
139
90
|
|
|
140
|
-
|
|
91
|
+
option_parser.on("-v", "--version", "Show version") do
|
|
92
|
+
puts "ralph #{Ralph::VERSION}"
|
|
93
|
+
exit 0
|
|
94
|
+
end
|
|
141
95
|
end
|
|
96
|
+
end
|
|
142
97
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Storage::Tasks.new.remove_task(task_index)
|
|
146
|
-
puts "✅ Removed task #{task_index} and its subtasks"
|
|
147
|
-
rescue IndexError => e
|
|
148
|
-
$stderr.puts "Error: #{e.message}"
|
|
149
|
-
exit 1
|
|
150
|
-
rescue RuntimeError => e
|
|
151
|
-
$stderr.puts "Error: #{e.message}"
|
|
152
|
-
exit 1
|
|
153
|
-
rescue StandardError => e
|
|
154
|
-
$stderr.puts "Error removing task: #{e}"
|
|
155
|
-
exit 1
|
|
156
|
-
end
|
|
98
|
+
def read_user_text
|
|
99
|
+
parts = []
|
|
157
100
|
|
|
158
|
-
|
|
101
|
+
# Read from stdin if data is being piped in
|
|
102
|
+
unless $stdin.tty?
|
|
103
|
+
stdin_content = $stdin.read
|
|
104
|
+
parts << stdin_content if stdin_content && !stdin_content.strip.empty?
|
|
159
105
|
end
|
|
160
106
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
o.separator ' ralph "Add tests" --completion-promise "ALL TESTS PASS" --model openai/gpt-5.1'
|
|
166
|
-
o.separator ' ralph --status'
|
|
167
|
-
o.separator ' ralph --add-context "Focus on the auth module first"'
|
|
168
|
-
o.separator ""
|
|
169
|
-
o.separator "How it works:"
|
|
170
|
-
o.separator " 1. Sends your prompt to the selected AI agent"
|
|
171
|
-
o.separator " 2. AI agent works on the task"
|
|
172
|
-
o.separator " 3. Checks output for completion promise"
|
|
173
|
-
o.separator " 4. If not complete, repeats with same prompt"
|
|
174
|
-
o.separator " 5. AI sees its previous work in files"
|
|
175
|
-
o.separator " 6. Continues until promise detected or max iterations"
|
|
176
|
-
o.separator ""
|
|
177
|
-
o.separator "To stop manually: Ctrl+C"
|
|
178
|
-
o.separator "Learn more: https://ghuntley.com/ralph/"
|
|
107
|
+
# Remaining positional arguments are the inline text
|
|
108
|
+
parts << @argv.join(" ") if @argv.any?
|
|
109
|
+
|
|
110
|
+
parts.join("\n\n") if parts.any?
|
|
179
111
|
end
|
|
180
112
|
|
|
181
|
-
|
|
113
|
+
def build_prompt_object
|
|
114
|
+
user_text = read_user_text
|
|
115
|
+
completion = @options[:completion]
|
|
182
116
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if $stdin.tty?
|
|
186
|
-
remaining_args.join(" ").strip
|
|
117
|
+
if @subcommand == "plan"
|
|
118
|
+
build_plan_prompt(user_text, completion)
|
|
187
119
|
else
|
|
188
|
-
|
|
120
|
+
build_build_prompt(user_text, completion)
|
|
189
121
|
end
|
|
190
122
|
end
|
|
191
123
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
124
|
+
def build_plan_prompt(user_text, completion)
|
|
125
|
+
Hash.new.then do |plan_options|
|
|
126
|
+
plan_options[:goal] = user_text if user_text
|
|
127
|
+
plan_options[:all_done] = completion if completion
|
|
128
|
+
Prompt::Plan.new(**plan_options)
|
|
129
|
+
end
|
|
198
130
|
end
|
|
199
131
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if @config.max_iterations > 0 && @config.min_iterations > @config.max_iterations
|
|
207
|
-
abort "Error: --min-iterations (#{@config.min_iterations}) cannot be greater than --max-iterations (#{@config.max_iterations})"
|
|
132
|
+
def build_build_prompt(user_text, completion)
|
|
133
|
+
Hash.new.then do |build_options|
|
|
134
|
+
build_options[:context] = user_text if user_text
|
|
135
|
+
build_options[:all_done] = completion if completion
|
|
136
|
+
Prompt::Build.new(**build_options)
|
|
208
137
|
end
|
|
209
|
-
|
|
210
|
-
state = Storage::State.from_config(@config, prompt: prompt)
|
|
211
|
-
history = Storage::History.new
|
|
212
|
-
|
|
213
|
-
Ralph::Loop.new(@config, state, history, context, tasks).run
|
|
214
138
|
end
|
|
215
|
-
|
|
216
|
-
rescue StandardError => e
|
|
217
|
-
$stderr.puts "Fatal error: #{e}"
|
|
218
|
-
Storage::State.clear
|
|
219
|
-
exit 1
|
|
220
|
-
end
|
|
221
139
|
end
|
|
222
140
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
# Handles all terminal output for the loop -- iteration status, metrics,
|
|
5
|
+
# agent text, and summary information.
|
|
6
|
+
class Display
|
|
7
|
+
SEPARATOR = "-" * 60
|
|
8
|
+
|
|
9
|
+
def initialize(loop_engine)
|
|
10
|
+
@loop_engine = loop_engine
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show_start(prompt)
|
|
14
|
+
puts SEPARATOR
|
|
15
|
+
puts "ralph -- autonomous agentic loop"
|
|
16
|
+
puts SEPARATOR
|
|
17
|
+
puts "Prompt: #{prompt[0, 200]}#{"..." if prompt.length > 200}"
|
|
18
|
+
puts SEPARATOR
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show_iteration_start
|
|
22
|
+
puts
|
|
23
|
+
puts "#{SEPARATOR}"
|
|
24
|
+
puts "Iteration #{@loop_engine.iteration_number} | " \
|
|
25
|
+
"elapsed: #{format_duration(@loop_engine.elapsed_seconds)} | " \
|
|
26
|
+
"context: #{format_tokens(@loop_engine.metrics.current_context)} | " \
|
|
27
|
+
"tokens: #{format_tokens(@loop_engine.metrics.tokens_consumed)}"
|
|
28
|
+
puts SEPARATOR
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def show_event(event)
|
|
32
|
+
if event.is_a?(Events::Text) && event.text
|
|
33
|
+
print event.text
|
|
34
|
+
$stdout.flush
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if event.is_a?(Events::ToolUse)
|
|
38
|
+
puts
|
|
39
|
+
puts " [tool] #{event.tool} (#{event.status})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if event.is_a?(Events::StepFinish)
|
|
43
|
+
puts
|
|
44
|
+
puts " [step] context=#{format_tokens(event.context_size)} " \
|
|
45
|
+
"output=#{format_tokens(event.output_tokens)} " \
|
|
46
|
+
"cache_r=#{format_tokens(event.cache_read)} " \
|
|
47
|
+
"cache_w=#{format_tokens(event.cache_write)}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def show_iteration_end
|
|
52
|
+
puts
|
|
53
|
+
puts " Iteration #{@loop_engine.iteration_number} complete | " \
|
|
54
|
+
"steps: #{@loop_engine.metrics.step_count} | " \
|
|
55
|
+
"iteration tokens: #{format_tokens(@loop_engine.metrics.iteration_tokens)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def show_iteration_cancelled(reason)
|
|
59
|
+
puts
|
|
60
|
+
puts " ** Iteration cancelled: #{reason}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def show_iteration_error(message)
|
|
64
|
+
puts
|
|
65
|
+
puts " ** Iteration error: #{message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def show_termination(reason)
|
|
69
|
+
puts
|
|
70
|
+
puts " ** Loop terminated: #{reason}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def show_summary
|
|
74
|
+
metrics = @loop_engine.metrics
|
|
75
|
+
puts
|
|
76
|
+
puts SEPARATOR
|
|
77
|
+
puts "SUMMARY"
|
|
78
|
+
puts SEPARATOR
|
|
79
|
+
puts " Status: #{@loop_engine.completed ? "COMPLETED" : "TERMINATED"}"
|
|
80
|
+
puts " Iterations: #{@loop_engine.iteration_number}"
|
|
81
|
+
puts " Duration: #{format_duration(@loop_engine.elapsed_seconds)}"
|
|
82
|
+
puts " Steps: #{metrics.step_count}"
|
|
83
|
+
puts " Tokens: #{format_tokens(metrics.tokens_consumed)}"
|
|
84
|
+
puts " Context: #{format_tokens(metrics.current_context)}"
|
|
85
|
+
puts SEPARATOR
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def format_duration(seconds)
|
|
91
|
+
minutes = (seconds / 60).to_i
|
|
92
|
+
remaining_seconds = (seconds % 60).to_i
|
|
93
|
+
if minutes > 0
|
|
94
|
+
"#{minutes}m #{remaining_seconds}s"
|
|
95
|
+
else
|
|
96
|
+
"#{remaining_seconds}s"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def format_tokens(count)
|
|
101
|
+
if count >= 1_000_000
|
|
102
|
+
format("%.1fM", count / 1_000_000.0)
|
|
103
|
+
elsif count >= 1_000
|
|
104
|
+
format("%.1fk", count / 1_000.0)
|
|
105
|
+
else
|
|
106
|
+
count.to_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/ralph/events.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
module Events
|
|
5
|
+
# Base event wrapping the common fields from opencode JSON stream lines
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :type, :timestamp, :session_id, :part
|
|
8
|
+
|
|
9
|
+
def initialize(data)
|
|
10
|
+
@type = data["type"]
|
|
11
|
+
@timestamp = data["timestamp"]
|
|
12
|
+
@session_id = data["sessionID"]
|
|
13
|
+
@part = data["part"] || {}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Emitted when a new LLM step begins
|
|
18
|
+
class StepStart < Base
|
|
19
|
+
def snapshot
|
|
20
|
+
part["snapshot"]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Emitted when the model streams text content
|
|
25
|
+
class Text < Base
|
|
26
|
+
def text
|
|
27
|
+
part["text"]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Emitted when the model invokes a tool
|
|
32
|
+
class ToolUse < Base
|
|
33
|
+
def tool
|
|
34
|
+
part["tool"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call_id
|
|
38
|
+
part["callID"]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def state
|
|
42
|
+
part["state"] || {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def status
|
|
46
|
+
state["status"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def input
|
|
50
|
+
state["input"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def output
|
|
54
|
+
state["output"]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Emitted when a step completes -- carries the token usage data
|
|
59
|
+
class StepFinish < Base
|
|
60
|
+
def reason
|
|
61
|
+
part["reason"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def tokens
|
|
65
|
+
part["tokens"] || {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def input_tokens
|
|
69
|
+
tokens["input"] || 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def output_tokens
|
|
73
|
+
tokens["output"] || 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reasoning_tokens
|
|
77
|
+
tokens["reasoning"] || 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cache
|
|
81
|
+
tokens["cache"] || {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cache_read
|
|
85
|
+
cache["read"] || 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cache_write
|
|
89
|
+
cache["write"] || 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Total context size for this step: input + cache.read + cache.write
|
|
93
|
+
def context_size
|
|
94
|
+
input_tokens + cache_read + cache_write
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
EVENT_TYPES = {
|
|
99
|
+
"step_start" => StepStart,
|
|
100
|
+
"text" => Text,
|
|
101
|
+
"tool_use" => ToolUse,
|
|
102
|
+
"step_finish" => StepFinish
|
|
103
|
+
}.freeze
|
|
104
|
+
|
|
105
|
+
# Parse a single JSON line into the appropriate event object.
|
|
106
|
+
# Returns nil for unknown event types or malformed JSON.
|
|
107
|
+
def self.parse(line)
|
|
108
|
+
data = JSON.parse(line)
|
|
109
|
+
event_class = EVENT_TYPES[data["type"]]
|
|
110
|
+
if event_class
|
|
111
|
+
event_class.new(data)
|
|
112
|
+
end
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|