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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +48 -0
  4. data/CLAUDE.md +20 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +11 -6
  7. data/README.md +225 -13
  8. data/bin/roast +27 -0
  9. data/docs/INSTRUMENTATION.md +42 -1
  10. data/docs/ITERATION_SYNTAX.md +119 -0
  11. data/examples/conditional/README.md +161 -0
  12. data/examples/conditional/check_condition/prompt.md +1 -0
  13. data/examples/conditional/simple_workflow.yml +15 -0
  14. data/examples/conditional/workflow.yml +23 -0
  15. data/examples/dot_notation/README.md +37 -0
  16. data/examples/dot_notation/workflow.yml +44 -0
  17. data/examples/exit_on_error/README.md +50 -0
  18. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  19. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  20. data/examples/exit_on_error/workflow.yml +19 -0
  21. data/examples/grading/workflow.yml +5 -1
  22. data/examples/iteration/IMPLEMENTATION.md +88 -0
  23. data/examples/iteration/README.md +68 -0
  24. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  25. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  26. data/examples/iteration/generate_report/prompt.md +129 -0
  27. data/examples/iteration/implement_fix/prompt.md +25 -0
  28. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  29. data/examples/iteration/prompts/analyze_file.md +28 -0
  30. data/examples/iteration/prompts/generate_summary.md +24 -0
  31. data/examples/iteration/prompts/update_report.md +29 -0
  32. data/examples/iteration/prompts/write_report.md +22 -0
  33. data/examples/iteration/read_file/prompt.md +9 -0
  34. data/examples/iteration/select_next_issue/prompt.md +25 -0
  35. data/examples/iteration/simple_workflow.md +39 -0
  36. data/examples/iteration/simple_workflow.yml +58 -0
  37. data/examples/iteration/update_fix_count/prompt.md +26 -0
  38. data/examples/iteration/verify_fix/prompt.md +29 -0
  39. data/examples/iteration/workflow.yml +42 -0
  40. data/examples/openrouter_example/workflow.yml +2 -2
  41. data/examples/workflow_generator/README.md +27 -0
  42. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  43. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  44. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  45. data/examples/workflow_generator/info_from_roast.rb +22 -0
  46. data/examples/workflow_generator/workflow.yml +35 -0
  47. data/lib/roast/errors.rb +9 -0
  48. data/lib/roast/factories/api_provider_factory.rb +61 -0
  49. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  50. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  51. data/lib/roast/helpers/prompt_loader.rb +50 -1
  52. data/lib/roast/resources/base_resource.rb +7 -0
  53. data/lib/roast/resources.rb +6 -6
  54. data/lib/roast/tools/ask_user.rb +40 -0
  55. data/lib/roast/tools/cmd.rb +1 -1
  56. data/lib/roast/tools/search_file.rb +1 -1
  57. data/lib/roast/tools/update_files.rb +413 -0
  58. data/lib/roast/tools.rb +12 -1
  59. data/lib/roast/value_objects/api_token.rb +49 -0
  60. data/lib/roast/value_objects/step_name.rb +39 -0
  61. data/lib/roast/value_objects/workflow_path.rb +77 -0
  62. data/lib/roast/value_objects.rb +5 -0
  63. data/lib/roast/version.rb +1 -1
  64. data/lib/roast/workflow/api_configuration.rb +61 -0
  65. data/lib/roast/workflow/base_iteration_step.rb +165 -0
  66. data/lib/roast/workflow/base_step.rb +4 -24
  67. data/lib/roast/workflow/base_workflow.rb +76 -73
  68. data/lib/roast/workflow/command_executor.rb +88 -0
  69. data/lib/roast/workflow/conditional_executor.rb +50 -0
  70. data/lib/roast/workflow/conditional_step.rb +96 -0
  71. data/lib/roast/workflow/configuration.rb +35 -158
  72. data/lib/roast/workflow/configuration_loader.rb +78 -0
  73. data/lib/roast/workflow/configuration_parser.rb +13 -248
  74. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  75. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  76. data/lib/roast/workflow/each_step.rb +86 -0
  77. data/lib/roast/workflow/error_handler.rb +97 -0
  78. data/lib/roast/workflow/expression_utils.rb +36 -0
  79. data/lib/roast/workflow/file_state_repository.rb +3 -2
  80. data/lib/roast/workflow/interpolator.rb +34 -0
  81. data/lib/roast/workflow/iteration_executor.rb +85 -0
  82. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  83. data/lib/roast/workflow/output_handler.rb +35 -0
  84. data/lib/roast/workflow/output_manager.rb +77 -0
  85. data/lib/roast/workflow/parallel_executor.rb +49 -0
  86. data/lib/roast/workflow/repeat_step.rb +75 -0
  87. data/lib/roast/workflow/replay_handler.rb +123 -0
  88. data/lib/roast/workflow/resource_resolver.rb +77 -0
  89. data/lib/roast/workflow/session_manager.rb +6 -2
  90. data/lib/roast/workflow/state_manager.rb +97 -0
  91. data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
  92. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  93. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  94. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  95. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  96. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  97. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  98. data/lib/roast/workflow/step_finder.rb +97 -0
  99. data/lib/roast/workflow/step_loader.rb +154 -0
  100. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  101. data/lib/roast/workflow/step_runner.rb +23 -0
  102. data/lib/roast/workflow/step_type_resolver.rb +117 -0
  103. data/lib/roast/workflow/workflow_context.rb +60 -0
  104. data/lib/roast/workflow/workflow_executor.rb +90 -209
  105. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  106. data/lib/roast/workflow/workflow_runner.rb +87 -0
  107. data/lib/roast/workflow.rb +3 -0
  108. data/lib/roast.rb +96 -3
  109. data/roast.gemspec +3 -1
  110. data/schema/workflow.json +85 -0
  111. 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
- require_relative "session_manager"
6
- require_relative "state_repository"
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