roast-ai 0.4.0 → 0.4.1

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/CHANGELOG.md +65 -0
  4. data/CLAUDE.md +55 -9
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +69 -3
  8. data/bin/console +1 -0
  9. data/docs/AGENT_STEPS.md +33 -9
  10. data/docs/VALIDATION.md +178 -0
  11. data/examples/agent_continue/add_documentation/prompt.md +5 -0
  12. data/examples/agent_continue/add_error_handling/prompt.md +5 -0
  13. data/examples/agent_continue/analyze_codebase/prompt.md +7 -0
  14. data/examples/agent_continue/combined_workflow.yml +24 -0
  15. data/examples/agent_continue/continue_adding_features/prompt.md +4 -0
  16. data/examples/agent_continue/create_integration_tests/prompt.md +3 -0
  17. data/examples/agent_continue/document_with_context/prompt.md +5 -0
  18. data/examples/agent_continue/explore_api/prompt.md +6 -0
  19. data/examples/agent_continue/implement_client/prompt.md +6 -0
  20. data/examples/agent_continue/inline_workflow.yml +20 -0
  21. data/examples/agent_continue/refactor_code/prompt.md +2 -0
  22. data/examples/agent_continue/verify_changes/prompt.md +6 -0
  23. data/examples/agent_continue/workflow.yml +27 -0
  24. data/examples/agent_workflow/workflow.png +0 -0
  25. data/examples/api_workflow/workflow.png +0 -0
  26. data/examples/apply_diff_demo/README.md +58 -0
  27. data/examples/apply_diff_demo/apply_simple_change/prompt.md +13 -0
  28. data/examples/apply_diff_demo/create_sample_file/prompt.md +11 -0
  29. data/examples/apply_diff_demo/workflow.yml +24 -0
  30. data/examples/available_tools_demo/workflow.png +0 -0
  31. data/examples/bash_prototyping/api_testing.png +0 -0
  32. data/examples/bash_prototyping/system_analysis.png +0 -0
  33. data/examples/case_when/workflow.png +0 -0
  34. data/examples/cmd/basic_workflow.png +0 -0
  35. data/examples/cmd/dev_workflow.png +0 -0
  36. data/examples/cmd/explorer_workflow.png +0 -0
  37. data/examples/conditional/simple_workflow.png +0 -0
  38. data/examples/conditional/workflow.png +0 -0
  39. data/examples/context_management_demo/README.md +43 -0
  40. data/examples/context_management_demo/workflow.yml +42 -0
  41. data/examples/direct_coerce_syntax/workflow.png +0 -0
  42. data/examples/dot_notation/workflow.png +0 -0
  43. data/examples/exit_on_error/workflow.png +0 -0
  44. data/examples/grading/workflow.png +0 -0
  45. data/examples/interpolation/workflow.png +0 -0
  46. data/examples/interpolation/workflow.yml +1 -1
  47. data/examples/iteration/workflow.png +0 -0
  48. data/examples/json_handling/workflow.png +0 -0
  49. data/examples/mcp/database_workflow.png +0 -0
  50. data/examples/mcp/env_demo/workflow.png +0 -0
  51. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  52. data/examples/mcp/github_workflow.png +0 -0
  53. data/examples/mcp/multi_mcp_workflow.png +0 -0
  54. data/examples/mcp/workflow.png +0 -0
  55. data/examples/no_model_fallback/README.md +17 -0
  56. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  57. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  58. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  59. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  60. data/examples/no_model_fallback/sample.rb +42 -0
  61. data/examples/no_model_fallback/workflow.yml +19 -0
  62. data/examples/openrouter_example/workflow.png +0 -0
  63. data/examples/pre_post_processing/workflow.png +0 -0
  64. data/examples/rspec_to_minitest/workflow.png +0 -0
  65. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  66. data/examples/shared_config/shared.png +0 -0
  67. data/examples/single_target_prepost/workflow.png +0 -0
  68. data/examples/smart_coercion_defaults/workflow.png +0 -0
  69. data/examples/step_configuration/workflow.png +0 -0
  70. data/examples/swarm_example.yml +25 -0
  71. data/examples/tool_config_example/workflow.png +0 -0
  72. data/examples/user_input/funny_name/workflow.png +0 -0
  73. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  74. data/examples/user_input/survey_workflow.png +0 -0
  75. data/examples/user_input/workflow.png +0 -0
  76. data/examples/workflow_generator/workflow.png +0 -0
  77. data/lib/roast/helpers/timeout_handler.rb +91 -0
  78. data/lib/roast/services/context_threshold_checker.rb +42 -0
  79. data/lib/roast/services/token_counting_service.rb +44 -0
  80. data/lib/roast/tools/apply_diff.rb +128 -0
  81. data/lib/roast/tools/bash.rb +15 -9
  82. data/lib/roast/tools/cmd.rb +32 -12
  83. data/lib/roast/tools/coding_agent.rb +64 -9
  84. data/lib/roast/tools/context_summarizer.rb +108 -0
  85. data/lib/roast/tools/swarm.rb +124 -0
  86. data/lib/roast/version.rb +1 -1
  87. data/lib/roast/workflow/agent_step.rb +9 -2
  88. data/lib/roast/workflow/base_iteration_step.rb +3 -2
  89. data/lib/roast/workflow/base_workflow.rb +41 -2
  90. data/lib/roast/workflow/configuration.rb +2 -1
  91. data/lib/roast/workflow/configuration_loader.rb +63 -1
  92. data/lib/roast/workflow/context_manager.rb +89 -0
  93. data/lib/roast/workflow/each_step.rb +1 -1
  94. data/lib/roast/workflow/output_handler.rb +1 -1
  95. data/lib/roast/workflow/repeat_step.rb +1 -1
  96. data/lib/roast/workflow/replay_handler.rb +1 -1
  97. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  98. data/lib/roast/workflow/state_manager.rb +2 -2
  99. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  100. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  101. data/lib/roast/workflow/step_executor_coordinator.rb +19 -18
  102. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  103. data/lib/roast/workflow/step_loader.rb +1 -1
  104. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  105. data/lib/roast/workflow/validation_command.rb +197 -0
  106. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  107. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  108. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  109. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  110. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  111. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  112. data/lib/roast/workflow/workflow_executor.rb +11 -4
  113. data/lib/roast/workflow/workflow_runner.rb +6 -0
  114. data/lib/roast/workflow_diagram_generator.rb +298 -0
  115. data/lib/roast.rb +157 -0
  116. data/roast.gemspec +2 -1
  117. data/schema/workflow.json +77 -1
  118. metadata +101 -1
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Roast
6
+ module Workflow
7
+ # SQLite-based implementation of StateRepository
8
+ # Provides structured, queryable session storage with better performance
9
+ class SqliteStateRepository < StateRepository
10
+ DEFAULT_DB_PATH = File.expand_path("~/.roast/sessions.db")
11
+
12
+ def initialize(db_path: nil, session_manager: SessionManager.new)
13
+ super()
14
+
15
+ # Lazy load sqlite3 only when actually using SQLite storage
16
+ begin
17
+ require "sqlite3"
18
+ rescue LoadError
19
+ raise LoadError, "SQLite storage requires the 'sqlite3' gem. Please add it to your Gemfile or install it: gem install sqlite3"
20
+ end
21
+
22
+ @db_path = db_path || ENV["ROAST_SESSIONS_DB"] || DEFAULT_DB_PATH
23
+ @session_manager = session_manager
24
+ ensure_database
25
+ end
26
+
27
+ def save_state(workflow, step_name, state_data)
28
+ workflow.session_timestamp ||= @session_manager.create_new_session(workflow.object_id)
29
+
30
+ session_id = ensure_session(workflow)
31
+
32
+ @db.execute(<<~SQL, session_id, state_data[:order], step_name, state_data.to_json)
33
+ INSERT INTO session_states (session_id, step_index, step_name, state_data)
34
+ VALUES (?, ?, ?, ?)
35
+ SQL
36
+
37
+ # Update session's current step
38
+ @db.execute(<<~SQL, state_data[:order], session_id)
39
+ UPDATE sessions#{" "}
40
+ SET current_step_index = ?, updated_at = CURRENT_TIMESTAMP
41
+ WHERE id = ?
42
+ SQL
43
+ rescue => e
44
+ $stderr.puts "Failed to save state for step #{step_name}: #{e.message}"
45
+ end
46
+
47
+ def load_state_before_step(workflow, step_name, timestamp: nil)
48
+ session_id = find_session_id(workflow, timestamp)
49
+ return false unless session_id
50
+
51
+ # Find the state before the target step
52
+ result = @db.execute(<<~SQL, session_id, step_name)
53
+ SELECT state_data, step_name
54
+ FROM session_states
55
+ WHERE session_id = ?
56
+ AND step_index < (
57
+ SELECT MIN(step_index)#{" "}
58
+ FROM session_states#{" "}
59
+ WHERE session_id = ? AND step_name = ?
60
+ )
61
+ ORDER BY step_index DESC
62
+ LIMIT 1
63
+ SQL
64
+
65
+ if result.empty?
66
+ # Try to find the latest state if target step doesn't exist
67
+ result = @db.execute(<<~SQL, session_id)
68
+ SELECT state_data, step_name
69
+ FROM session_states
70
+ WHERE session_id = ?
71
+ ORDER BY step_index DESC
72
+ LIMIT 1
73
+ SQL
74
+
75
+ if result.empty?
76
+ $stderr.puts "No state found for session"
77
+ return false
78
+ end
79
+ end
80
+
81
+ state_data = JSON.parse(result[0][0], symbolize_names: true)
82
+ loaded_step = result[0][1]
83
+ $stderr.puts "Found state from step: #{loaded_step} (will replay from here to #{step_name})"
84
+
85
+ # If no timestamp provided and workflow has no session, create new session and copy states
86
+ if !timestamp && workflow.session_timestamp.nil?
87
+ copy_states_to_new_session(workflow, session_id, step_name)
88
+ end
89
+
90
+ state_data
91
+ end
92
+
93
+ def save_final_output(workflow, output_content)
94
+ return if output_content.empty?
95
+
96
+ session_id = ensure_session(workflow)
97
+
98
+ @db.execute(<<~SQL, output_content, session_id)
99
+ UPDATE sessions#{" "}
100
+ SET final_output = ?, status = 'completed', updated_at = CURRENT_TIMESTAMP
101
+ WHERE id = ?
102
+ SQL
103
+
104
+ session_id
105
+ rescue => e
106
+ $stderr.puts "Failed to save final output: #{e.message}"
107
+ nil
108
+ end
109
+
110
+ # Additional query methods for the new capabilities
111
+
112
+ def list_sessions(status: nil, workflow_name: nil, older_than: nil, limit: 100)
113
+ conditions = []
114
+ params = []
115
+
116
+ if status
117
+ conditions << "status = ?"
118
+ params << status
119
+ end
120
+
121
+ if workflow_name
122
+ conditions << "workflow_name = ?"
123
+ params << workflow_name
124
+ end
125
+
126
+ if older_than
127
+ conditions << "created_at < datetime('now', ?)"
128
+ params << "-#{older_than}"
129
+ end
130
+
131
+ where_clause = conditions.empty? ? "" : "WHERE #{conditions.join(" AND ")}"
132
+
133
+ @db.execute(<<~SQL, *params)
134
+ SELECT id, workflow_name, workflow_path, status, current_step_index,#{" "}
135
+ created_at, updated_at
136
+ FROM sessions
137
+ #{where_clause}
138
+ ORDER BY created_at DESC
139
+ LIMIT #{limit}
140
+ SQL
141
+ end
142
+
143
+ def get_session_details(session_id)
144
+ session = @db.execute(<<~SQL, session_id).first
145
+ SELECT * FROM sessions WHERE id = ?
146
+ SQL
147
+
148
+ return unless session
149
+
150
+ states = @db.execute(<<~SQL, session_id)
151
+ SELECT step_index, step_name, created_at
152
+ FROM session_states
153
+ WHERE session_id = ?
154
+ ORDER BY step_index
155
+ SQL
156
+
157
+ events = @db.execute(<<~SQL, session_id)
158
+ SELECT event_name, event_data, received_at
159
+ FROM session_events
160
+ WHERE session_id = ?
161
+ ORDER BY received_at
162
+ SQL
163
+
164
+ {
165
+ session: session,
166
+ states: states,
167
+ events: events,
168
+ }
169
+ end
170
+
171
+ def cleanup_old_sessions(older_than)
172
+ count = @db.changes
173
+ @db.execute(<<~SQL, "-#{older_than}")
174
+ DELETE FROM sessions
175
+ WHERE created_at < datetime('now', ?)
176
+ SQL
177
+ @db.changes - count
178
+ end
179
+
180
+ def add_event(workflow_path, session_id, event_name, event_data = nil)
181
+ # Find the session if session_id not provided
182
+ unless session_id
183
+ workflow_name = File.basename(File.dirname(workflow_path))
184
+ result = @db.execute(<<~SQL, workflow_name, "waiting")
185
+ SELECT id FROM sessions
186
+ WHERE workflow_name = ? AND status = ?
187
+ ORDER BY created_at DESC
188
+ LIMIT 1
189
+ SQL
190
+
191
+ raise "No waiting session found for workflow: #{workflow_name}" if result.empty?
192
+
193
+ session_id = result[0][0]
194
+ end
195
+
196
+ # Add the event
197
+ @db.execute(<<~SQL, session_id, event_name, event_data&.to_json)
198
+ INSERT INTO session_events (session_id, event_name, event_data)
199
+ VALUES (?, ?, ?)
200
+ SQL
201
+
202
+ # Update session status
203
+ @db.execute(<<~SQL, session_id)
204
+ UPDATE sessions#{" "}
205
+ SET status = 'running', updated_at = CURRENT_TIMESTAMP
206
+ WHERE id = ?
207
+ SQL
208
+
209
+ session_id
210
+ end
211
+
212
+ private
213
+
214
+ def ensure_database
215
+ FileUtils.mkdir_p(File.dirname(@db_path))
216
+ @db = SQLite3::Database.new(@db_path)
217
+ @db.execute("PRAGMA foreign_keys = ON")
218
+ create_schema
219
+ end
220
+
221
+ def create_schema
222
+ @db.execute_batch(<<~SQL)
223
+ CREATE TABLE IF NOT EXISTS sessions (
224
+ id TEXT PRIMARY KEY,
225
+ workflow_name TEXT NOT NULL,
226
+ workflow_path TEXT NOT NULL,
227
+ status TEXT NOT NULL DEFAULT 'running',
228
+ current_step_index INTEGER,
229
+ final_output TEXT,
230
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
231
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
232
+ );
233
+
234
+ CREATE TABLE IF NOT EXISTS session_states (
235
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
236
+ session_id TEXT NOT NULL,
237
+ step_index INTEGER NOT NULL,
238
+ step_name TEXT NOT NULL,
239
+ state_data TEXT NOT NULL,
240
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
241
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
242
+ );
243
+
244
+ CREATE TABLE IF NOT EXISTS session_events (
245
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
246
+ session_id TEXT NOT NULL,
247
+ event_name TEXT NOT NULL,
248
+ event_data TEXT,
249
+ received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
250
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
251
+ );
252
+
253
+ CREATE TABLE IF NOT EXISTS session_variables (
254
+ session_id TEXT NOT NULL,
255
+ key TEXT NOT NULL,
256
+ value TEXT NOT NULL,
257
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
258
+ PRIMARY KEY (session_id, key),
259
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
260
+ );
261
+
262
+ -- Indexes for common queries
263
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
264
+ CREATE INDEX IF NOT EXISTS idx_sessions_workflow_name ON sessions(workflow_name);
265
+ CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at);
266
+ CREATE INDEX IF NOT EXISTS idx_session_states_session_id ON session_states(session_id);
267
+ CREATE INDEX IF NOT EXISTS idx_session_events_session_id ON session_events(session_id);
268
+ SQL
269
+ end
270
+
271
+ def ensure_session(workflow)
272
+ session_id = generate_session_id(workflow)
273
+
274
+ # Check if session exists
275
+ existing = @db.execute("SELECT id FROM sessions WHERE id = ?", session_id).first
276
+ return session_id if existing
277
+
278
+ # Create new session
279
+ workflow_name = workflow.session_name || "unnamed"
280
+ workflow_path = workflow.file || "notarget"
281
+
282
+ @db.execute(<<~SQL, session_id, workflow_name, workflow_path)
283
+ INSERT INTO sessions (id, workflow_name, workflow_path)
284
+ VALUES (?, ?, ?)
285
+ SQL
286
+
287
+ session_id
288
+ end
289
+
290
+ def find_session_id(workflow, timestamp)
291
+ if timestamp
292
+ # Find by exact timestamp
293
+ generate_session_id(workflow, timestamp)
294
+ else
295
+ # Find latest session for this workflow
296
+ workflow_name = workflow.session_name || "unnamed"
297
+ workflow_path = workflow.file || "notarget"
298
+
299
+ result = @db.execute(<<~SQL, workflow_name, workflow_path)
300
+ SELECT id FROM sessions
301
+ WHERE workflow_name = ? AND workflow_path = ?
302
+ ORDER BY created_at DESC
303
+ LIMIT 1
304
+ SQL
305
+
306
+ result.empty? ? nil : result[0][0]
307
+ end
308
+ end
309
+
310
+ def generate_session_id(workflow, timestamp = nil)
311
+ timestamp ||= workflow.session_timestamp || @session_manager.create_new_session(workflow.object_id)
312
+ workflow_name = workflow.session_name || "unnamed"
313
+ workflow_path = workflow.file || "notarget"
314
+
315
+ # Generate a unique session ID based on workflow info and timestamp
316
+ file_hash = Digest::MD5.hexdigest(workflow_path)[0..7]
317
+ "#{workflow_name.parameterize.underscore}_#{file_hash}_#{timestamp}"
318
+ end
319
+
320
+ def copy_states_to_new_session(workflow, source_session_id, target_step_name)
321
+ # Create new session
322
+ new_timestamp = @session_manager.create_new_session(workflow.object_id)
323
+ workflow.session_timestamp = new_timestamp
324
+ new_session_id = ensure_session(workflow)
325
+
326
+ # Copy states up to the target step
327
+ @db.execute(<<~SQL, new_session_id, source_session_id, target_step_name, source_session_id)
328
+ INSERT INTO session_states (session_id, step_index, step_name, state_data)
329
+ SELECT ?, step_index, step_name, state_data
330
+ FROM session_states
331
+ WHERE session_id = ?
332
+ AND step_index < COALESCE(
333
+ (SELECT MIN(step_index) FROM session_states WHERE session_id = ? AND step_name = ?),
334
+ 999999
335
+ )
336
+ SQL
337
+
338
+ true
339
+ end
340
+ end
341
+ end
342
+ end
@@ -6,10 +6,10 @@ module Roast
6
6
  class StateManager
7
7
  attr_reader :workflow, :logger
8
8
 
9
- def initialize(workflow, logger: nil)
9
+ def initialize(workflow, logger: nil, state_repository: nil, storage_type: nil)
10
10
  @workflow = workflow
11
11
  @logger = logger
12
- @state_repository = FileStateRepository.new
12
+ @state_repository = state_repository || StateRepositoryFactory.create(storage_type)
13
13
  end
14
14
 
15
15
  # Save the current state after a step execution
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Factory for creating the appropriate StateRepository implementation
6
+ class StateRepositoryFactory
7
+ class << self
8
+ def create(type = nil)
9
+ type ||= default_type
10
+
11
+ case type.to_s
12
+ when "sqlite"
13
+ # Lazy load the SQLite repository only when needed
14
+ Roast::Workflow::SqliteStateRepository.new
15
+ when "file", "filesystem"
16
+ Roast::Workflow::FileStateRepository.new
17
+ else
18
+ raise ArgumentError, "Unknown state repository type: #{type}. Valid types are: sqlite, file"
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def default_type
25
+ # Check environment variable first (for backwards compatibility)
26
+ if ENV["ROAST_STATE_STORAGE"]
27
+ ENV["ROAST_STATE_STORAGE"].downcase
28
+ else
29
+ # Default to SQLite for better functionality
30
+ "sqlite"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Reports step completion with token consumption information
6
+ class StepCompletionReporter
7
+ def initialize(output: $stderr)
8
+ @output = output
9
+ end
10
+
11
+ def report(step_name, tokens_consumed, total_tokens)
12
+ formatted_consumed = number_with_delimiter(tokens_consumed)
13
+ formatted_total = number_with_delimiter(total_tokens)
14
+
15
+ @output.puts "✓ Complete: #{step_name} (consumed #{formatted_consumed} tokens, total #{formatted_total})"
16
+ @output.puts
17
+ @output.puts
18
+ end
19
+
20
+ private
21
+
22
+ def number_with_delimiter(number)
23
+ number.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
24
+ end
25
+ end
26
+ end
27
+ end
@@ -28,11 +28,11 @@ module Roast
28
28
  is_last_step = (index == workflow_steps.length - 1)
29
29
  case step
30
30
  when Hash
31
- execute(step, is_last_step: is_last_step)
31
+ execute(step, is_last_step:)
32
32
  when Array
33
- execute(step, is_last_step: is_last_step)
33
+ execute(step, is_last_step:)
34
34
  when String
35
- execute(step, is_last_step: is_last_step)
35
+ execute(step, is_last_step:)
36
36
  # Handle pause after string steps
37
37
  if @context.workflow.pause_step_name == step
38
38
  Kernel.binding.irb # rubocop:disable Lint/Debugger
@@ -62,17 +62,17 @@ module Roast
62
62
  when StepTypeResolver::AGENT_STEP
63
63
  execute_agent_step(step, options)
64
64
  when StepTypeResolver::GLOB_STEP
65
- execute_glob_step(step)
65
+ execute_glob_step(step, options)
66
66
  when StepTypeResolver::ITERATION_STEP
67
- execute_iteration_step(step)
67
+ execute_iteration_step(step, options)
68
68
  when StepTypeResolver::CONDITIONAL_STEP
69
- execute_conditional_step(step)
69
+ execute_conditional_step(step, options)
70
70
  when StepTypeResolver::CASE_STEP
71
- execute_case_step(step)
71
+ execute_case_step(step, options)
72
72
  when StepTypeResolver::INPUT_STEP
73
- execute_input_step(step)
73
+ execute_input_step(step, options)
74
74
  when StepTypeResolver::HASH_STEP
75
- execute_hash_step(step)
75
+ execute_hash_step(step, options)
76
76
  when StepTypeResolver::PARALLEL_STEP
77
77
  # Use factory for parallel steps
78
78
  executor = StepExecutorFactory.for(step, workflow_executor)
@@ -189,11 +189,11 @@ module Roast
189
189
  step_orchestrator.execute_step(step_name, exit_on_error:, step_key: options[:step_key], agent_type: :coding_agent)
190
190
  end
191
191
 
192
- def execute_glob_step(step)
192
+ def execute_glob_step(step, options = {})
193
193
  Dir.glob(step).join("\n")
194
194
  end
195
195
 
196
- def execute_iteration_step(step)
196
+ def execute_iteration_step(step, options = {})
197
197
  name = step.keys.first
198
198
  command = step[name]
199
199
 
@@ -206,19 +206,19 @@ module Roast
206
206
  end
207
207
  end
208
208
 
209
- def execute_conditional_step(step)
209
+ def execute_conditional_step(step, options = {})
210
210
  conditional_executor.execute_conditional(step)
211
211
  end
212
212
 
213
- def execute_case_step(step)
213
+ def execute_case_step(step, options = {})
214
214
  case_executor.execute_case(step)
215
215
  end
216
216
 
217
- def execute_input_step(step)
217
+ def execute_input_step(step, options = {})
218
218
  input_executor.execute_input(step["input"])
219
219
  end
220
220
 
221
- def execute_hash_step(step)
221
+ def execute_hash_step(step, options = {})
222
222
  name, command = step.to_a.flatten
223
223
  interpolated_name = interpolator.interpolate(name)
224
224
 
@@ -230,7 +230,8 @@ module Roast
230
230
 
231
231
  # Execute the command directly using the appropriate executor
232
232
  # Pass the original key name for configuration lookup
233
- result = execute(interpolated_command, { exit_on_error: exit_on_error, step_key: interpolated_name })
233
+ # Merge options to preserve is_last_step
234
+ result = execute(interpolated_command, { exit_on_error:, step_key: interpolated_name }.merge(options))
234
235
  context.workflow.output[interpolated_name] = result
235
236
  result
236
237
  end
@@ -247,10 +248,10 @@ module Roast
247
248
  if StepTypeResolver.command_step?(interpolated_step)
248
249
  # Command step - execute directly, preserving any passed options
249
250
  exit_on_error = options.fetch(:exit_on_error, true)
250
- execute_command_step(interpolated_step, { exit_on_error: exit_on_error })
251
+ execute_command_step(interpolated_step, { exit_on_error: })
251
252
  else
252
253
  exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
253
- execute_standard_step(interpolated_step, options.merge(exit_on_error: exit_on_error))
254
+ execute_standard_step(interpolated_step, options.merge(exit_on_error:))
254
255
  end
255
256
  end
256
257
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Decorator that adds token consumption reporting to step execution
6
+ class StepExecutorWithReporting
7
+ def initialize(base_executor, context, output: $stderr)
8
+ @base_executor = base_executor
9
+ @context = context
10
+ @reporter = StepCompletionReporter.new(output: output)
11
+ @name_extractor = StepNameExtractor.new
12
+ end
13
+
14
+ def execute(step, options = {})
15
+ # Track tokens before execution
16
+ tokens_before = @context.workflow.context_manager&.total_tokens || 0
17
+
18
+ # Execute the step
19
+ result = @base_executor.execute(step, options)
20
+
21
+ # Report token consumption after successful execution
22
+ tokens_after = @context.workflow.context_manager&.total_tokens || 0
23
+ tokens_consumed = tokens_after - tokens_before
24
+
25
+ step_type = StepTypeResolver.resolve(step, @context)
26
+ step_name = @name_extractor.extract(step, step_type)
27
+ @reporter.report(step_name, tokens_consumed, tokens_after)
28
+
29
+ result
30
+ end
31
+
32
+ # Override execute_steps to ensure reporting happens for each step
33
+ def execute_steps(workflow_steps)
34
+ workflow_steps.each_with_index do |step, index|
35
+ is_last_step = (index == workflow_steps.length - 1)
36
+ case step
37
+ when Hash
38
+ execute(step, is_last_step:)
39
+ when Array
40
+ execute(step, is_last_step:)
41
+ when String
42
+ execute(step, is_last_step:)
43
+ # Handle pause after string steps
44
+ if @context.workflow.pause_step_name == step
45
+ Kernel.binding.irb # rubocop:disable Lint/Debugger
46
+ end
47
+ else
48
+ # For other types, delegate to base executor
49
+ execute(step, is_last_step:)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Delegate all other methods to the base executor
55
+ def method_missing(method, *args, **kwargs, &block)
56
+ if @base_executor.respond_to?(method)
57
+ @base_executor.send(method, *args, **kwargs, &block)
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ def respond_to_missing?(method, include_private = false)
64
+ @base_executor.respond_to?(method, include_private) || super
65
+ end
66
+ end
67
+ end
68
+ end
@@ -4,7 +4,7 @@ module Roast
4
4
  module Workflow
5
5
  # Handles loading and instantiation of workflow steps
6
6
  class StepLoader
7
- DEFAULT_MODEL = "openai/gpt-4o-mini"
7
+ DEFAULT_MODEL = "gpt-4o-mini"
8
8
 
9
9
  # Custom exception classes
10
10
  class StepLoaderError < StandardError
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Extracts human-readable names from various step types
6
+ class StepNameExtractor
7
+ def extract(step, step_type)
8
+ case step_type
9
+ when StepTypeResolver::COMMAND_STEP
10
+ extract_command_name(step)
11
+ when StepTypeResolver::HASH_STEP
12
+ extract_hash_step_name(step)
13
+ when StepTypeResolver::ITERATION_STEP
14
+ extract_iteration_step_name(step)
15
+ when StepTypeResolver::CONDITIONAL_STEP
16
+ extract_conditional_step_name(step)
17
+ when StepTypeResolver::CASE_STEP
18
+ "case"
19
+ when StepTypeResolver::INPUT_STEP
20
+ "input"
21
+ when StepTypeResolver::AGENT_STEP
22
+ StepTypeResolver.extract_name(step)
23
+ when StepTypeResolver::STRING_STEP
24
+ step.to_s
25
+ else
26
+ step.to_s
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def extract_command_name(step)
33
+ cmd = step.to_s.strip
34
+ cmd.length > 20 ? "#{cmd[0..19]}..." : cmd
35
+ end
36
+
37
+ def extract_hash_step_name(step)
38
+ key, value = step.to_a.first
39
+
40
+ # Check if this looks like an inline prompt (key is similar to sanitized value)
41
+ if value.is_a?(String)
42
+ # Get first non-empty line
43
+ first_line = value.lines.map(&:strip).find { |line| !line.empty? } || ""
44
+
45
+ # If key looks like it was auto-generated from the content, use truncated content
46
+ sanitized = first_line.downcase.gsub(/[^a-z0-9_]/, "_").squeeze("_").gsub(/^_|_$/, "")
47
+ if key.to_s == sanitized || key.to_s.start_with?(sanitized[0..15])
48
+ # This is likely an inline prompt
49
+ first_line.length > 20 ? "#{first_line[0..19]}..." : first_line
50
+ else
51
+ # This is a labeled step
52
+ key.to_s
53
+ end
54
+ else
55
+ key.to_s
56
+ end
57
+ end
58
+
59
+ def extract_iteration_step_name(step)
60
+ if step.key?("each")
61
+ items = step["each"]
62
+ count = items.respond_to?(:size) ? items.size : "?"
63
+ "each (#{count} items)"
64
+ elsif step.key?("repeat")
65
+ config = step["repeat"]
66
+ times = config.is_a?(Hash) ? config["times"] || "?" : config
67
+ "repeat (#{times} times)"
68
+ else
69
+ "iteration"
70
+ end
71
+ end
72
+
73
+ def extract_conditional_step_name(step)
74
+ if step.key?("if")
75
+ "if"
76
+ elsif step.key?("unless")
77
+ "unless"
78
+ else
79
+ "conditional"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end