roast-ai 0.1.7 → 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 +40 -1
- data/CLAUDE.md +20 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -6
- data/README.md +81 -14
- data/bin/roast +27 -0
- 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.rb +11 -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 +2 -1
- data/schema/workflow.json +85 -0
- metadata +97 -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,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
|
@@ -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
|
@@ -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 Boolean coercion
|
27
|
+
until process_iteration_input(@until_condition, workflow, coerce_to: :boolean) || (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
|
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/step_finder"
|
4
|
+
require "roast/workflow/file_state_repository"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
# Handles replay functionality for workflows
|
9
|
+
# Manages skipping to specific steps and loading previous state
|
10
|
+
class ReplayHandler
|
11
|
+
attr_reader :processed
|
12
|
+
|
13
|
+
def initialize(workflow, state_repository: nil)
|
14
|
+
@workflow = workflow
|
15
|
+
@state_repository = state_repository || FileStateRepository.new
|
16
|
+
@processed = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def process_replay(steps, replay_option)
|
20
|
+
return steps unless replay_option && !@processed
|
21
|
+
|
22
|
+
timestamp, step_name = parse_replay_option(replay_option)
|
23
|
+
skip_index = StepFinder.find_index(steps, step_name)
|
24
|
+
|
25
|
+
if skip_index
|
26
|
+
$stderr.puts "Replaying from step: #{step_name}#{timestamp ? " (session: #{timestamp})" : ""}"
|
27
|
+
@workflow.session_timestamp = timestamp if timestamp && @workflow.respond_to?(:session_timestamp=)
|
28
|
+
steps = load_state_and_get_remaining_steps(steps, skip_index, step_name, timestamp)
|
29
|
+
else
|
30
|
+
$stderr.puts "Step #{step_name} not found in workflow, running from beginning"
|
31
|
+
end
|
32
|
+
|
33
|
+
@processed = true
|
34
|
+
steps
|
35
|
+
end
|
36
|
+
|
37
|
+
def load_state_and_restore(step_name, timestamp: nil)
|
38
|
+
state_data = if timestamp
|
39
|
+
$stderr.puts "Looking for state before '#{step_name}' in session #{timestamp}..."
|
40
|
+
@state_repository.load_state_before_step(@workflow, step_name, timestamp: timestamp)
|
41
|
+
else
|
42
|
+
$stderr.puts "Looking for state before '#{step_name}' in most recent session..."
|
43
|
+
@state_repository.load_state_before_step(@workflow, step_name)
|
44
|
+
end
|
45
|
+
|
46
|
+
if state_data
|
47
|
+
$stderr.puts "Successfully loaded state with data from previous step"
|
48
|
+
restore_workflow_state(state_data)
|
49
|
+
else
|
50
|
+
session_info = timestamp ? " in session #{timestamp}" : ""
|
51
|
+
$stderr.puts "Could not find suitable state data from a previous step to '#{step_name}'#{session_info}."
|
52
|
+
$stderr.puts "Will run workflow from '#{step_name}' without prior context."
|
53
|
+
end
|
54
|
+
|
55
|
+
state_data
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def parse_replay_option(replay_param)
|
61
|
+
return [nil, replay_param] unless replay_param.include?(":")
|
62
|
+
|
63
|
+
timestamp, step_name = replay_param.split(":", 2)
|
64
|
+
|
65
|
+
# Validate timestamp format (YYYYMMDD_HHMMSS_LLL)
|
66
|
+
unless timestamp.match?(/^\d{8}_\d{6}_\d{3}$/)
|
67
|
+
raise ArgumentError, "Invalid timestamp format: #{timestamp}. Expected YYYYMMDD_HHMMSS_LLL"
|
68
|
+
end
|
69
|
+
|
70
|
+
[timestamp, step_name]
|
71
|
+
end
|
72
|
+
|
73
|
+
def load_state_and_get_remaining_steps(steps, skip_index, step_name, timestamp)
|
74
|
+
load_state_and_restore(step_name, timestamp: timestamp)
|
75
|
+
# Always return steps from the requested index, regardless of state loading success
|
76
|
+
steps[skip_index..-1]
|
77
|
+
end
|
78
|
+
|
79
|
+
def restore_workflow_state(state_data)
|
80
|
+
return unless state_data && @workflow
|
81
|
+
|
82
|
+
restore_output(state_data)
|
83
|
+
restore_transcript(state_data)
|
84
|
+
restore_final_output(state_data)
|
85
|
+
end
|
86
|
+
|
87
|
+
def restore_output(state_data)
|
88
|
+
return unless state_data.key?(:output)
|
89
|
+
return unless @workflow.respond_to?(:output=)
|
90
|
+
|
91
|
+
@workflow.output = state_data[:output]
|
92
|
+
end
|
93
|
+
|
94
|
+
def restore_transcript(state_data)
|
95
|
+
return unless state_data.key?(:transcript)
|
96
|
+
return unless @workflow.respond_to?(:transcript)
|
97
|
+
|
98
|
+
# Transcript is an array from Raix::ChatCompletion
|
99
|
+
# We need to clear it and repopulate it
|
100
|
+
if @workflow.transcript.respond_to?(:clear) && @workflow.transcript.respond_to?(:<<)
|
101
|
+
@workflow.transcript.clear
|
102
|
+
state_data[:transcript].each do |message|
|
103
|
+
@workflow.transcript << message
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def restore_final_output(state_data)
|
109
|
+
return unless state_data.key?(:final_output)
|
110
|
+
|
111
|
+
# Make sure final_output is always handled as an array
|
112
|
+
final_output = state_data[:final_output]
|
113
|
+
final_output = [final_output] if final_output.is_a?(String)
|
114
|
+
|
115
|
+
if @workflow.respond_to?(:final_output=)
|
116
|
+
@workflow.final_output = final_output
|
117
|
+
elsif @workflow.instance_variable_defined?(:@final_output)
|
118
|
+
@workflow.instance_variable_set(:@final_output, final_output)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|