roast-ai 0.1.7 → 0.2.1

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +49 -1
  4. data/CLAUDE.md +20 -0
  5. data/CLAUDE_NOTES.md +68 -0
  6. data/Gemfile +1 -0
  7. data/Gemfile.lock +9 -6
  8. data/README.md +159 -26
  9. data/bin/roast +27 -0
  10. data/docs/ITERATION_SYNTAX.md +147 -0
  11. data/examples/case_when/README.md +58 -0
  12. data/examples/case_when/detect_language/prompt.md +16 -0
  13. data/examples/case_when/workflow.yml +58 -0
  14. data/examples/conditional/README.md +161 -0
  15. data/examples/conditional/check_condition/prompt.md +1 -0
  16. data/examples/conditional/simple_workflow.yml +15 -0
  17. data/examples/conditional/workflow.yml +23 -0
  18. data/examples/direct_coerce_syntax/README.md +32 -0
  19. data/examples/direct_coerce_syntax/workflow.yml +36 -0
  20. data/examples/dot_notation/README.md +37 -0
  21. data/examples/dot_notation/workflow.yml +44 -0
  22. data/examples/exit_on_error/README.md +50 -0
  23. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  24. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  25. data/examples/exit_on_error/workflow.yml +19 -0
  26. data/examples/grading/workflow.yml +10 -4
  27. data/examples/iteration/IMPLEMENTATION.md +88 -0
  28. data/examples/iteration/README.md +68 -0
  29. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  30. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  31. data/examples/iteration/generate_report/prompt.md +129 -0
  32. data/examples/iteration/implement_fix/prompt.md +25 -0
  33. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  34. data/examples/iteration/prompts/analyze_file.md +28 -0
  35. data/examples/iteration/prompts/generate_summary.md +24 -0
  36. data/examples/iteration/prompts/update_report.md +29 -0
  37. data/examples/iteration/prompts/write_report.md +22 -0
  38. data/examples/iteration/read_file/prompt.md +9 -0
  39. data/examples/iteration/select_next_issue/prompt.md +25 -0
  40. data/examples/iteration/simple_workflow.md +39 -0
  41. data/examples/iteration/simple_workflow.yml +58 -0
  42. data/examples/iteration/update_fix_count/prompt.md +26 -0
  43. data/examples/iteration/verify_fix/prompt.md +29 -0
  44. data/examples/iteration/workflow.yml +42 -0
  45. data/examples/json_handling/README.md +32 -0
  46. data/examples/json_handling/workflow.yml +52 -0
  47. data/examples/openrouter_example/workflow.yml +2 -2
  48. data/examples/smart_coercion_defaults/README.md +65 -0
  49. data/examples/smart_coercion_defaults/workflow.yml +44 -0
  50. data/examples/step_configuration/README.md +87 -0
  51. data/examples/step_configuration/workflow.yml +60 -0
  52. data/examples/workflow_generator/README.md +27 -0
  53. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  54. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  55. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  56. data/examples/workflow_generator/info_from_roast.rb +22 -0
  57. data/examples/workflow_generator/workflow.yml +35 -0
  58. data/lib/roast/errors.rb +9 -0
  59. data/lib/roast/factories/api_provider_factory.rb +61 -0
  60. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  61. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  62. data/lib/roast/helpers/prompt_loader.rb +50 -1
  63. data/lib/roast/resources/base_resource.rb +7 -0
  64. data/lib/roast/resources.rb +6 -6
  65. data/lib/roast/tools/ask_user.rb +40 -0
  66. data/lib/roast/tools/cmd.rb +1 -1
  67. data/lib/roast/tools/search_file.rb +1 -1
  68. data/lib/roast/tools.rb +11 -1
  69. data/lib/roast/value_objects/api_token.rb +49 -0
  70. data/lib/roast/value_objects/step_name.rb +39 -0
  71. data/lib/roast/value_objects/workflow_path.rb +77 -0
  72. data/lib/roast/value_objects.rb +5 -0
  73. data/lib/roast/version.rb +1 -1
  74. data/lib/roast/workflow/api_configuration.rb +61 -0
  75. data/lib/roast/workflow/base_iteration_step.rb +184 -0
  76. data/lib/roast/workflow/base_step.rb +44 -27
  77. data/lib/roast/workflow/base_workflow.rb +76 -73
  78. data/lib/roast/workflow/case_executor.rb +49 -0
  79. data/lib/roast/workflow/case_step.rb +82 -0
  80. data/lib/roast/workflow/command_executor.rb +88 -0
  81. data/lib/roast/workflow/conditional_executor.rb +50 -0
  82. data/lib/roast/workflow/conditional_step.rb +59 -0
  83. data/lib/roast/workflow/configuration.rb +35 -158
  84. data/lib/roast/workflow/configuration_loader.rb +78 -0
  85. data/lib/roast/workflow/configuration_parser.rb +13 -248
  86. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  87. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  88. data/lib/roast/workflow/each_step.rb +86 -0
  89. data/lib/roast/workflow/error_handler.rb +97 -0
  90. data/lib/roast/workflow/expression_evaluator.rb +78 -0
  91. data/lib/roast/workflow/expression_utils.rb +36 -0
  92. data/lib/roast/workflow/file_state_repository.rb +3 -2
  93. data/lib/roast/workflow/interpolator.rb +34 -0
  94. data/lib/roast/workflow/iteration_executor.rb +103 -0
  95. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  96. data/lib/roast/workflow/output_handler.rb +35 -0
  97. data/lib/roast/workflow/output_manager.rb +77 -0
  98. data/lib/roast/workflow/parallel_executor.rb +49 -0
  99. data/lib/roast/workflow/prompt_step.rb +4 -1
  100. data/lib/roast/workflow/repeat_step.rb +75 -0
  101. data/lib/roast/workflow/replay_handler.rb +123 -0
  102. data/lib/roast/workflow/resource_resolver.rb +77 -0
  103. data/lib/roast/workflow/session_manager.rb +6 -2
  104. data/lib/roast/workflow/state_manager.rb +97 -0
  105. data/lib/roast/workflow/step_executor_coordinator.rb +221 -0
  106. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  107. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  108. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  109. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  110. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  111. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  112. data/lib/roast/workflow/step_finder.rb +97 -0
  113. data/lib/roast/workflow/step_loader.rb +155 -0
  114. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  115. data/lib/roast/workflow/step_runner.rb +23 -0
  116. data/lib/roast/workflow/step_type_resolver.rb +133 -0
  117. data/lib/roast/workflow/workflow_context.rb +60 -0
  118. data/lib/roast/workflow/workflow_executor.rb +90 -209
  119. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  120. data/lib/roast/workflow/workflow_runner.rb +87 -0
  121. data/lib/roast/workflow.rb +3 -0
  122. data/lib/roast.rb +96 -3
  123. data/roast.gemspec +2 -1
  124. data/schema/workflow.json +112 -0
  125. metadata +112 -4
@@ -0,0 +1,184 @@
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
+ # Default to regular boolean for ruby expressions
30
+ coerce_to ||= :boolean
31
+ process_ruby_expression(input, context, coerce_to)
32
+ elsif bash_command?(input)
33
+ # Default to boolean (which will interpret exit code) for bash commands
34
+ coerce_to ||= :boolean
35
+ process_bash_command(input, coerce_to)
36
+ else
37
+ # For prompts/steps, default to llm_boolean
38
+ coerce_to ||= :llm_boolean
39
+ process_step_or_prompt(input, context, coerce_to)
40
+ end
41
+ else
42
+ # Non-string inputs default to regular boolean
43
+ coerce_to ||= :boolean
44
+ coerce_result(input, coerce_to)
45
+ end
46
+ end
47
+
48
+ # Interpolates {{expression}} in a string with values from the workflow context
49
+ def interpolate_expression(text, context)
50
+ return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
51
+
52
+ # Replace all {{expression}} with their evaluated values
53
+ text.gsub(/\{\{([^}]+)\}\}/) do |match|
54
+ expression = extract_expression(match)
55
+ begin
56
+ # Evaluate the expression in the workflow's context
57
+ result = context.instance_eval(expression)
58
+ result.inspect # Convert to string representation
59
+ rescue => e
60
+ warn_interpolation_error(expression, e)
61
+ match # Return the original match to preserve it in the string
62
+ end
63
+ end
64
+ end
65
+
66
+ # Execute nested steps
67
+ def execute_nested_steps(steps, context, executor = nil)
68
+ executor ||= WorkflowExecutor.new(context, {}, context_path)
69
+ results = []
70
+
71
+ steps.each do |step|
72
+ result = case step
73
+ when String
74
+ executor.execute_step(step)
75
+ when Hash, Array
76
+ executor.execute_steps([step])
77
+ end
78
+ results << result
79
+ end
80
+
81
+ results
82
+ end
83
+
84
+ private
85
+
86
+ # Process a Ruby expression
87
+ def process_ruby_expression(input, context, coerce_to)
88
+ expression = extract_expression(input)
89
+ result = evaluate_ruby_expression(expression, context)
90
+ coerce_result(result, coerce_to)
91
+ end
92
+
93
+ # Process a Bash command
94
+ def process_bash_command(input, coerce_to)
95
+ command = extract_command(input)
96
+ execute_command(command, coerce_to)
97
+ end
98
+
99
+ # Process a step name or prompt
100
+ def process_step_or_prompt(input, context, coerce_to)
101
+ step_result = execute_step_by_name(input, context)
102
+ coerce_result(step_result, coerce_to)
103
+ end
104
+
105
+ # Execute a Ruby expression in the workflow context
106
+ def evaluate_ruby_expression(expression, context)
107
+ context.instance_eval(expression)
108
+ rescue => e
109
+ warn_expression_error(expression, e)
110
+ nil
111
+ end
112
+
113
+ # Execute a bash command and return its result
114
+ def execute_command(command, coerce_to)
115
+ # Use the Cmd module to execute the command
116
+ result = Roast::Tools::Cmd.call(command)
117
+
118
+ if coerce_to == :boolean
119
+ # For boolean coercion, check if command was allowed and exit status was 0
120
+ if result.to_s.start_with?("Error: Command not allowed")
121
+ return false
122
+ end
123
+
124
+ # Parse exit status from the output
125
+ # The Cmd tool returns output in format: "Command: X\nExit status: Y\nOutput:\nZ"
126
+ if result =~ /Exit status: (\d+)/
127
+ exit_status = ::Regexp.last_match(1).to_i
128
+ exit_status == 0
129
+ else
130
+ # If we can't parse exit status, assume success if no error
131
+ !result.to_s.start_with?("Error")
132
+ end
133
+ else
134
+ # For other uses, return the output
135
+ result
136
+ end
137
+ end
138
+
139
+ # Execute a step by name and return its result
140
+ def execute_step_by_name(step_name, context)
141
+ # Reuse existing step execution logic
142
+ executor = WorkflowExecutor.new(context, {}, context_path)
143
+ executor.execute_step(step_name)
144
+ end
145
+
146
+ # Coerce results to the appropriate type
147
+ def coerce_result(result, coerce_to)
148
+ return coerce_to_boolean(result) if coerce_to == :boolean
149
+ return coerce_to_iterable(result) if coerce_to == :iterable
150
+ return coerce_to_llm_boolean(result) if coerce_to == :llm_boolean
151
+
152
+ # Default - return as is
153
+ result
154
+ end
155
+
156
+ # Force a value to boolean
157
+ def coerce_to_boolean(result)
158
+ !!result
159
+ end
160
+
161
+ # Ensure a value is iterable
162
+ def coerce_to_iterable(result)
163
+ return result if result.respond_to?(:each)
164
+
165
+ result.to_s.split("\n")
166
+ end
167
+
168
+ # Convert LLM response to boolean
169
+ def coerce_to_llm_boolean(result)
170
+ LlmBooleanCoercer.coerce(result)
171
+ end
172
+
173
+ # Log a warning for expression evaluation errors
174
+ def warn_expression_error(expression, error)
175
+ $stderr.puts "Warning: Error evaluating expression '#{expression}': #{error.message}"
176
+ end
177
+
178
+ # Log a warning for interpolation errors
179
+ def warn_interpolation_error(expression, error)
180
+ $stderr.puts "Warning: Error interpolating {{#{expression}}}: #{error.message}"
181
+ end
182
+ end
183
+ end
184
+ end
@@ -2,42 +2,56 @@
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
8
9
  class BaseStep
9
10
  extend Forwardable
10
11
 
11
- attr_accessor :model, :print_response, :auto_loop, :json, :params, :resource
12
+ attr_accessor :model, :print_response, :auto_loop, :json, :params, :resource, :coerce_to
12
13
  attr_reader :workflow, :name, :context_path
13
14
 
14
15
  def_delegator :workflow, :append_to_final_output
15
16
  def_delegator :workflow, :chat_completion
16
17
  def_delegator :workflow, :transcript
17
18
 
18
- def initialize(workflow, model: "anthropic:claude-3-7-sonnet", name: nil, context_path: nil, auto_loop: true)
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 || determine_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
26
27
  @params = {}
28
+ @coerce_to = nil
27
29
  @resource = workflow.resource if workflow.respond_to?(:resource)
28
30
  end
29
31
 
30
32
  def call
31
33
  prompt(read_sidecar_prompt)
32
- chat_completion(print_response:, auto_loop:, json:, params:)
34
+ result = chat_completion(print_response:, auto_loop:, json:, params:)
35
+
36
+ # Apply coercion if configured
37
+ apply_coercion(result)
33
38
  end
34
39
 
35
40
  protected
36
41
 
37
- 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|
42
+ def chat_completion(print_response: nil, auto_loop: nil, json: nil, params: nil)
43
+ # Use instance variables as defaults if parameters are not provided
44
+ print_response = @print_response if print_response.nil?
45
+ auto_loop = @auto_loop if auto_loop.nil?
46
+ json = @json if json.nil?
47
+ params = @params if params.nil?
48
+
49
+ workflow.chat_completion(openai: workflow.openai? && model, loop: auto_loop, model: model, json:, params:).then do |response|
39
50
  case response
51
+ in Array if json
52
+ response.flatten.first
40
53
  in Array
54
+ # For non-JSON responses, join array elements
41
55
  response.map(&:presence).compact.join("\n")
42
56
  else
43
57
  response
@@ -47,27 +61,6 @@ module Roast
47
61
  end
48
62
  end
49
63
 
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
64
  def prompt(text)
72
65
  transcript << { user: text }
73
66
  end
@@ -93,6 +86,30 @@ module Roast
93
86
  append_to_final_output(response)
94
87
  end
95
88
  end
89
+
90
+ private
91
+
92
+ def apply_coercion(result)
93
+ return result unless @coerce_to
94
+
95
+ case @coerce_to
96
+ when :boolean
97
+ # Simple boolean coercion
98
+ !!result
99
+ when :llm_boolean
100
+ # Use LLM boolean coercer for natural language responses
101
+ require "roast/workflow/llm_boolean_coercer"
102
+ LlmBooleanCoercer.coerce(result)
103
+ when :iterable
104
+ # Ensure result is iterable
105
+ return result if result.respond_to?(:each)
106
+
107
+ result.to_s.split("\n")
108
+ else
109
+ # Unknown coercion type, return as-is
110
+ result
111
+ end
112
+ end
96
113
  end
97
114
  end
98
115
  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 || determine_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
- def append_to_final_output(message)
52
- @final_output << message
53
- end
42
+ # Initialize managers
43
+ @output_manager = OutputManager.new
54
44
 
55
- def final_output
56
- return @final_output if @final_output.is_a?(String)
57
- return "" if @final_output.nil?
45
+ # Setup prompt and handlers
46
+ read_sidecar_prompt.then do |prompt|
47
+ next unless prompt
58
48
 
59
- # Handle array case (expected normal case)
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
- model = kwargs[:openai] || "default"
72
-
73
- ActiveSupport::Notifications.instrument("roast.chat_completion.start", {
74
- model: model,
75
- parameters: kwargs.except(:openai),
76
- })
77
-
78
- result = super(**kwargs)
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
- ActiveSupport::Notifications.instrument("roast.chat_completion.complete", {
82
- success: true,
83
- model: model,
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
- ActiveSupport::Notifications.instrument("roast.chat_completion.error", {
94
- error: e.class.name,
95
- message: e.message,
96
- model: model,
97
- parameters: kwargs.except(:openai),
98
- execution_time: execution_time,
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
- private
103
+ # Expose output manager for state management
104
+ attr_reader :output_manager
108
105
 
109
- # Determine the directory where the actual class is defined, not BaseWorkflow
110
- def determine_context_path
111
- # Get the actual class's source file
112
- klass = self.class
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
- # Fall back to the current file if we can't find it
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
- # Return directory containing the class definition
127
- File.dirname(path || __FILE__)
130
+ raise error
128
131
  end
129
132
 
130
133
  def read_sidecar_prompt
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Handles execution of case/when/else steps
6
+ class CaseExecutor
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_case(case_config)
15
+ $stderr.puts "Executing case step: #{case_config.inspect}"
16
+
17
+ # Extract case expression
18
+ case_expr = case_config["case"]
19
+ when_clauses = case_config["when"]
20
+ case_config["else"]
21
+
22
+ # Verify required parameters
23
+ raise WorkflowExecutor::ConfigurationError, "Missing 'case' expression in case configuration" unless case_expr
24
+ raise WorkflowExecutor::ConfigurationError, "Missing 'when' clauses in case configuration" unless when_clauses
25
+
26
+ # Create and execute a CaseStep
27
+ require "roast/workflow/case_step" unless defined?(Roast::Workflow::CaseStep)
28
+ case_step = CaseStep.new(
29
+ @workflow,
30
+ config: case_config,
31
+ name: "case_#{case_expr.to_s.gsub(/[^a-zA-Z0-9_]/, "_")[0..30]}",
32
+ context_path: @context_path,
33
+ workflow_executor: @workflow_executor,
34
+ )
35
+
36
+ result = case_step.call
37
+
38
+ # Store the result in workflow output
39
+ step_name = "case_#{case_expr.to_s.gsub(/[^a-zA-Z0-9_]/, "_")[0..30]}"
40
+ @workflow.output[step_name] = result
41
+
42
+ # Save state
43
+ @state_manager.save_state(step_name, @workflow.output[step_name])
44
+
45
+ result
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/base_step"
4
+ require "roast/workflow/expression_evaluator"
5
+ require "roast/workflow/interpolator"
6
+
7
+ module Roast
8
+ module Workflow
9
+ class CaseStep < BaseStep
10
+ include ExpressionEvaluator
11
+
12
+ def initialize(workflow, config:, name:, context_path:, workflow_executor:, **kwargs)
13
+ super(workflow, name: name, context_path: context_path, **kwargs)
14
+
15
+ @config = config
16
+ @case_expression = config["case"]
17
+ @when_clauses = config["when"] || {}
18
+ @else_steps = config["else"] || []
19
+ @workflow_executor = workflow_executor
20
+ end
21
+
22
+ def call
23
+ # Evaluate the case expression to get the value to match against
24
+ case_value = evaluate_case_expression(@case_expression)
25
+
26
+ # Find the matching when clause
27
+ matched_key = find_matching_when_clause(case_value)
28
+
29
+ # Determine which steps to execute
30
+ steps_to_execute = if matched_key
31
+ @when_clauses[matched_key]
32
+ else
33
+ @else_steps
34
+ end
35
+
36
+ # Execute the selected steps
37
+ unless steps_to_execute.nil? || steps_to_execute.empty?
38
+ @workflow_executor.execute_steps(steps_to_execute)
39
+ end
40
+
41
+ # Return a result indicating which branch was taken
42
+ {
43
+ case_value: case_value,
44
+ matched_when: matched_key,
45
+ branch_executed: matched_key || (steps_to_execute.empty? ? "none" : "else"),
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def evaluate_case_expression(expression)
52
+ return unless expression
53
+
54
+ # Handle interpolated expressions
55
+ if expression.is_a?(String)
56
+ interpolated = Interpolator.new(@workflow).interpolate(expression)
57
+
58
+ if ruby_expression?(interpolated)
59
+ evaluate_ruby_expression(interpolated)
60
+ elsif bash_command?(interpolated)
61
+ evaluate_bash_command(interpolated, for_condition: false)
62
+ else
63
+ # Return the interpolated value as-is
64
+ interpolated
65
+ end
66
+ else
67
+ expression
68
+ end
69
+ end
70
+
71
+ def find_matching_when_clause(case_value)
72
+ # Convert case_value to string for comparison
73
+ case_value_str = case_value.to_s
74
+
75
+ @when_clauses.keys.find do |when_key|
76
+ # Direct string comparison
77
+ when_key.to_s == case_value_str
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end