roast-ai 0.1.6 → 0.2.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/ci.yaml +1 -1
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +20 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +11 -6
- data/README.md +225 -13
- data/bin/roast +27 -0
- data/docs/INSTRUMENTATION.md +42 -1
- data/docs/ITERATION_SYNTAX.md +119 -0
- data/examples/conditional/README.md +161 -0
- data/examples/conditional/check_condition/prompt.md +1 -0
- data/examples/conditional/simple_workflow.yml +15 -0
- data/examples/conditional/workflow.yml +23 -0
- data/examples/dot_notation/README.md +37 -0
- data/examples/dot_notation/workflow.yml +44 -0
- data/examples/exit_on_error/README.md +50 -0
- data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
- data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
- data/examples/exit_on_error/workflow.yml +19 -0
- data/examples/grading/workflow.yml +5 -1
- data/examples/iteration/IMPLEMENTATION.md +88 -0
- data/examples/iteration/README.md +68 -0
- data/examples/iteration/analyze_complexity/prompt.md +22 -0
- data/examples/iteration/generate_recommendations/prompt.md +21 -0
- data/examples/iteration/generate_report/prompt.md +129 -0
- data/examples/iteration/implement_fix/prompt.md +25 -0
- data/examples/iteration/prioritize_issues/prompt.md +24 -0
- data/examples/iteration/prompts/analyze_file.md +28 -0
- data/examples/iteration/prompts/generate_summary.md +24 -0
- data/examples/iteration/prompts/update_report.md +29 -0
- data/examples/iteration/prompts/write_report.md +22 -0
- data/examples/iteration/read_file/prompt.md +9 -0
- data/examples/iteration/select_next_issue/prompt.md +25 -0
- data/examples/iteration/simple_workflow.md +39 -0
- data/examples/iteration/simple_workflow.yml +58 -0
- data/examples/iteration/update_fix_count/prompt.md +26 -0
- data/examples/iteration/verify_fix/prompt.md +29 -0
- data/examples/iteration/workflow.yml +42 -0
- data/examples/openrouter_example/workflow.yml +2 -2
- data/examples/workflow_generator/README.md +27 -0
- data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
- data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
- data/examples/workflow_generator/get_user_input/prompt.md +14 -0
- data/examples/workflow_generator/info_from_roast.rb +22 -0
- data/examples/workflow_generator/workflow.yml +35 -0
- data/lib/roast/errors.rb +9 -0
- data/lib/roast/factories/api_provider_factory.rb +61 -0
- data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
- data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
- data/lib/roast/helpers/prompt_loader.rb +50 -1
- data/lib/roast/resources/base_resource.rb +7 -0
- data/lib/roast/resources.rb +6 -6
- data/lib/roast/tools/ask_user.rb +40 -0
- data/lib/roast/tools/cmd.rb +1 -1
- data/lib/roast/tools/search_file.rb +1 -1
- data/lib/roast/tools/update_files.rb +413 -0
- data/lib/roast/tools.rb +12 -1
- data/lib/roast/value_objects/api_token.rb +49 -0
- data/lib/roast/value_objects/step_name.rb +39 -0
- data/lib/roast/value_objects/workflow_path.rb +77 -0
- data/lib/roast/value_objects.rb +5 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/api_configuration.rb +61 -0
- data/lib/roast/workflow/base_iteration_step.rb +165 -0
- data/lib/roast/workflow/base_step.rb +4 -24
- data/lib/roast/workflow/base_workflow.rb +76 -73
- data/lib/roast/workflow/command_executor.rb +88 -0
- data/lib/roast/workflow/conditional_executor.rb +50 -0
- data/lib/roast/workflow/conditional_step.rb +96 -0
- data/lib/roast/workflow/configuration.rb +35 -158
- data/lib/roast/workflow/configuration_loader.rb +78 -0
- data/lib/roast/workflow/configuration_parser.rb +13 -248
- data/lib/roast/workflow/context_path_resolver.rb +43 -0
- data/lib/roast/workflow/dot_access_hash.rb +198 -0
- data/lib/roast/workflow/each_step.rb +86 -0
- data/lib/roast/workflow/error_handler.rb +97 -0
- data/lib/roast/workflow/expression_utils.rb +36 -0
- data/lib/roast/workflow/file_state_repository.rb +3 -2
- data/lib/roast/workflow/interpolator.rb +34 -0
- data/lib/roast/workflow/iteration_executor.rb +85 -0
- data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
- data/lib/roast/workflow/output_handler.rb +35 -0
- data/lib/roast/workflow/output_manager.rb +77 -0
- data/lib/roast/workflow/parallel_executor.rb +49 -0
- data/lib/roast/workflow/repeat_step.rb +75 -0
- data/lib/roast/workflow/replay_handler.rb +123 -0
- data/lib/roast/workflow/resource_resolver.rb +77 -0
- data/lib/roast/workflow/session_manager.rb +6 -2
- data/lib/roast/workflow/state_manager.rb +97 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
- data/lib/roast/workflow/step_executor_factory.rb +47 -0
- data/lib/roast/workflow/step_executor_registry.rb +79 -0
- data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
- data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
- data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
- data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
- data/lib/roast/workflow/step_finder.rb +97 -0
- data/lib/roast/workflow/step_loader.rb +154 -0
- data/lib/roast/workflow/step_orchestrator.rb +45 -0
- data/lib/roast/workflow/step_runner.rb +23 -0
- data/lib/roast/workflow/step_type_resolver.rb +117 -0
- data/lib/roast/workflow/workflow_context.rb +60 -0
- data/lib/roast/workflow/workflow_executor.rb +90 -209
- data/lib/roast/workflow/workflow_initializer.rb +112 -0
- data/lib/roast/workflow/workflow_runner.rb +87 -0
- data/lib/roast/workflow.rb +3 -0
- data/lib/roast.rb +96 -3
- data/roast.gemspec +3 -1
- data/schema/workflow.json +85 -0
- metadata +112 -4
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/expression_utils"
|
4
|
+
require "roast/workflow/llm_boolean_coercer"
|
5
|
+
require "roast/workflow/workflow_executor"
|
6
|
+
|
7
|
+
module Roast
|
8
|
+
module Workflow
|
9
|
+
# Base class for iteration steps (RepeatStep and EachStep)
|
10
|
+
class BaseIterationStep < BaseStep
|
11
|
+
include ExpressionUtils
|
12
|
+
|
13
|
+
DEFAULT_MAX_ITERATIONS = 100
|
14
|
+
|
15
|
+
attr_reader :steps
|
16
|
+
|
17
|
+
def initialize(workflow, steps:, **kwargs)
|
18
|
+
super(workflow, **kwargs)
|
19
|
+
@steps = steps
|
20
|
+
# Don't initialize cmd_tool here - we'll do it lazily when needed
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
|
25
|
+
# Process various types of inputs and convert to appropriate types for iteration
|
26
|
+
def process_iteration_input(input, context, coerce_to: nil)
|
27
|
+
if input.is_a?(String)
|
28
|
+
if ruby_expression?(input)
|
29
|
+
process_ruby_expression(input, context, coerce_to)
|
30
|
+
elsif bash_command?(input)
|
31
|
+
process_bash_command(input, coerce_to)
|
32
|
+
else
|
33
|
+
process_step_or_prompt(input, context, coerce_to)
|
34
|
+
end
|
35
|
+
else
|
36
|
+
# Non-string inputs are coerced as-is
|
37
|
+
coerce_result(input, coerce_to)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Interpolates {{expression}} in a string with values from the workflow context
|
42
|
+
def interpolate_expression(text, context)
|
43
|
+
return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
|
44
|
+
|
45
|
+
# Replace all {{expression}} with their evaluated values
|
46
|
+
text.gsub(/\{\{([^}]+)\}\}/) do |match|
|
47
|
+
expression = extract_expression(match)
|
48
|
+
begin
|
49
|
+
# Evaluate the expression in the workflow's context
|
50
|
+
result = context.instance_eval(expression)
|
51
|
+
result.inspect # Convert to string representation
|
52
|
+
rescue => e
|
53
|
+
warn_interpolation_error(expression, e)
|
54
|
+
match # Return the original match to preserve it in the string
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Execute nested steps
|
60
|
+
def execute_nested_steps(steps, context, executor = nil)
|
61
|
+
executor ||= WorkflowExecutor.new(context, {}, context_path)
|
62
|
+
results = []
|
63
|
+
|
64
|
+
steps.each do |step|
|
65
|
+
result = case step
|
66
|
+
when String
|
67
|
+
executor.execute_step(step)
|
68
|
+
when Hash, Array
|
69
|
+
executor.execute_steps([step])
|
70
|
+
end
|
71
|
+
results << result
|
72
|
+
end
|
73
|
+
|
74
|
+
results
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Process a Ruby expression
|
80
|
+
def process_ruby_expression(input, context, coerce_to)
|
81
|
+
expression = extract_expression(input)
|
82
|
+
result = evaluate_ruby_expression(expression, context)
|
83
|
+
coerce_result(result, coerce_to)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Process a Bash command
|
87
|
+
def process_bash_command(input, coerce_to)
|
88
|
+
command = extract_command(input)
|
89
|
+
execute_command(command, coerce_to)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Process a step name or prompt
|
93
|
+
def process_step_or_prompt(input, context, coerce_to)
|
94
|
+
step_result = execute_step_by_name(input, context)
|
95
|
+
coerce_result(step_result, coerce_to)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Execute a Ruby expression in the workflow context
|
99
|
+
def evaluate_ruby_expression(expression, context)
|
100
|
+
context.instance_eval(expression)
|
101
|
+
rescue => e
|
102
|
+
warn_expression_error(expression, e)
|
103
|
+
nil
|
104
|
+
end
|
105
|
+
|
106
|
+
# Execute a bash command and return its result
|
107
|
+
def execute_command(command, coerce_to)
|
108
|
+
# Use the Cmd module to execute the command
|
109
|
+
result = Roast::Tools::Cmd.call(command)
|
110
|
+
|
111
|
+
if coerce_to == :boolean
|
112
|
+
# For boolean coercion, use exit status (assume success unless error message)
|
113
|
+
!result.to_s.start_with?("Error")
|
114
|
+
else
|
115
|
+
# For other uses, return the output
|
116
|
+
result
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Execute a step by name and return its result
|
121
|
+
def execute_step_by_name(step_name, context)
|
122
|
+
# Reuse existing step execution logic
|
123
|
+
executor = WorkflowExecutor.new(context, {}, context_path)
|
124
|
+
executor.execute_step(step_name)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Coerce results to the appropriate type
|
128
|
+
def coerce_result(result, coerce_to)
|
129
|
+
return coerce_to_boolean(result) if coerce_to == :boolean
|
130
|
+
return coerce_to_iterable(result) if coerce_to == :iterable
|
131
|
+
return coerce_to_llm_boolean(result) if coerce_to == :llm_boolean
|
132
|
+
|
133
|
+
# Default - return as is
|
134
|
+
result
|
135
|
+
end
|
136
|
+
|
137
|
+
# Force a value to boolean
|
138
|
+
def coerce_to_boolean(result)
|
139
|
+
!!result
|
140
|
+
end
|
141
|
+
|
142
|
+
# Ensure a value is iterable
|
143
|
+
def coerce_to_iterable(result)
|
144
|
+
return result if result.respond_to?(:each)
|
145
|
+
|
146
|
+
result.to_s.split("\n")
|
147
|
+
end
|
148
|
+
|
149
|
+
# Convert LLM response to boolean
|
150
|
+
def coerce_to_llm_boolean(result)
|
151
|
+
LlmBooleanCoercer.coerce(result)
|
152
|
+
end
|
153
|
+
|
154
|
+
# Log a warning for expression evaluation errors
|
155
|
+
def warn_expression_error(expression, error)
|
156
|
+
$stderr.puts "Warning: Error evaluating expression '#{expression}': #{error.message}"
|
157
|
+
end
|
158
|
+
|
159
|
+
# Log a warning for interpolation errors
|
160
|
+
def warn_interpolation_error(expression, error)
|
161
|
+
$stderr.puts "Warning: Error interpolating {{#{expression}}}: #{error.message}"
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require "erb"
|
4
4
|
require "forwardable"
|
5
|
+
require "roast/workflow/context_path_resolver"
|
5
6
|
|
6
7
|
module Roast
|
7
8
|
module Workflow
|
@@ -15,11 +16,11 @@ module Roast
|
|
15
16
|
def_delegator :workflow, :chat_completion
|
16
17
|
def_delegator :workflow, :transcript
|
17
18
|
|
18
|
-
def initialize(workflow, model: "anthropic:claude-
|
19
|
+
def initialize(workflow, model: "anthropic:claude-opus-4", name: nil, context_path: nil, auto_loop: true)
|
19
20
|
@workflow = workflow
|
20
21
|
@model = model
|
21
22
|
@name = name || self.class.name.underscore.split("/").last
|
22
|
-
@context_path = context_path ||
|
23
|
+
@context_path = context_path || ContextPathResolver.resolve(self.class)
|
23
24
|
@print_response = false
|
24
25
|
@auto_loop = auto_loop
|
25
26
|
@json = false
|
@@ -35,7 +36,7 @@ module Roast
|
|
35
36
|
protected
|
36
37
|
|
37
38
|
def chat_completion(print_response: false, auto_loop: true, json: false, params: {})
|
38
|
-
workflow.chat_completion(openai: model, loop: auto_loop, json:, params:).then do |response|
|
39
|
+
workflow.chat_completion(openai: workflow.openai? && model, loop: auto_loop, model: model, json:, params:).then do |response|
|
39
40
|
case response
|
40
41
|
in Array
|
41
42
|
response.map(&:presence).compact.join("\n")
|
@@ -47,27 +48,6 @@ module Roast
|
|
47
48
|
end
|
48
49
|
end
|
49
50
|
|
50
|
-
# Determine the directory where the actual class is defined, not BaseWorkflow
|
51
|
-
def determine_context_path
|
52
|
-
# Get the actual class's source file
|
53
|
-
klass = self.class
|
54
|
-
|
55
|
-
# Try to get the file path where the class is defined
|
56
|
-
path = if klass.name.include?("::")
|
57
|
-
# For namespaced classes like Roast::Workflow::Grading::Workflow
|
58
|
-
# Convert the class name to a relative path
|
59
|
-
class_path = klass.name.underscore + ".rb"
|
60
|
-
# Look through load path to find the actual file
|
61
|
-
$LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
|
62
|
-
else
|
63
|
-
# Fall back to the current file if we can't find it
|
64
|
-
__FILE__
|
65
|
-
end
|
66
|
-
|
67
|
-
# Return directory containing the class definition
|
68
|
-
File.dirname(path || __FILE__)
|
69
|
-
end
|
70
|
-
|
71
51
|
def prompt(text)
|
72
52
|
transcript << { user: text }
|
73
53
|
end
|
@@ -6,125 +6,128 @@ require "active_support"
|
|
6
6
|
require "active_support/isolated_execution_state"
|
7
7
|
require "active_support/notifications"
|
8
8
|
require "active_support/core_ext/hash/indifferent_access"
|
9
|
+
require "roast/workflow/output_manager"
|
10
|
+
require "roast/workflow/context_path_resolver"
|
9
11
|
|
10
12
|
module Roast
|
11
13
|
module Workflow
|
12
14
|
class BaseWorkflow
|
13
15
|
include Raix::ChatCompletion
|
14
16
|
|
15
|
-
attr_reader :output
|
16
17
|
attr_accessor :file,
|
17
18
|
:concise,
|
18
19
|
:output_file,
|
20
|
+
:pause_step_name,
|
19
21
|
:verbose,
|
20
22
|
:name,
|
21
23
|
:context_path,
|
22
24
|
:resource,
|
23
25
|
:session_name,
|
24
26
|
:session_timestamp,
|
25
|
-
:configuration
|
27
|
+
:configuration,
|
28
|
+
:model
|
29
|
+
|
30
|
+
delegate :api_provider, :openai?, to: :configuration
|
31
|
+
delegate :output, :output=, :append_to_final_output, :final_output, to: :output_manager
|
26
32
|
|
27
33
|
def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, configuration: nil)
|
28
34
|
@file = file
|
29
35
|
@name = name || self.class.name.underscore.split("/").last
|
30
|
-
@context_path = context_path ||
|
31
|
-
@final_output = []
|
32
|
-
@output = ActiveSupport::HashWithIndifferentAccess.new
|
36
|
+
@context_path = context_path || ContextPathResolver.resolve(self.class)
|
33
37
|
@resource = resource || Roast::Resources.for(file)
|
34
38
|
@session_name = session_name || @name
|
35
39
|
@session_timestamp = nil
|
36
40
|
@configuration = configuration
|
37
|
-
transcript << { system: read_sidecar_prompt }
|
38
|
-
Roast::Tools.setup_interrupt_handler(transcript)
|
39
|
-
Roast::Tools.setup_exit_handler(self)
|
40
|
-
end
|
41
|
-
|
42
|
-
# Custom writer for output to ensure it's always a HashWithIndifferentAccess
|
43
|
-
def output=(value)
|
44
|
-
@output = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
|
45
|
-
value
|
46
|
-
else
|
47
|
-
ActiveSupport::HashWithIndifferentAccess.new(value)
|
48
|
-
end
|
49
|
-
end
|
50
41
|
|
51
|
-
|
52
|
-
@
|
53
|
-
end
|
42
|
+
# Initialize managers
|
43
|
+
@output_manager = OutputManager.new
|
54
44
|
|
55
|
-
|
56
|
-
|
57
|
-
|
45
|
+
# Setup prompt and handlers
|
46
|
+
read_sidecar_prompt.then do |prompt|
|
47
|
+
next unless prompt
|
58
48
|
|
59
|
-
|
60
|
-
if @final_output.respond_to?(:join)
|
61
|
-
@final_output.join("\n\n")
|
62
|
-
else
|
63
|
-
# Handle any other unexpected type by converting to string
|
64
|
-
@final_output.to_s
|
49
|
+
transcript << { system: prompt }
|
65
50
|
end
|
51
|
+
Roast::Tools.setup_interrupt_handler(transcript)
|
52
|
+
Roast::Tools.setup_exit_handler(self)
|
66
53
|
end
|
67
54
|
|
68
55
|
# Override chat_completion to add instrumentation
|
69
56
|
def chat_completion(**kwargs)
|
70
57
|
start_time = Time.now
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
58
|
+
step_model = kwargs[:model]
|
59
|
+
|
60
|
+
with_model(step_model) do
|
61
|
+
ActiveSupport::Notifications.instrument("roast.chat_completion.start", {
|
62
|
+
model: model,
|
63
|
+
parameters: kwargs.except(:openai, :model),
|
64
|
+
})
|
65
|
+
|
66
|
+
# Call the parent module's chat_completion
|
67
|
+
# skip model because it is read directly from the model method
|
68
|
+
result = super(**kwargs.except(:model))
|
69
|
+
execution_time = Time.now - start_time
|
70
|
+
|
71
|
+
ActiveSupport::Notifications.instrument("roast.chat_completion.complete", {
|
72
|
+
success: true,
|
73
|
+
model: model,
|
74
|
+
parameters: kwargs.except(:openai, :model),
|
75
|
+
execution_time: execution_time,
|
76
|
+
response_size: result.to_s.length,
|
77
|
+
})
|
78
|
+
result
|
79
|
+
end
|
80
|
+
rescue Faraday::ResourceNotFound => e
|
79
81
|
execution_time = Time.now - start_time
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
parameters: kwargs.except(:openai),
|
85
|
-
execution_time: execution_time,
|
86
|
-
response_size: result.to_s.length,
|
87
|
-
})
|
88
|
-
|
89
|
-
result
|
82
|
+
message = e.response.dig(:body, "error", "message") || e.message
|
83
|
+
error = Roast::ResourceNotFoundError.new(message)
|
84
|
+
error.set_backtrace(e.backtrace)
|
85
|
+
log_and_raise_error(error, message, step_model || model, kwargs, execution_time)
|
90
86
|
rescue => e
|
91
87
|
execution_time = Time.now - start_time
|
88
|
+
log_and_raise_error(e, e.message, step_model || model, kwargs, execution_time)
|
89
|
+
end
|
92
90
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
})
|
100
|
-
raise
|
91
|
+
def with_model(model)
|
92
|
+
previous_model = @model
|
93
|
+
@model = model
|
94
|
+
yield
|
95
|
+
ensure
|
96
|
+
@model = previous_model
|
101
97
|
end
|
102
98
|
|
103
99
|
def workflow
|
104
100
|
self
|
105
101
|
end
|
106
102
|
|
107
|
-
|
103
|
+
# Expose output manager for state management
|
104
|
+
attr_reader :output_manager
|
108
105
|
|
109
|
-
#
|
110
|
-
def
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
# Try to get the file path where the class is defined
|
115
|
-
path = if klass.name.include?("::")
|
116
|
-
# For namespaced classes like Roast::Workflow::Grading::Workflow
|
117
|
-
# Convert the class name to a relative path
|
118
|
-
class_path = klass.name.underscore + ".rb"
|
119
|
-
# Look through load path to find the actual file
|
120
|
-
$LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
|
106
|
+
# Allow direct access to output values without 'output.' prefix
|
107
|
+
def method_missing(method_name, *args, &block)
|
108
|
+
if output.respond_to?(method_name)
|
109
|
+
output.send(method_name, *args, &block)
|
121
110
|
else
|
122
|
-
|
123
|
-
__FILE__
|
111
|
+
super
|
124
112
|
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def respond_to_missing?(method_name, include_private = false)
|
116
|
+
output.respond_to?(method_name) || super
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def log_and_raise_error(error, message, model, params, execution_time)
|
122
|
+
ActiveSupport::Notifications.instrument("roast.chat_completion.error", {
|
123
|
+
error: error.class.name,
|
124
|
+
message: message,
|
125
|
+
model: model,
|
126
|
+
parameters: params.except(:openai, :model),
|
127
|
+
execution_time: execution_time,
|
128
|
+
})
|
125
129
|
|
126
|
-
|
127
|
-
File.dirname(path || __FILE__)
|
130
|
+
raise error
|
128
131
|
end
|
129
132
|
|
130
133
|
def read_sidecar_prompt
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "English"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
class CommandExecutor
|
8
|
+
class CommandExecutionError < StandardError
|
9
|
+
attr_reader :command, :exit_status, :original_error
|
10
|
+
|
11
|
+
def initialize(message, command:, exit_status: nil, original_error: nil)
|
12
|
+
@command = command
|
13
|
+
@exit_status = exit_status
|
14
|
+
@original_error = original_error
|
15
|
+
super(message)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(logger: nil)
|
20
|
+
@logger = logger || NullLogger.new
|
21
|
+
end
|
22
|
+
|
23
|
+
def execute(command_string, exit_on_error: true)
|
24
|
+
command = extract_command(command_string)
|
25
|
+
|
26
|
+
output = %x(#{command})
|
27
|
+
exit_status = $CHILD_STATUS.exitstatus
|
28
|
+
|
29
|
+
handle_execution_result(
|
30
|
+
command: command,
|
31
|
+
output: output,
|
32
|
+
exit_status: exit_status,
|
33
|
+
success: $CHILD_STATUS.success?,
|
34
|
+
exit_on_error: exit_on_error,
|
35
|
+
)
|
36
|
+
rescue ArgumentError, CommandExecutionError
|
37
|
+
raise
|
38
|
+
rescue => e
|
39
|
+
handle_execution_error(
|
40
|
+
command: command,
|
41
|
+
error: e,
|
42
|
+
exit_on_error: exit_on_error,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def extract_command(command_string)
|
49
|
+
match = command_string.strip.match(/^\$\((.*)\)$/)
|
50
|
+
raise ArgumentError, "Invalid command format. Expected $(command), got: #{command_string}" unless match
|
51
|
+
|
52
|
+
match[1]
|
53
|
+
end
|
54
|
+
|
55
|
+
def handle_execution_result(command:, output:, exit_status:, success:, exit_on_error:)
|
56
|
+
return output if success
|
57
|
+
|
58
|
+
if exit_on_error
|
59
|
+
raise CommandExecutionError.new(
|
60
|
+
"Command exited with non-zero status (#{exit_status})",
|
61
|
+
command: command,
|
62
|
+
exit_status: exit_status,
|
63
|
+
)
|
64
|
+
else
|
65
|
+
@logger.warn("Command '#{command}' exited with non-zero status (#{exit_status}), continuing execution")
|
66
|
+
output + "\n[Exit status: #{exit_status}]"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def handle_execution_error(command:, error:, exit_on_error:)
|
71
|
+
if exit_on_error
|
72
|
+
raise CommandExecutionError.new(
|
73
|
+
"Failed to execute command '#{command}': #{error.message}",
|
74
|
+
command: command,
|
75
|
+
original_error: error,
|
76
|
+
)
|
77
|
+
else
|
78
|
+
@logger.warn("Command '#{command}' failed with error: #{error.message}, continuing execution")
|
79
|
+
"Error executing command: #{error.message}\n[Exit status: error]"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
class NullLogger
|
84
|
+
def warn(_message); end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Handles execution of conditional steps (if and unless)
|
6
|
+
class ConditionalExecutor
|
7
|
+
def initialize(workflow, context_path, state_manager, workflow_executor = nil)
|
8
|
+
@workflow = workflow
|
9
|
+
@context_path = context_path
|
10
|
+
@state_manager = state_manager
|
11
|
+
@workflow_executor = workflow_executor
|
12
|
+
end
|
13
|
+
|
14
|
+
def execute_conditional(conditional_config)
|
15
|
+
$stderr.puts "Executing conditional step: #{conditional_config.inspect}"
|
16
|
+
|
17
|
+
# Determine if this is an 'if' or 'unless' condition
|
18
|
+
condition_expr = conditional_config["if"] || conditional_config["unless"]
|
19
|
+
is_unless = conditional_config.key?("unless")
|
20
|
+
then_steps = conditional_config["then"]
|
21
|
+
|
22
|
+
# Verify required parameters
|
23
|
+
raise WorkflowExecutor::ConfigurationError, "Missing condition in conditional configuration" unless condition_expr
|
24
|
+
raise WorkflowExecutor::ConfigurationError, "Missing 'then' steps in conditional configuration" unless then_steps
|
25
|
+
|
26
|
+
# Create and execute a ConditionalStep
|
27
|
+
require "roast/workflow/conditional_step" unless defined?(Roast::Workflow::ConditionalStep)
|
28
|
+
conditional_step = ConditionalStep.new(
|
29
|
+
@workflow,
|
30
|
+
config: conditional_config,
|
31
|
+
name: "conditional_#{condition_expr.gsub(/[^a-zA-Z0-9_]/, "_")[0..20]}",
|
32
|
+
context_path: @context_path,
|
33
|
+
workflow_executor: @workflow_executor,
|
34
|
+
)
|
35
|
+
|
36
|
+
result = conditional_step.call
|
37
|
+
|
38
|
+
# Store a marker in workflow output to indicate which branch was taken
|
39
|
+
condition_key = is_unless ? "unless" : "if"
|
40
|
+
step_name = "#{condition_key}_#{condition_expr.gsub(/[^a-zA-Z0-9_]/, "_")[0..30]}"
|
41
|
+
@workflow.output[step_name] = result
|
42
|
+
|
43
|
+
# Save state
|
44
|
+
@state_manager.save_state(step_name, @workflow.output[step_name])
|
45
|
+
|
46
|
+
result
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/base_step"
|
4
|
+
require "roast/workflow/command_executor"
|
5
|
+
require "roast/workflow/expression_utils"
|
6
|
+
require "roast/workflow/interpolator"
|
7
|
+
|
8
|
+
module Roast
|
9
|
+
module Workflow
|
10
|
+
class ConditionalStep < BaseStep
|
11
|
+
include ExpressionUtils
|
12
|
+
|
13
|
+
def initialize(workflow, config:, name:, context_path:, workflow_executor:, **kwargs)
|
14
|
+
super(workflow, name: name, context_path: context_path, **kwargs)
|
15
|
+
|
16
|
+
@config = config
|
17
|
+
@condition = config["if"] || config["unless"]
|
18
|
+
@is_unless = config.key?("unless")
|
19
|
+
@then_steps = config["then"] || []
|
20
|
+
@else_steps = config["else"] || []
|
21
|
+
@workflow_executor = workflow_executor
|
22
|
+
end
|
23
|
+
|
24
|
+
def call
|
25
|
+
# Evaluate the condition
|
26
|
+
condition_result = evaluate_condition(@condition)
|
27
|
+
|
28
|
+
# Invert the result if this is an 'unless' condition
|
29
|
+
condition_result = !condition_result if @is_unless
|
30
|
+
|
31
|
+
# Select which steps to execute based on the condition
|
32
|
+
steps_to_execute = condition_result ? @then_steps : @else_steps
|
33
|
+
|
34
|
+
# Execute the selected steps
|
35
|
+
unless steps_to_execute.empty?
|
36
|
+
@workflow_executor.execute_steps(steps_to_execute)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Return a result indicating which branch was taken
|
40
|
+
{ condition_result: condition_result, branch_executed: condition_result ? "then" : "else" }
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def evaluate_condition(condition)
|
46
|
+
return false unless condition.is_a?(String)
|
47
|
+
|
48
|
+
if ruby_expression?(condition)
|
49
|
+
evaluate_ruby_expression(condition)
|
50
|
+
elsif bash_command?(condition)
|
51
|
+
evaluate_bash_command(condition)
|
52
|
+
else
|
53
|
+
# Treat as a step name or direct boolean
|
54
|
+
evaluate_step_or_value(condition)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def evaluate_ruby_expression(expression)
|
59
|
+
expr = extract_expression(expression)
|
60
|
+
begin
|
61
|
+
!!@workflow.instance_eval(expr)
|
62
|
+
rescue => e
|
63
|
+
$stderr.puts "Warning: Error evaluating expression '#{expr}': #{e.message}"
|
64
|
+
false
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def evaluate_bash_command(command)
|
69
|
+
cmd = extract_command(command)
|
70
|
+
executor = CommandExecutor.new(logger: Roast::Helpers::Logger)
|
71
|
+
begin
|
72
|
+
result = executor.execute(cmd, exit_on_error: false)
|
73
|
+
# For conditionals, we care about the exit status
|
74
|
+
result[:success]
|
75
|
+
rescue => e
|
76
|
+
$stderr.puts "Warning: Error executing command '#{cmd}': #{e.message}"
|
77
|
+
false
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def evaluate_step_or_value(input)
|
82
|
+
# Check if it's a reference to a previous step output
|
83
|
+
if @workflow.output.key?(input)
|
84
|
+
result = @workflow.output[input]
|
85
|
+
# Coerce to boolean
|
86
|
+
return false if result.nil? || result == false || result == "" || result == "false"
|
87
|
+
|
88
|
+
return true
|
89
|
+
end
|
90
|
+
|
91
|
+
# Otherwise treat as a direct value
|
92
|
+
input.to_s.downcase == "true"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|