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,198 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
class DotAccessHash
|
6
|
+
def initialize(hash)
|
7
|
+
@hash = hash || {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](key)
|
11
|
+
value = @hash[key.to_sym] || @hash[key.to_s]
|
12
|
+
value.is_a?(Hash) ? DotAccessHash.new(value) : value
|
13
|
+
end
|
14
|
+
|
15
|
+
def []=(key, value)
|
16
|
+
@hash[key.to_sym] = value
|
17
|
+
end
|
18
|
+
|
19
|
+
def method_missing(method_name, *args, &block)
|
20
|
+
method_str = method_name.to_s
|
21
|
+
|
22
|
+
# Handle boolean predicate methods (ending with ?)
|
23
|
+
if method_str.end_with?("?")
|
24
|
+
key = method_str.chomp("?")
|
25
|
+
# Always return false for non-existent keys with ? methods
|
26
|
+
return false unless has_key?(key) # rubocop:disable Style/PreferredHashMethods
|
27
|
+
|
28
|
+
!!self[key]
|
29
|
+
# Handle setter methods (ending with =)
|
30
|
+
elsif method_str.end_with?("=")
|
31
|
+
key = method_str.chomp("=")
|
32
|
+
self[key] = args.first
|
33
|
+
# Handle bang methods (ending with !) - should raise
|
34
|
+
elsif method_str.end_with?("!")
|
35
|
+
super
|
36
|
+
# Handle regular getter methods
|
37
|
+
elsif args.empty? && block.nil?
|
38
|
+
# Return nil for non-existent keys (like a hash would)
|
39
|
+
self[method_str]
|
40
|
+
else
|
41
|
+
super
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def respond_to_missing?(method_name, include_private = false)
|
46
|
+
method_str = method_name.to_s
|
47
|
+
|
48
|
+
if method_str.end_with?("!")
|
49
|
+
false # Don't respond to bang methods
|
50
|
+
elsif method_str.end_with?("?")
|
51
|
+
true # Always respond to predicate methods
|
52
|
+
elsif method_str.end_with?("=")
|
53
|
+
true # Always respond to setter methods
|
54
|
+
else
|
55
|
+
true # Always respond to getter methods (they return nil if missing)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def to_h
|
60
|
+
@hash
|
61
|
+
end
|
62
|
+
|
63
|
+
def keys
|
64
|
+
@hash.keys
|
65
|
+
end
|
66
|
+
|
67
|
+
def empty?
|
68
|
+
@hash.empty?
|
69
|
+
end
|
70
|
+
|
71
|
+
def each(&block)
|
72
|
+
@hash.each(&block)
|
73
|
+
end
|
74
|
+
|
75
|
+
def to_s
|
76
|
+
@hash.to_s
|
77
|
+
end
|
78
|
+
|
79
|
+
def inspect
|
80
|
+
@hash.inspect
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_json(*args)
|
84
|
+
@hash.to_json(*args)
|
85
|
+
end
|
86
|
+
|
87
|
+
def merge(other)
|
88
|
+
merged_hash = @hash.dup
|
89
|
+
other_hash = other.is_a?(DotAccessHash) ? other.to_h : other
|
90
|
+
merged_hash.merge!(other_hash)
|
91
|
+
DotAccessHash.new(merged_hash)
|
92
|
+
end
|
93
|
+
|
94
|
+
def values
|
95
|
+
@hash.values
|
96
|
+
end
|
97
|
+
|
98
|
+
def key?(key)
|
99
|
+
has_key?(key) # rubocop:disable Style/PreferredHashMethods
|
100
|
+
end
|
101
|
+
|
102
|
+
def include?(key)
|
103
|
+
has_key?(key) # rubocop:disable Style/PreferredHashMethods
|
104
|
+
end
|
105
|
+
|
106
|
+
def fetch(key, *args)
|
107
|
+
if has_key?(key) # rubocop:disable Style/PreferredHashMethods
|
108
|
+
self[key]
|
109
|
+
elsif block_given?
|
110
|
+
yield(key)
|
111
|
+
elsif !args.empty?
|
112
|
+
args[0]
|
113
|
+
else
|
114
|
+
raise KeyError, "key not found: #{key.inspect}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def dig(*keys)
|
119
|
+
keys.inject(self) do |obj, key|
|
120
|
+
break nil unless obj.is_a?(DotAccessHash) || obj.is_a?(Hash)
|
121
|
+
|
122
|
+
if obj.is_a?(DotAccessHash)
|
123
|
+
obj[key]
|
124
|
+
else
|
125
|
+
obj[key.to_sym] || obj[key.to_s]
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def size
|
131
|
+
@hash.size
|
132
|
+
end
|
133
|
+
|
134
|
+
alias_method :length, :size
|
135
|
+
|
136
|
+
def map(&block)
|
137
|
+
@hash.map(&block)
|
138
|
+
end
|
139
|
+
|
140
|
+
def select(&block)
|
141
|
+
DotAccessHash.new(@hash.select(&block))
|
142
|
+
end
|
143
|
+
|
144
|
+
def reject(&block)
|
145
|
+
DotAccessHash.new(@hash.reject(&block))
|
146
|
+
end
|
147
|
+
|
148
|
+
def compact
|
149
|
+
DotAccessHash.new(@hash.compact)
|
150
|
+
end
|
151
|
+
|
152
|
+
def slice(*keys)
|
153
|
+
sliced = {}
|
154
|
+
keys.each do |key|
|
155
|
+
if has_key?(key) # rubocop:disable Style/PreferredHashMethods
|
156
|
+
sliced[key.to_sym] = @hash[key.to_sym] || @hash[key.to_s]
|
157
|
+
end
|
158
|
+
end
|
159
|
+
DotAccessHash.new(sliced)
|
160
|
+
end
|
161
|
+
|
162
|
+
def except(*keys)
|
163
|
+
excluded = @hash.dup
|
164
|
+
keys.each do |key|
|
165
|
+
excluded.delete(key.to_sym)
|
166
|
+
excluded.delete(key.to_s)
|
167
|
+
end
|
168
|
+
DotAccessHash.new(excluded)
|
169
|
+
end
|
170
|
+
|
171
|
+
def delete(key)
|
172
|
+
@hash.delete(key.to_sym) || @hash.delete(key.to_s)
|
173
|
+
end
|
174
|
+
|
175
|
+
def clear
|
176
|
+
@hash.clear
|
177
|
+
self
|
178
|
+
end
|
179
|
+
|
180
|
+
def ==(other)
|
181
|
+
case other
|
182
|
+
when DotAccessHash
|
183
|
+
@hash == other.instance_variable_get(:@hash)
|
184
|
+
when Hash
|
185
|
+
@hash == other
|
186
|
+
else
|
187
|
+
false
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def has_key?(key_name)
|
192
|
+
@hash.key?(key_name.to_sym) || @hash.key?(key_name.to_s)
|
193
|
+
end
|
194
|
+
|
195
|
+
alias_method :member?, :has_key?
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Executes steps for each item in a collection
|
6
|
+
class EachStep < BaseIterationStep
|
7
|
+
attr_reader :collection_expr, :variable_name
|
8
|
+
|
9
|
+
def initialize(workflow, collection_expr:, variable_name:, steps:, **kwargs)
|
10
|
+
super(workflow, steps: steps, **kwargs)
|
11
|
+
@collection_expr = collection_expr
|
12
|
+
@variable_name = variable_name
|
13
|
+
end
|
14
|
+
|
15
|
+
def call
|
16
|
+
# Process the collection expression with appropriate type coercion
|
17
|
+
collection = process_iteration_input(@collection_expr, workflow, coerce_to: :iterable)
|
18
|
+
|
19
|
+
unless collection.respond_to?(:each)
|
20
|
+
$stderr.puts "Error: Collection '#{@collection_expr}' is not iterable"
|
21
|
+
raise ArgumentError, "Collection '#{@collection_expr}' is not iterable"
|
22
|
+
end
|
23
|
+
|
24
|
+
results = []
|
25
|
+
$stderr.puts "Starting each loop over collection with #{collection.size} items"
|
26
|
+
|
27
|
+
# Iterate over the collection
|
28
|
+
collection.each_with_index do |item, index|
|
29
|
+
$stderr.puts "Each loop iteration #{index + 1} with #{@variable_name}=#{item.inspect}"
|
30
|
+
|
31
|
+
# Create a context with the current item as a variable
|
32
|
+
define_iteration_variable(item)
|
33
|
+
|
34
|
+
# Execute the nested steps
|
35
|
+
step_results = execute_nested_steps(@steps, workflow)
|
36
|
+
results << step_results
|
37
|
+
|
38
|
+
# Save state after each iteration if the workflow supports it
|
39
|
+
save_iteration_state(index, item) if workflow.respond_to?(:session_name) && workflow.session_name
|
40
|
+
end
|
41
|
+
|
42
|
+
$stderr.puts "Each loop completed with #{collection.size} iterations"
|
43
|
+
results
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Keep for backward compatibility, deprecated
|
49
|
+
def resolve_collection
|
50
|
+
process_iteration_input(@collection_expr, workflow, coerce_to: :iterable)
|
51
|
+
end
|
52
|
+
|
53
|
+
def define_iteration_variable(value)
|
54
|
+
# Set the variable in the workflow's context
|
55
|
+
workflow.instance_variable_set("@#{@variable_name}", value)
|
56
|
+
|
57
|
+
# Define a getter method for the variable
|
58
|
+
var_name = @variable_name.to_sym
|
59
|
+
workflow.singleton_class.class_eval do
|
60
|
+
attr_reader(var_name)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Make the variable accessible in the output hash
|
64
|
+
workflow.output[@variable_name] = value if workflow.respond_to?(:output)
|
65
|
+
end
|
66
|
+
|
67
|
+
def save_iteration_state(index, item)
|
68
|
+
state_repository = FileStateRepository.new
|
69
|
+
|
70
|
+
# Save the current iteration state
|
71
|
+
state_data = {
|
72
|
+
step_name: name,
|
73
|
+
iteration_index: index,
|
74
|
+
current_item: item,
|
75
|
+
output: workflow.respond_to?(:output) ? workflow.output.clone : {},
|
76
|
+
transcript: workflow.respond_to?(:transcript) ? workflow.transcript.map(&:itself) : [],
|
77
|
+
}
|
78
|
+
|
79
|
+
state_repository.save_state(workflow, "#{name}_item_#{index}", state_data)
|
80
|
+
rescue => e
|
81
|
+
# Don't fail the workflow if state saving fails
|
82
|
+
$stderr.puts "Warning: Failed to save iteration state: #{e.message}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/notifications"
|
4
|
+
require "roast/helpers/logger"
|
5
|
+
require "roast/workflow/command_executor"
|
6
|
+
|
7
|
+
module Roast
|
8
|
+
module Workflow
|
9
|
+
# Handles error logging and instrumentation for workflow execution
|
10
|
+
class ErrorHandler
|
11
|
+
def initialize
|
12
|
+
# Use the Roast logger singleton
|
13
|
+
end
|
14
|
+
|
15
|
+
def with_error_handling(step_name, resource_type: nil)
|
16
|
+
start_time = Time.now
|
17
|
+
|
18
|
+
ActiveSupport::Notifications.instrument("roast.step.start", {
|
19
|
+
step_name: step_name,
|
20
|
+
resource_type: resource_type,
|
21
|
+
})
|
22
|
+
|
23
|
+
result = yield
|
24
|
+
|
25
|
+
execution_time = Time.now - start_time
|
26
|
+
|
27
|
+
ActiveSupport::Notifications.instrument("roast.step.complete", {
|
28
|
+
step_name: step_name,
|
29
|
+
resource_type: resource_type,
|
30
|
+
success: true,
|
31
|
+
execution_time: execution_time,
|
32
|
+
result_size: result.to_s.length,
|
33
|
+
})
|
34
|
+
|
35
|
+
result
|
36
|
+
rescue WorkflowExecutor::WorkflowExecutorError => e
|
37
|
+
handle_workflow_error(e, step_name, resource_type, start_time)
|
38
|
+
raise
|
39
|
+
rescue CommandExecutor::CommandExecutionError => e
|
40
|
+
handle_workflow_error(e, step_name, resource_type, start_time)
|
41
|
+
raise
|
42
|
+
rescue => e
|
43
|
+
handle_generic_error(e, step_name, resource_type, start_time)
|
44
|
+
end
|
45
|
+
|
46
|
+
def log_error(message)
|
47
|
+
Roast::Helpers::Logger.error(message)
|
48
|
+
end
|
49
|
+
|
50
|
+
def log_warning(message)
|
51
|
+
Roast::Helpers::Logger.warn(message)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Alias methods for compatibility
|
55
|
+
def error(message)
|
56
|
+
log_error(message)
|
57
|
+
end
|
58
|
+
|
59
|
+
def warn(message)
|
60
|
+
log_warning(message)
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def handle_workflow_error(error, step_name, resource_type, start_time)
|
66
|
+
execution_time = Time.now - start_time
|
67
|
+
|
68
|
+
ActiveSupport::Notifications.instrument("roast.step.error", {
|
69
|
+
step_name: step_name,
|
70
|
+
resource_type: resource_type,
|
71
|
+
error: error.class.name,
|
72
|
+
message: error.message,
|
73
|
+
execution_time: execution_time,
|
74
|
+
})
|
75
|
+
end
|
76
|
+
|
77
|
+
def handle_generic_error(error, step_name, resource_type, start_time)
|
78
|
+
execution_time = Time.now - start_time
|
79
|
+
|
80
|
+
ActiveSupport::Notifications.instrument("roast.step.error", {
|
81
|
+
step_name: step_name,
|
82
|
+
resource_type: resource_type,
|
83
|
+
error: error.class.name,
|
84
|
+
message: error.message,
|
85
|
+
execution_time: execution_time,
|
86
|
+
})
|
87
|
+
|
88
|
+
# Wrap the original error with context about which step failed
|
89
|
+
raise WorkflowExecutor::StepExecutionError.new(
|
90
|
+
"Failed to execute step '#{step_name}': #{error.message}",
|
91
|
+
step_name: step_name,
|
92
|
+
original_error: error,
|
93
|
+
)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Shared utilities for detecting and extracting expressions
|
6
|
+
module ExpressionUtils
|
7
|
+
# Check if the input is a Ruby expression in {{...}}
|
8
|
+
def ruby_expression?(input)
|
9
|
+
return false unless input.is_a?(String)
|
10
|
+
|
11
|
+
input.strip.start_with?("{{") && input.strip.end_with?("}}")
|
12
|
+
end
|
13
|
+
|
14
|
+
# Check if the input is a Bash command in $(...)
|
15
|
+
def bash_command?(input)
|
16
|
+
return false unless input.is_a?(String)
|
17
|
+
|
18
|
+
input.strip.start_with?("$(") && input.strip.end_with?(")")
|
19
|
+
end
|
20
|
+
|
21
|
+
# Extract the expression from {{...}}
|
22
|
+
def extract_expression(input)
|
23
|
+
return input unless ruby_expression?(input)
|
24
|
+
|
25
|
+
input.strip[2...-2].strip
|
26
|
+
end
|
27
|
+
|
28
|
+
# Extract the command from $(...)
|
29
|
+
def extract_command(input)
|
30
|
+
return input unless bash_command?(input)
|
31
|
+
|
32
|
+
input.strip[2...-1].strip
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -2,8 +2,8 @@
|
|
2
2
|
|
3
3
|
require "json"
|
4
4
|
require "fileutils"
|
5
|
-
|
6
|
-
|
5
|
+
require "roast/workflow/session_manager"
|
6
|
+
require "roast/workflow/state_repository"
|
7
7
|
|
8
8
|
module Roast
|
9
9
|
module Workflow
|
@@ -28,6 +28,7 @@ module Roast
|
|
28
28
|
timestamp: workflow.session_timestamp,
|
29
29
|
)
|
30
30
|
step_file = File.join(session_dir, format_step_filename(state_data[:order], step_name))
|
31
|
+
FileUtils.mkdir_p(File.dirname(step_file))
|
31
32
|
File.write(step_file, JSON.pretty_generate(state_data))
|
32
33
|
end
|
33
34
|
rescue => e
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
class Interpolator
|
6
|
+
def initialize(context, logger: nil)
|
7
|
+
@context = context
|
8
|
+
@logger = logger || NullLogger.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def interpolate(text)
|
12
|
+
return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
|
13
|
+
|
14
|
+
# Replace all {{expression}} with their evaluated values
|
15
|
+
text.gsub(/\{\{([^}]+)\}\}/) do |match|
|
16
|
+
expression = Regexp.last_match(1).strip
|
17
|
+
begin
|
18
|
+
# Evaluate the expression in the context
|
19
|
+
@context.instance_eval(expression).to_s
|
20
|
+
rescue => e
|
21
|
+
# Provide a detailed error message but preserve the original expression
|
22
|
+
error_msg = "Error interpolating {{#{expression}}}: #{e.message}. This variable is not defined in the workflow context."
|
23
|
+
@logger.error(error_msg)
|
24
|
+
match # Preserve the original expression in the string
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class NullLogger
|
30
|
+
def error(_message); end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Handles execution of iteration steps (repeat and each)
|
6
|
+
class IterationExecutor
|
7
|
+
def initialize(workflow, context_path, state_manager)
|
8
|
+
@workflow = workflow
|
9
|
+
@context_path = context_path
|
10
|
+
@state_manager = state_manager
|
11
|
+
end
|
12
|
+
|
13
|
+
def execute_repeat(repeat_config)
|
14
|
+
$stderr.puts "Executing repeat step: #{repeat_config.inspect}"
|
15
|
+
|
16
|
+
# Extract parameters from the repeat configuration
|
17
|
+
steps = repeat_config["steps"]
|
18
|
+
until_condition = repeat_config["until"]
|
19
|
+
max_iterations = repeat_config["max_iterations"] || BaseIterationStep::DEFAULT_MAX_ITERATIONS
|
20
|
+
|
21
|
+
# Verify required parameters
|
22
|
+
raise WorkflowExecutor::ConfigurationError, "Missing 'steps' in repeat configuration" unless steps
|
23
|
+
raise WorkflowExecutor::ConfigurationError, "Missing 'until' condition in repeat configuration" unless until_condition
|
24
|
+
|
25
|
+
# Create and execute a RepeatStep
|
26
|
+
require "roast/workflow/repeat_step" unless defined?(RepeatStep)
|
27
|
+
repeat_step = RepeatStep.new(
|
28
|
+
@workflow,
|
29
|
+
steps: steps,
|
30
|
+
until_condition: until_condition,
|
31
|
+
max_iterations: max_iterations,
|
32
|
+
name: "repeat_#{@workflow.output.size}",
|
33
|
+
context_path: @context_path,
|
34
|
+
)
|
35
|
+
|
36
|
+
results = repeat_step.call
|
37
|
+
|
38
|
+
# Store results in workflow output
|
39
|
+
step_name = "repeat_#{until_condition.gsub(/[^a-zA-Z0-9_]/, "_")}"
|
40
|
+
@workflow.output[step_name] = results
|
41
|
+
|
42
|
+
# Save state
|
43
|
+
@state_manager.save_state(step_name, results)
|
44
|
+
|
45
|
+
results
|
46
|
+
end
|
47
|
+
|
48
|
+
def execute_each(each_config)
|
49
|
+
$stderr.puts "Executing each step: #{each_config.inspect}"
|
50
|
+
|
51
|
+
# Extract parameters from the each configuration
|
52
|
+
collection_expr = each_config["each"]
|
53
|
+
variable_name = each_config["as"]
|
54
|
+
steps = each_config["steps"]
|
55
|
+
|
56
|
+
# Verify required parameters
|
57
|
+
raise WorkflowExecutor::ConfigurationError, "Missing collection expression in each configuration" unless collection_expr
|
58
|
+
raise WorkflowExecutor::ConfigurationError, "Missing 'as' variable name in each configuration" unless variable_name
|
59
|
+
raise WorkflowExecutor::ConfigurationError, "Missing 'steps' in each configuration" unless steps
|
60
|
+
|
61
|
+
# Create and execute an EachStep
|
62
|
+
require "roast/workflow/each_step" unless defined?(EachStep)
|
63
|
+
each_step = EachStep.new(
|
64
|
+
@workflow,
|
65
|
+
collection_expr: collection_expr,
|
66
|
+
variable_name: variable_name,
|
67
|
+
steps: steps,
|
68
|
+
name: "each_#{variable_name}",
|
69
|
+
context_path: @context_path,
|
70
|
+
)
|
71
|
+
|
72
|
+
results = each_step.call
|
73
|
+
|
74
|
+
# Store results in workflow output
|
75
|
+
step_name = "each_#{variable_name}"
|
76
|
+
@workflow.output[step_name] = results
|
77
|
+
|
78
|
+
# Save state
|
79
|
+
@state_manager.save_state(step_name, results)
|
80
|
+
|
81
|
+
results
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Handles intelligent coercion of LLM responses to boolean values
|
6
|
+
class LlmBooleanCoercer
|
7
|
+
# Patterns for detecting affirmative and negative responses
|
8
|
+
EXPLICIT_TRUE_PATTERN = /\A(yes|y|true|t|1)\z/i
|
9
|
+
EXPLICIT_FALSE_PATTERN = /\A(no|n|false|f|0)\z/i
|
10
|
+
AFFIRMATIVE_PATTERN = /\b(yes|true|correct|affirmative|confirmed|indeed|right|positive|agree|definitely|certainly|absolutely)\b/
|
11
|
+
NEGATIVE_PATTERN = /\b(no|false|incorrect|negative|denied|wrong|disagree|never)\b/
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Convert an LLM response to a boolean value
|
15
|
+
#
|
16
|
+
# @param result [Object] The value to coerce to boolean
|
17
|
+
# @return [Boolean] The coerced boolean value
|
18
|
+
def coerce(result)
|
19
|
+
return true if result.is_a?(TrueClass)
|
20
|
+
return false if result.is_a?(FalseClass) || result.nil?
|
21
|
+
|
22
|
+
text = result.to_s.downcase.strip
|
23
|
+
|
24
|
+
# Check for explicit boolean-like responses first
|
25
|
+
return true if text =~ EXPLICIT_TRUE_PATTERN
|
26
|
+
return false if text =~ EXPLICIT_FALSE_PATTERN
|
27
|
+
|
28
|
+
# Then check for these words within longer responses
|
29
|
+
has_affirmative = !!(text =~ AFFIRMATIVE_PATTERN)
|
30
|
+
has_negative = !!(text =~ NEGATIVE_PATTERN)
|
31
|
+
|
32
|
+
# Handle conflicts
|
33
|
+
if has_affirmative && has_negative
|
34
|
+
warn_ambiguity(result, "contains both affirmative and negative terms")
|
35
|
+
false
|
36
|
+
elsif has_affirmative
|
37
|
+
true
|
38
|
+
elsif has_negative
|
39
|
+
false
|
40
|
+
else
|
41
|
+
warn_ambiguity(result, "no clear boolean indicators found")
|
42
|
+
false
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Log a warning for ambiguous LLM boolean responses
|
49
|
+
def warn_ambiguity(result, reason)
|
50
|
+
$stderr.puts "Warning: Ambiguous LLM response for boolean conversion (#{reason}): '#{result.to_s.strip}'"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/file_state_repository"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
# Handles output operations for workflows including saving final output and results
|
8
|
+
class OutputHandler
|
9
|
+
def save_final_output(workflow)
|
10
|
+
return unless workflow.respond_to?(:session_name) && workflow.session_name && workflow.respond_to?(:final_output)
|
11
|
+
|
12
|
+
begin
|
13
|
+
final_output = workflow.final_output.to_s
|
14
|
+
return if final_output.empty?
|
15
|
+
|
16
|
+
state_repository = FileStateRepository.new
|
17
|
+
output_file = state_repository.save_final_output(workflow, final_output)
|
18
|
+
$stderr.puts "Final output saved to: #{output_file}" if output_file
|
19
|
+
rescue => e
|
20
|
+
# Don't fail if saving output fails
|
21
|
+
$stderr.puts "Warning: Failed to save final output to session: #{e.message}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def write_results(workflow)
|
26
|
+
if workflow.output_file
|
27
|
+
File.write(workflow.output_file, workflow.final_output)
|
28
|
+
$stdout.puts "Results saved to #{workflow.output_file}"
|
29
|
+
else
|
30
|
+
$stdout.puts workflow.final_output
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|