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,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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/command_executor"
4
+ require "roast/workflow/expression_utils"
5
+
6
+ module Roast
7
+ module Workflow
8
+ # Shared module for evaluating expressions in workflow steps
9
+ module ExpressionEvaluator
10
+ include ExpressionUtils
11
+
12
+ # Evaluate a Ruby expression in the workflow context
13
+ # @param expression [String] The expression to evaluate
14
+ # @return [Object] The result of the expression
15
+ def evaluate_ruby_expression(expression)
16
+ expr = extract_expression(expression)
17
+ begin
18
+ @workflow.instance_eval(expr)
19
+ rescue => e
20
+ $stderr.puts "Warning: Error evaluating expression '#{expr}': #{e.message}"
21
+ nil
22
+ end
23
+ end
24
+
25
+ # Evaluate a bash command and return its output
26
+ # @param command [String] The command to execute
27
+ # @param for_condition [Boolean] If true, returns success status; if false, returns output
28
+ # @return [Boolean, String, nil] Command result based on for_condition flag
29
+ def evaluate_bash_command(command, for_condition: false)
30
+ cmd = command.start_with?("$(") ? command : extract_command(command)
31
+ executor = CommandExecutor.new(logger: Roast::Helpers::Logger)
32
+
33
+ begin
34
+ output = executor.execute(cmd, exit_on_error: false)
35
+
36
+ if for_condition
37
+ # For conditions, we care about the exit status (success = true)
38
+ # Check if output contains exit status marker
39
+ !output.include?("[Exit status:")
40
+ else
41
+ # For case expressions, we want the actual output
42
+ output.strip
43
+ end
44
+ rescue => e
45
+ $stderr.puts "Warning: Error executing command '#{cmd}': #{e.message}"
46
+ for_condition ? false : nil
47
+ end
48
+ end
49
+
50
+ # Evaluate a step reference or direct value
51
+ # @param input [String] The input to evaluate
52
+ # @return [Boolean, Object] The result for conditions, or the value itself
53
+ def evaluate_step_or_value(input, for_condition: false)
54
+ # Check if it's a reference to a previous step output
55
+ if @workflow.output.key?(input)
56
+ result = @workflow.output[input]
57
+
58
+ if for_condition
59
+ # Coerce to boolean for conditions
60
+ return false if result.nil? || result == false || result == "" || result == "false"
61
+
62
+ return true
63
+ else
64
+ # Return the actual value for case expressions
65
+ return result
66
+ end
67
+ end
68
+
69
+ # Otherwise treat as a direct value
70
+ if for_condition
71
+ input.to_s.downcase == "true"
72
+ else
73
+ input
74
+ end
75
+ end
76
+ end
77
+ end
78
+ 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,103 @@
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
+ # Apply configuration if provided
37
+ apply_step_configuration(repeat_step, repeat_config)
38
+
39
+ results = repeat_step.call
40
+
41
+ # Store results in workflow output
42
+ step_name = "repeat_#{until_condition.gsub(/[^a-zA-Z0-9_]/, "_")}"
43
+ @workflow.output[step_name] = results
44
+
45
+ # Save state
46
+ @state_manager.save_state(step_name, results)
47
+
48
+ results
49
+ end
50
+
51
+ def execute_each(each_config)
52
+ $stderr.puts "Executing each step: #{each_config.inspect}"
53
+
54
+ # Extract parameters from the each configuration
55
+ collection_expr = each_config["each"]
56
+ variable_name = each_config["as"]
57
+ steps = each_config["steps"]
58
+
59
+ # Verify required parameters
60
+ raise WorkflowExecutor::ConfigurationError, "Missing collection expression in each configuration" unless collection_expr
61
+ raise WorkflowExecutor::ConfigurationError, "Missing 'as' variable name in each configuration" unless variable_name
62
+ raise WorkflowExecutor::ConfigurationError, "Missing 'steps' in each configuration" unless steps
63
+
64
+ # Create and execute an EachStep
65
+ require "roast/workflow/each_step" unless defined?(EachStep)
66
+ each_step = EachStep.new(
67
+ @workflow,
68
+ collection_expr: collection_expr,
69
+ variable_name: variable_name,
70
+ steps: steps,
71
+ name: "each_#{variable_name}",
72
+ context_path: @context_path,
73
+ )
74
+
75
+ # Apply configuration if provided
76
+ apply_step_configuration(each_step, each_config)
77
+
78
+ results = each_step.call
79
+
80
+ # Store results in workflow output
81
+ step_name = "each_#{variable_name}"
82
+ @workflow.output[step_name] = results
83
+
84
+ # Save state
85
+ @state_manager.save_state(step_name, results)
86
+
87
+ results
88
+ end
89
+
90
+ private
91
+
92
+ # Apply configuration settings to a step
93
+ def apply_step_configuration(step, step_config)
94
+ step.print_response = step_config["print_response"] if step_config.key?("print_response")
95
+ step.auto_loop = step_config["loop"] if step_config.key?("loop")
96
+ step.json = step_config["json"] if step_config.key?("json")
97
+ step.params = step_config["params"] if step_config.key?("params")
98
+ step.model = step_config["model"] if step_config.key?("model")
99
+ step.coerce_to = step_config["coerce_to"].to_sym if step_config.key?("coerce_to")
100
+ end
101
+ end
102
+ end
103
+ 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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+ require "roast/workflow/dot_access_hash"
5
+
6
+ module Roast
7
+ module Workflow
8
+ # Manages workflow output, including both the key-value output hash
9
+ # and the final output string/array
10
+ class OutputManager
11
+ def initialize
12
+ @output = ActiveSupport::HashWithIndifferentAccess.new
13
+ @output_wrapper = nil
14
+ @final_output = []
15
+ end
16
+
17
+ # Get output wrapped in DotAccessHash for dot notation access
18
+ def output
19
+ @output_wrapper ||= DotAccessHash.new(@output)
20
+ end
21
+
22
+ # Set output, ensuring it's always a HashWithIndifferentAccess
23
+ def output=(value)
24
+ @output = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
25
+ value
26
+ else
27
+ ActiveSupport::HashWithIndifferentAccess.new(value)
28
+ end
29
+ # Reset the wrapper when output changes
30
+ @output_wrapper = nil
31
+ end
32
+
33
+ # Append a message to the final output
34
+ def append_to_final_output(message)
35
+ @final_output << message
36
+ end
37
+
38
+ # Get the final output as a string
39
+ def final_output
40
+ return @final_output if @final_output.is_a?(String)
41
+ return "" if @final_output.nil?
42
+
43
+ # Handle array case (expected normal case)
44
+ if @final_output.respond_to?(:join)
45
+ @final_output.join("\n\n")
46
+ else
47
+ # Handle any other unexpected type by converting to string
48
+ @final_output.to_s
49
+ end
50
+ end
51
+
52
+ # Set the final output directly (used when loading from state)
53
+ attr_writer :final_output
54
+
55
+ # Get the raw output hash (for internal use)
56
+ def raw_output
57
+ @output
58
+ end
59
+
60
+ # Get a snapshot of the current state for persistence
61
+ def to_h
62
+ {
63
+ output: @output.to_h,
64
+ final_output: @final_output,
65
+ }
66
+ end
67
+
68
+ # Restore state from a hash
69
+ def from_h(data)
70
+ return unless data
71
+
72
+ self.output = data[:output] if data.key?(:output)
73
+ self.final_output = data[:final_output] if data.key?(:final_output)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Executes workflow steps in parallel using threads
6
+ class ParallelExecutor
7
+ class << self
8
+ def execute(steps, executor)
9
+ new(executor).execute(steps)
10
+ end
11
+ end
12
+
13
+ def initialize(executor)
14
+ @executor = executor
15
+ end
16
+
17
+ def execute(steps)
18
+ threads = steps.map do |sub_step|
19
+ Thread.new do
20
+ # Each thread needs its own isolated execution context
21
+ Thread.current[:step] = sub_step
22
+ Thread.current[:result] = nil
23
+ Thread.current[:error] = nil
24
+
25
+ begin
26
+ # Execute the single step in this thread
27
+ @executor.execute_steps([sub_step])
28
+ Thread.current[:result] = :success
29
+ rescue => e
30
+ Thread.current[:error] = e
31
+ end
32
+ end
33
+ end
34
+
35
+ # Wait for all threads to complete
36
+ threads.each(&:join)
37
+
38
+ # Check for errors in any thread
39
+ threads.each_with_index do |thread, _index|
40
+ if thread[:error]
41
+ raise thread[:error]
42
+ end
43
+ end
44
+
45
+ :success
46
+ end
47
+ end
48
+ end
49
+ end
@@ -9,7 +9,10 @@ module Roast
9
9
 
10
10
  def call
11
11
  prompt(name)
12
- chat_completion(auto_loop: false, print_response: true)
12
+ result = chat_completion
13
+
14
+ # Apply coercion if configured
15
+ apply_coercion(result)
13
16
  end
14
17
  end
15
18
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Executes steps repeatedly until a condition is met or max_iterations is reached
6
+ class RepeatStep < BaseIterationStep
7
+ attr_reader :until_condition, :max_iterations
8
+
9
+ def initialize(workflow, steps:, until_condition:, max_iterations: DEFAULT_MAX_ITERATIONS, **kwargs)
10
+ super(workflow, steps: steps, **kwargs)
11
+ @until_condition = until_condition
12
+ @max_iterations = max_iterations.to_i
13
+
14
+ # Ensure max_iterations is at least 1
15
+ @max_iterations = 1 if @max_iterations < 1
16
+ end
17
+
18
+ def call
19
+ iteration = 0
20
+ results = []
21
+
22
+ $stderr.puts "Starting repeat loop with max_iterations: #{@max_iterations}"
23
+
24
+ begin
25
+ # Loop until condition is met or max_iterations is reached
26
+ # Process the until_condition based on its type with configured coercion
27
+ until process_iteration_input(@until_condition, workflow, coerce_to: @coerce_to) || (iteration >= @max_iterations)
28
+ $stderr.puts "Repeat loop iteration #{iteration + 1}"
29
+
30
+ # Execute the nested steps
31
+ step_results = execute_nested_steps(@steps, workflow)
32
+ results << step_results
33
+
34
+ # Increment iteration counter
35
+ iteration += 1
36
+
37
+ # Save state after each iteration if the workflow supports it
38
+ save_iteration_state(iteration) if workflow.respond_to?(:session_name) && workflow.session_name
39
+ end
40
+
41
+ if iteration >= @max_iterations
42
+ $stderr.puts "Repeat loop reached maximum iterations (#{@max_iterations})"
43
+ else
44
+ $stderr.puts "Repeat loop condition satisfied after #{iteration} iterations"
45
+ end
46
+
47
+ # Return the results of all iterations
48
+ results
49
+ rescue => e
50
+ $stderr.puts "Error in repeat loop: #{e.message}"
51
+ raise
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def save_iteration_state(iteration)
58
+ state_repository = FileStateRepository.new
59
+
60
+ # Save the current iteration count in the state
61
+ state_data = {
62
+ step_name: name,
63
+ iteration: iteration,
64
+ output: workflow.respond_to?(:output) ? workflow.output.clone : {},
65
+ transcript: workflow.respond_to?(:transcript) ? workflow.transcript.map(&:itself) : [],
66
+ }
67
+
68
+ state_repository.save_state(workflow, "#{name}_iteration_#{iteration}", state_data)
69
+ rescue => e
70
+ # Don't fail the workflow if state saving fails
71
+ $stderr.puts "Warning: Failed to save iteration state: #{e.message}"
72
+ end
73
+ end
74
+ end
75
+ end