roast-ai 0.4.2 → 0.4.3

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.
@@ -4,6 +4,7 @@ module Roast
4
4
  module Tools
5
5
  module CodingAgent
6
6
  extend self
7
+ include Roast::Helpers::MetadataAccess
7
8
 
8
9
  class CodingAgentError < StandardError; end
9
10
 
@@ -11,9 +12,10 @@ module Roast
11
12
  private_constant :CONFIG_CODING_AGENT_COMMAND
12
13
 
13
14
  @configured_command = nil
15
+ @configured_options = {}
14
16
 
15
17
  class << self
16
- attr_accessor :configured_command
18
+ attr_accessor :configured_command, :configured_options
17
19
 
18
20
  def included(base)
19
21
  base.class_eval do
@@ -23,11 +25,13 @@ module Roast
23
25
  prompt: { type: "string", description: "The prompt to send to Claude Code" },
24
26
  include_context_summary: { type: "boolean", description: "Whether to set a summary of the current workflow context as system directive (default: false)", required: false },
25
27
  continue: { type: "boolean", description: "Whether to continue where the previous coding agent left off or start with a fresh context (default: false, start fresh)", required: false },
28
+ retries: { type: "integer", description: "Number of times to retry the coding agent invocation if it terminates with an error (default: 0, no retry)", required: false },
26
29
  ) do |params|
27
30
  Roast::Tools::CodingAgent.call(
28
31
  params[:prompt],
29
32
  include_context_summary: params[:include_context_summary].presence || false,
30
33
  continue: params[:continue].presence || false,
34
+ retries: params[:retries],
31
35
  )
32
36
  end
33
37
  end
@@ -36,14 +40,26 @@ module Roast
36
40
  # Called after configuration is loaded
37
41
  def post_configuration_setup(base, config = {})
38
42
  self.configured_command = config[CONFIG_CODING_AGENT_COMMAND]
43
+ # Store any other configuration options (like model)
44
+ self.configured_options = config.except(CONFIG_CODING_AGENT_COMMAND)
39
45
  end
40
46
  end
41
47
 
42
- def call(prompt, include_context_summary: false, continue: false)
43
- Roast::Helpers::Logger.info("🤖 Running CodingAgent\n")
44
- run_claude_code(prompt, include_context_summary:, continue:)
48
+ def call(prompt, include_context_summary: false, continue: false, retries: nil)
49
+ # Use configured retries as default, fall back to 0 if not configured
50
+ retries ||= CodingAgent.configured_options[:retries] || CodingAgent.configured_options["retries"] || 0
51
+ (retries + 1).times do |iteration|
52
+ Roast::Helpers::Logger.info("🤖 Running CodingAgent#{iteration > 0 ? ", attempt #{iteration + 1} of #{retries + 1}" : ""}\n")
53
+ return run_claude_code(prompt, include_context_summary:, continue:)
54
+ rescue CodingAgentError => e
55
+ raise e if iteration >= retries
56
+
57
+ Roast::Helpers::Logger.warn("🤖 Retrying after error running CodingAgent: #{e.message}")
58
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
59
+ end
60
+ Roast::Helpers::Logger.error("🤖 CodingAgent did not complete successfully after multiple retries")
45
61
  rescue StandardError => e
46
- "Error running CodingAgent: #{e.message}".tap do |error_message|
62
+ "🤖 Error running CodingAgent: #{e.message}".tap do |error_message|
47
63
  Roast::Helpers::Logger.error(error_message + "\n")
48
64
  Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
49
65
  end
@@ -68,9 +84,19 @@ module Roast
68
84
  temp_file.write(final_prompt)
69
85
  temp_file.close
70
86
 
71
- # Build the command with continue option if specified
87
+ # Check for session ID if continue is requested
88
+ # Resuming from a specific session id is more resilient than simply continuing if there are
89
+ # parallel invocations of claude being run in the same working directory.
90
+ session_id = nil
91
+ if continue
92
+ session_id = step_metadata["coding_agent_session_id"]
93
+ end
94
+
95
+ # Build the command with continue option (may become resume if session_id exists)
72
96
  base_command = claude_code_command
73
- command_to_run = build_command(base_command, continue:)
97
+ command_to_run = build_command(base_command, continue:, session_id:)
98
+
99
+ Roast::Helpers::Logger.debug(command_to_run)
74
100
 
75
101
  # Run Claude Code CLI using the temp file as input with streaming output
76
102
  expect_json_output = command_to_run.include?("--output-format stream-json") ||
@@ -85,19 +111,22 @@ module Roast
85
111
  json = parse_json(line)
86
112
  next unless json
87
113
 
114
+ handle_session_info(json)
88
115
  handle_intermediate_message(json)
89
- result += handle_result(json) || ""
116
+ handled_result = handle_result(json)
117
+ result += handled_result if handled_result
90
118
  end
91
119
  else
92
120
  result = stdout.read
121
+ # Clear any stale session ID we might have when not using JSON formatting
122
+ set_current_step_metadata("coding_agent_session_id", nil)
93
123
  end
94
124
 
95
125
  status = wait_thread.value
96
126
  if status.success?
97
127
  return result
98
128
  else
99
- error_output = stderr.read
100
- return "Error running CodingAgent: #{error_output}"
129
+ raise CodingAgentError, stderr.read
101
130
  end
102
131
  end
103
132
  ensure
@@ -127,13 +156,18 @@ module Roast
127
156
  def handle_result(json)
128
157
  if json["type"] == "result"
129
158
  # NOTE: the format of an error response is { "subtype": "success", "is_error": true }
130
- if json["is_error"]
131
- raise CodingAgentError, json["result"]
132
- elsif json["subtype"] == "success"
133
- json["result"]
134
- else
135
- raise CodingAgentError, "CodingAgent did not complete successfully: #{line}"
136
- end
159
+ is_error = json["is_error"] || false
160
+ success = !is_error && json["subtype"] == "success"
161
+ raise CodingAgentError, json.inspect unless success
162
+
163
+ json["result"]
164
+ end
165
+ end
166
+
167
+ def handle_session_info(json)
168
+ session_id = json["session_id"]
169
+ if session_id
170
+ set_current_step_metadata("coding_agent_session_id", session_id)
137
171
  end
138
172
  end
139
173
 
@@ -150,17 +184,55 @@ module Roast
150
184
  CodingAgent.configured_command || ENV["CLAUDE_CODE_COMMAND"] || "claude -p --verbose --output-format stream-json --dangerously-skip-permissions"
151
185
  end
152
186
 
153
- def build_command(base_command, continue:)
154
- return base_command unless continue
187
+ def build_command(base_command, continue:, session_id: nil)
188
+ command = base_command.dup
155
189
 
156
- # Add --continue flag to the command
157
- # If the command already has flags, insert --continue after 'claude'
158
- if base_command.start_with?("claude ")
159
- base_command.sub("claude ", "claude --continue ")
160
- else
161
- # Fallback for non-standard commands
162
- "#{base_command} --continue"
190
+ # Add configured options (like --model), excluding retries which is handled internally
191
+ options_for_command = CodingAgent.configured_options.except("retries", :retries)
192
+ if options_for_command.any?
193
+ options_str = build_options_string(options_for_command)
194
+ command = if command.start_with?("claude ")
195
+ command.sub("claude ", "claude #{options_str} ")
196
+ else
197
+ # For non-standard commands, append at the end
198
+ "#{command} #{options_str}"
199
+ end
163
200
  end
201
+
202
+ # Add --resume or --continue flag based on continue option and session_id value
203
+ if continue
204
+ command = if session_id
205
+ # Use --resume with session ID if available
206
+ if command.start_with?("claude ")
207
+ command.sub("claude ", "claude --resume #{session_id} ")
208
+ else
209
+ # Fallback for non-standard commands
210
+ "#{command} --resume #{session_id}"
211
+ end
212
+ elsif command.start_with?("claude ")
213
+ # Use --continue if no session ID
214
+ command.sub("claude ", "claude --continue ")
215
+ else
216
+ # Fallback for non-standard commands
217
+ "#{command} --continue"
218
+ end
219
+ end
220
+
221
+ command
222
+ end
223
+
224
+ def build_options_string(options)
225
+ options.map do |key, value|
226
+ # Convert Ruby hash keys to command line format
227
+ flag = "--#{key.to_s.tr("_", "-")}"
228
+ if value == true
229
+ flag
230
+ elsif value == false || value.nil?
231
+ nil
232
+ else
233
+ "#{flag} #{value}"
234
+ end
235
+ end.compact.join(" ")
164
236
  end
165
237
 
166
238
  def prepare_prompt(prompt, include_context_summary)
@@ -27,6 +27,10 @@ module Roast
27
27
  def call(string)
28
28
  Roast::Helpers::Logger.info("🔍 Grepping for string: #{string}\n")
29
29
 
30
+ unless system("command -v rg >/dev/null 2>&1")
31
+ raise "ripgrep is not available. Please install it using your package manager (e.g., brew install rg) and make sure it's on your PATH."
32
+ end
33
+
30
34
  # Use Open3 to safely pass the string as an argument, avoiding shell injection
31
35
  cmd = ["rg", "-C", "4", "--trim", "--color=never", "--heading", "-F", "--", string, "."]
32
36
  stdout, _stderr, _status = Open3.capture3(*cmd)
data/lib/roast/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Roast
4
- VERSION = "0.4.2"
4
+ VERSION = "0.4.3"
5
5
  end
@@ -3,6 +3,15 @@
3
3
  module Roast
4
4
  module Workflow
5
5
  class AgentStep < BaseStep
6
+ attr_accessor :include_context_summary, :continue, :resume
7
+
8
+ def initialize(workflow, **kwargs)
9
+ super
10
+ # Set default values for agent-specific options
11
+ @include_context_summary = false
12
+ @continue = false
13
+ end
14
+
6
15
  def call
7
16
  # For inline prompts (detected by plain text step names), use the name as the prompt
8
17
  # For file-based steps, load from the prompt file
@@ -12,22 +21,66 @@ module Roast
12
21
  read_sidecar_prompt
13
22
  end
14
23
 
15
- # Extract agent-specific configuration from workflow config
16
- step_config = workflow.config[name.to_s] || {}
24
+ # Handle resume option by copying session ID from referenced step
25
+ if @resume.present?
26
+ if (session_id = workflow.metadata.dig(@resume, "coding_agent_session_id"))
27
+ workflow.metadata[name.to_s] ||= {}
28
+ workflow.metadata[name.to_s]["coding_agent_session_id"] = session_id
29
+ else
30
+ Roast::Helpers::Logger.warn("Cannot resume from step '#{@resume}'. It does not have a coding_agent_session_id in its metadata.")
31
+ end
32
+ end
33
+
34
+ # Use agent-specific configuration that was applied by StepLoader
35
+ # If resume is set and a session_id is available, translate it to continue
17
36
  agent_options = {
18
- include_context_summary: step_config.fetch("include_context_summary", false),
19
- continue: step_config.fetch("continue", false),
37
+ include_context_summary: @include_context_summary,
38
+ continue: @continue || session_id.present?, # Use continue if either continue is set or a session_id to resume from is available
20
39
  }
21
40
 
22
41
  # Call CodingAgent directly with the prompt content and options
23
42
  result = Roast::Tools::CodingAgent.call(prompt_content, **agent_options)
24
43
 
44
+ # Parse as JSON if json: true is configured (since CodingAgent response is not handled by Raix)
45
+ if @json && result.is_a?(String)
46
+ # Don't try to parse error messages as JSON
47
+ if result.start_with?("Error running CodingAgent:")
48
+ raise result
49
+ end
50
+
51
+ # Extract JSON from markdown code blocks anywhere in the response
52
+ cleaned_result = extract_json_from_markdown(result)
53
+
54
+ begin
55
+ result = JSON.parse(cleaned_result)
56
+ rescue JSON::ParserError => e
57
+ raise "Failed to parse CodingAgent result as JSON: #{e.message}"
58
+ end
59
+ end
60
+
25
61
  # Process output if print_response is enabled
26
62
  process_output(result, print_response:)
27
63
 
28
64
  # Apply coercion if configured
29
65
  apply_coercion(result)
30
66
  end
67
+
68
+ private
69
+
70
+ def extract_json_from_markdown(text)
71
+ # Look for JSON code blocks anywhere in the text
72
+ # Matches ```json or ``` followed by content, then closing ```
73
+ json_block_pattern = /```(?:json)?\s*\n(.*?)\n```/m
74
+
75
+ match = text.match(json_block_pattern)
76
+ if match
77
+ # Return the content inside the code block
78
+ match[1].strip
79
+ else
80
+ # No code block found, return original text
81
+ text.strip
82
+ end
83
+ end
31
84
  end
32
85
  end
33
86
  end
@@ -24,6 +24,7 @@ module Roast
24
24
 
25
25
  delegate :api_provider, :openai?, to: :workflow_configuration, allow_nil: true
26
26
  delegate :output, :output=, :append_to_final_output, :final_output, to: :output_manager
27
+ delegate :metadata, :metadata=, to: :metadata_manager
27
28
  delegate_missing_to :output
28
29
 
29
30
  def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, workflow_configuration: nil, pre_processing_data: nil)
@@ -38,6 +39,7 @@ module Roast
38
39
 
39
40
  # Initialize managers
40
41
  @output_manager = OutputManager.new
42
+ @metadata_manager = MetadataManager.new
41
43
  @context_manager = ContextManager.new
42
44
  @context_management_config = {}
43
45
 
@@ -125,8 +127,8 @@ module Roast
125
127
  self
126
128
  end
127
129
 
128
- # Expose output manager for state management
129
- attr_reader :output_manager
130
+ # Expose output and metadata managers for state management
131
+ attr_reader :output_manager, :metadata_manager
130
132
 
131
133
  private
132
134
 
@@ -54,10 +54,12 @@ module Roast
54
54
  # Set the variable in the workflow's context
55
55
  workflow.instance_variable_set("@#{@variable_name}", value)
56
56
 
57
- # Define a getter method for the variable
57
+ # Define a getter method for the variable if it doesn't exist
58
58
  var_name = @variable_name.to_sym
59
- workflow.singleton_class.class_eval do
60
- attr_reader(var_name)
59
+ unless workflow.respond_to?(var_name)
60
+ workflow.singleton_class.class_eval do
61
+ attr_reader(var_name)
62
+ end
61
63
  end
62
64
 
63
65
  # Make the variable accessible in the output hash
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Manages workflow metadata, providing a structure parallel to output
6
+ # but specifically for internal metadata that shouldn't be user-facing
7
+ class MetadataManager
8
+ def initialize
9
+ @metadata = ActiveSupport::HashWithIndifferentAccess.new
10
+ @metadata_wrapper = nil
11
+ end
12
+
13
+ # Get metadata wrapped in DotAccessHash for dot notation access
14
+ def metadata
15
+ @metadata_wrapper ||= DotAccessHash.new(@metadata)
16
+ end
17
+
18
+ # Set metadata, ensuring it's always a HashWithIndifferentAccess
19
+ def metadata=(value)
20
+ @metadata = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
21
+ value
22
+ else
23
+ ActiveSupport::HashWithIndifferentAccess.new(value)
24
+ end
25
+ # Reset the wrapper when metadata changes
26
+ @metadata_wrapper = nil
27
+ end
28
+
29
+ # Get the raw metadata hash (for internal use)
30
+ def raw_metadata
31
+ @metadata
32
+ end
33
+
34
+ # Get a snapshot of the current state for persistence
35
+ def to_h
36
+ @metadata.to_h
37
+ end
38
+
39
+ # Restore state from a hash
40
+ def from_h(data)
41
+ return unless data
42
+
43
+ self.metadata = data
44
+ end
45
+ end
46
+ end
47
+ end
@@ -25,6 +25,7 @@ module Roast
25
25
  File.write(workflow.output_file, workflow.final_output)
26
26
  $stdout.puts "Results saved to #{workflow.output_file}"
27
27
  else
28
+ $stderr.puts "🔥🔥🔥 Final Output: 🔥🔥🔥"
28
29
  $stdout.puts workflow.final_output
29
30
  end
30
31
  end
@@ -77,6 +77,7 @@ module Roast
77
77
  return unless state_data && @workflow
78
78
 
79
79
  restore_output(state_data)
80
+ restore_metadata(state_data)
80
81
  restore_transcript(state_data)
81
82
  restore_final_output(state_data)
82
83
  end
@@ -88,6 +89,13 @@ module Roast
88
89
  @workflow.output = state_data[:output]
89
90
  end
90
91
 
92
+ def restore_metadata(state_data)
93
+ return unless state_data.key?(:metadata)
94
+ return unless @workflow.respond_to?(:metadata=)
95
+
96
+ @workflow.metadata = state_data[:metadata]
97
+ end
98
+
91
99
  def restore_transcript(state_data)
92
100
  return unless state_data.key?(:transcript)
93
101
  return unless @workflow.respond_to?(:transcript)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ class ShellScriptStep < BaseStep
6
+ attr_reader :script_path
7
+ attr_accessor :exit_on_error, :env
8
+
9
+ def initialize(workflow, script_path:, **options)
10
+ super(workflow, **options)
11
+ @script_path = script_path
12
+ @exit_on_error = true # default to true
13
+ @env = {} # custom environment variables
14
+ end
15
+
16
+ def call
17
+ validate_script!
18
+
19
+ stdout, stderr, status = execute_script
20
+
21
+ result = if status.success?
22
+ parse_output(stdout)
23
+ else
24
+ handle_script_error(stderr, status.exitstatus)
25
+ end
26
+
27
+ process_output(result, print_response: @print_response)
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def validate_script!
34
+ unless File.exist?(script_path)
35
+ raise ::CLI::Kit::Abort, "Shell script not found: #{script_path}"
36
+ end
37
+
38
+ unless File.executable?(script_path)
39
+ raise ::CLI::Kit::Abort, "Shell script is not executable: #{script_path}. Run: chmod +x #{script_path}"
40
+ end
41
+ end
42
+
43
+ def execute_script
44
+ env = setup_environment
45
+ cmd = build_command
46
+
47
+ log_debug("Executing shell script: #{cmd}")
48
+ log_debug("Environment: #{env.inspect}")
49
+
50
+ Open3.capture3(env, cmd, chdir: Dir.pwd)
51
+ end
52
+
53
+ def build_command
54
+ script_path
55
+ end
56
+
57
+ def setup_environment
58
+ env_vars = {}
59
+
60
+ # Add workflow context as environment variables
61
+ env_vars["ROAST_WORKFLOW_RESOURCE"] = workflow.resource.to_s if workflow.resource
62
+ env_vars["ROAST_STEP_NAME"] = name.value
63
+
64
+ # Add workflow outputs as JSON
65
+ if workflow.output && !workflow.output.empty?
66
+ env_vars["ROAST_WORKFLOW_OUTPUT"] = JSON.generate(workflow.output)
67
+ end
68
+
69
+ # Add any custom environment variables from step configuration
70
+ if @env.is_a?(Hash)
71
+ @env.each do |key, value|
72
+ env_vars[key.to_s] = value.to_s
73
+ end
74
+ end
75
+
76
+ env_vars
77
+ end
78
+
79
+ def parse_output(stdout)
80
+ return "" if stdout.strip.empty?
81
+
82
+ if @json
83
+ begin
84
+ JSON.parse(stdout.strip)
85
+ rescue JSON::ParserError => e
86
+ raise "Failed to parse shell script output as JSON: #{e.message}\nOutput was: #{stdout.strip}"
87
+ end
88
+ else
89
+ stdout.strip
90
+ end
91
+ end
92
+
93
+ def handle_script_error(stderr, exit_code)
94
+ error_message = "Shell script failed with exit code #{exit_code}"
95
+ error_message += "\nError output:\n#{stderr}" unless stderr.strip.empty?
96
+
97
+ if @exit_on_error == false
98
+ log_error(error_message)
99
+ # Return stderr as the result when not exiting on error
100
+ stderr.strip.empty? ? "" : stderr.strip
101
+ else
102
+ raise ::CLI::Kit::Abort, error_message
103
+ end
104
+ end
105
+
106
+ def log_debug(message)
107
+ $stderr.puts "[ShellScriptStep] #{message}" if ENV["ROAST_DEBUG"]
108
+ end
109
+
110
+ def log_error(message)
111
+ $stderr.puts "[ShellScriptStep] ERROR: #{message}"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -42,6 +42,7 @@ module Roast
42
42
  order: determine_step_order(step_name),
43
43
  transcript: extract_transcript,
44
44
  output: extract_output,
45
+ metadata: extract_metadata,
45
46
  final_output: extract_final_output,
46
47
  execution_order: extract_execution_order,
47
48
  }
@@ -68,6 +69,13 @@ module Roast
68
69
  workflow.output.clone
69
70
  end
70
71
 
72
+ # Extract metadata if available
73
+ def extract_metadata
74
+ return {} unless workflow.respond_to?(:metadata)
75
+
76
+ workflow.metadata.clone
77
+ end
78
+
71
79
  # Extract final output data if available
72
80
  def extract_final_output
73
81
  return [] unless workflow.respond_to?(:final_output)
@@ -38,7 +38,7 @@ module Roast
38
38
  Kernel.binding.irb # rubocop:disable Lint/Debugger
39
39
  end
40
40
  else
41
- step_orchestrator.execute_step(step, is_last_step: is_last_step)
41
+ execute_custom_step(step, is_last_step: is_last_step)
42
42
  end
43
43
  end
44
44
  end
@@ -54,6 +54,10 @@ module Roast
54
54
  # @return [Object] The result of the step execution
55
55
  def execute(step, options = {})
56
56
  step_type = StepTypeResolver.resolve(step, @context)
57
+ step_name = StepTypeResolver.extract_name(step)
58
+
59
+ Thread.current[:current_step_name] = step_name if step_name
60
+ Thread.current[:workflow_metadata] = @context.workflow.metadata
57
61
 
58
62
  case step_type
59
63
  when StepTypeResolver::COMMAND_STEP
@@ -126,14 +130,18 @@ module Roast
126
130
  )
127
131
  end
128
132
 
129
- def step_orchestrator
130
- dependencies[:step_orchestrator]
131
- end
132
-
133
133
  def error_handler
134
134
  dependencies[:error_handler]
135
135
  end
136
136
 
137
+ def step_loader
138
+ dependencies[:step_loader]
139
+ end
140
+
141
+ def state_manager
142
+ dependencies[:state_manager]
143
+ end
144
+
137
145
  def execute_command_step(step, options)
138
146
  exit_on_error = options.fetch(:exit_on_error, true)
139
147
  resource_type = @context.resource_type
@@ -185,8 +193,11 @@ module Roast
185
193
  step_name = StepTypeResolver.extract_name(step)
186
194
 
187
195
  # Load and execute the agent step
188
- exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
189
- step_orchestrator.execute_step(step_name, exit_on_error:, step_key: options[:step_key], agent_type: :coding_agent)
196
+ merged_options = options.merge(
197
+ exit_on_error: options.fetch(:exit_on_error) { context.exit_on_error?(step) },
198
+ agent_type: :coding_agent,
199
+ )
200
+ execute_custom_step(step_name, **merged_options)
190
201
  end
191
202
 
192
203
  def execute_glob_step(step, options = {})
@@ -259,7 +270,7 @@ module Roast
259
270
  exit_on_error = options.fetch(:exit_on_error, true)
260
271
  step_key = options[:step_key]
261
272
  is_last_step = options[:is_last_step]
262
- step_orchestrator.execute_step(step, exit_on_error:, step_key:, is_last_step:)
273
+ execute_custom_step(step, exit_on_error:, step_key:, is_last_step:)
263
274
  end
264
275
 
265
276
  def validate_each_step!(step)
@@ -268,6 +279,30 @@ module Roast
268
279
  "Invalid 'each' step format. 'as' and 'steps' must be at the same level as 'each'"
269
280
  end
270
281
  end
282
+
283
+ def execute_custom_step(name, step_key: nil, **options)
284
+ resource_type = @context.workflow.respond_to?(:resource) ? @context.workflow.resource&.type : nil
285
+
286
+ error_handler.with_error_handling(name, resource_type: resource_type) do
287
+ $stderr.puts "Executing: #{name} (Resource type: #{resource_type || "unknown"})"
288
+
289
+ # Use step_key for loading if provided, otherwise use name
290
+ load_key = step_key || name
291
+ is_last_step = options[:is_last_step]
292
+ step_object = step_loader.load(name, exit_on_error: false, step_key: load_key, is_last_step:, **options)
293
+ step_result = step_object.call
294
+
295
+ # Store result in workflow output
296
+ # Use step_key for output storage if provided (for hash steps)
297
+ output_key = step_key || name
298
+ @context.workflow.output[output_key] = step_result
299
+
300
+ # Save state after each step
301
+ state_manager.save_state(name, step_result)
302
+
303
+ step_result
304
+ end
305
+ end
271
306
  end
272
307
  end
273
308
  end
@@ -11,12 +11,12 @@ module Roast
11
11
  @name_extractor = StepNameExtractor.new
12
12
  end
13
13
 
14
- def execute(step, options = {})
14
+ def execute(step, **options)
15
15
  # Track tokens before execution
16
16
  tokens_before = @context.workflow.context_manager&.total_tokens || 0
17
17
 
18
18
  # Execute the step
19
- result = @base_executor.execute(step, options)
19
+ result = @base_executor.execute(step, **options)
20
20
 
21
21
  # Report token consumption after successful execution
22
22
  tokens_after = @context.workflow.context_manager&.total_tokens || 0