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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/Gemfile.lock +35 -12
- data/README.md +228 -29
- data/examples/coding_agent_with_model.yml +20 -0
- data/examples/coding_agent_with_retries.yml +30 -0
- data/lib/roast/helpers/metadata_access.rb +39 -0
- data/lib/roast/helpers/timeout_handler.rb +1 -1
- data/lib/roast/tools/coding_agent.rb +98 -26
- data/lib/roast/tools/grep.rb +4 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/agent_step.rb +57 -4
- data/lib/roast/workflow/base_workflow.rb +4 -2
- data/lib/roast/workflow/each_step.rb +5 -3
- data/lib/roast/workflow/metadata_manager.rb +47 -0
- data/lib/roast/workflow/output_handler.rb +1 -0
- data/lib/roast/workflow/replay_handler.rb +8 -0
- data/lib/roast/workflow/shell_script_step.rb +115 -0
- data/lib/roast/workflow/state_manager.rb +8 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +43 -8
- data/lib/roast/workflow/step_executor_with_reporting.rb +2 -2
- data/lib/roast/workflow/step_loader.rb +55 -9
- data/lib/roast/workflow/workflow_executor.rb +3 -4
- data/lib/roast/workflow/workflow_initializer.rb +24 -13
- data/lib/roast/workflow/workflow_runner.rb +2 -2
- data/lib/roast.rb +1 -0
- data/roast.gemspec +1 -2
- metadata +8 -18
- data/lib/roast/workflow/step_orchestrator.rb +0 -48
@@ -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
|
-
|
44
|
-
|
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
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
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
|
-
|
187
|
+
def build_command(base_command, continue:, session_id: nil)
|
188
|
+
command = base_command.dup
|
155
189
|
|
156
|
-
# Add --
|
157
|
-
|
158
|
-
if
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
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)
|
data/lib/roast/tools/grep.rb
CHANGED
@@ -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
@@ -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
|
-
#
|
16
|
-
|
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:
|
19
|
-
continue:
|
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
|
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.
|
60
|
-
|
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
|
@@ -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
|
-
|
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
|
-
|
189
|
-
|
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
|
-
|
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
|