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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +1 -1
- data/CHANGELOG.md +49 -1
- data/CLAUDE.md +20 -0
- data/CLAUDE_NOTES.md +68 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -6
- data/README.md +159 -26
- data/bin/roast +27 -0
- data/docs/ITERATION_SYNTAX.md +147 -0
- data/examples/case_when/README.md +58 -0
- data/examples/case_when/detect_language/prompt.md +16 -0
- data/examples/case_when/workflow.yml +58 -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/direct_coerce_syntax/README.md +32 -0
- data/examples/direct_coerce_syntax/workflow.yml +36 -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 +10 -4
- 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/json_handling/README.md +32 -0
- data/examples/json_handling/workflow.yml +52 -0
- data/examples/openrouter_example/workflow.yml +2 -2
- data/examples/smart_coercion_defaults/README.md +65 -0
- data/examples/smart_coercion_defaults/workflow.yml +44 -0
- data/examples/step_configuration/README.md +87 -0
- data/examples/step_configuration/workflow.yml +60 -0
- 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 +184 -0
- data/lib/roast/workflow/base_step.rb +44 -27
- data/lib/roast/workflow/base_workflow.rb +76 -73
- data/lib/roast/workflow/case_executor.rb +49 -0
- data/lib/roast/workflow/case_step.rb +82 -0
- 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 +59 -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_evaluator.rb +78 -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 +103 -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/prompt_step.rb +4 -1
- 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 +221 -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 +155 -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 +133 -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 +112 -0
- 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
|
-
|
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,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
|
@@ -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
|