roast-ai 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/CLAUDE.md +3 -1
  4. data/Gemfile +1 -0
  5. data/Gemfile.lock +15 -10
  6. data/README.md +189 -20
  7. data/examples/bash_prototyping/README.md +53 -0
  8. data/examples/bash_prototyping/analyze_network/prompt.md +13 -0
  9. data/examples/bash_prototyping/analyze_system/prompt.md +11 -0
  10. data/examples/bash_prototyping/api_testing.yml +14 -0
  11. data/examples/bash_prototyping/check_processes/prompt.md +11 -0
  12. data/examples/bash_prototyping/generate_report/prompt.md +16 -0
  13. data/examples/bash_prototyping/process_json_response/prompt.md +24 -0
  14. data/examples/bash_prototyping/system_analysis.yml +14 -0
  15. data/examples/bash_prototyping/test_public_api/prompt.md +22 -0
  16. data/examples/cmd/README.md +99 -0
  17. data/examples/cmd/analyze_project/prompt.md +57 -0
  18. data/examples/cmd/basic_demo/prompt.md +48 -0
  19. data/examples/cmd/basic_workflow.yml +17 -0
  20. data/examples/cmd/check_repository/prompt.md +57 -0
  21. data/examples/cmd/create_and_verify/prompt.md +56 -0
  22. data/examples/cmd/dev_workflow.yml +26 -0
  23. data/examples/cmd/explore_project/prompt.md +67 -0
  24. data/examples/cmd/explorer_workflow.yml +21 -0
  25. data/examples/cmd/smart_tool_selection/prompt.md +99 -0
  26. data/examples/grading/read_dependencies/prompt.md +4 -2
  27. data/examples/grading/run_coverage.rb +9 -0
  28. data/examples/grading/workflow.yml +0 -2
  29. data/examples/mcp/README.md +223 -0
  30. data/examples/mcp/analyze_changes/prompt.md +8 -0
  31. data/examples/mcp/analyze_issues/prompt.md +4 -0
  32. data/examples/mcp/analyze_schema/prompt.md +4 -0
  33. data/examples/mcp/check_data_quality/prompt.md +5 -0
  34. data/examples/mcp/check_documentation/prompt.md +4 -0
  35. data/examples/mcp/create_recommendations/prompt.md +5 -0
  36. data/examples/mcp/database_workflow.yml +29 -0
  37. data/examples/mcp/env_demo/workflow.yml +34 -0
  38. data/examples/mcp/fetch_pr_context/prompt.md +4 -0
  39. data/examples/mcp/filesystem_demo/create_test_file/prompt.md +2 -0
  40. data/examples/mcp/filesystem_demo/list_files/prompt.md +6 -0
  41. data/examples/mcp/filesystem_demo/read_with_mcp/prompt.md +7 -0
  42. data/examples/mcp/filesystem_demo/workflow.yml +38 -0
  43. data/examples/mcp/generate_insights/prompt.md +4 -0
  44. data/examples/mcp/generate_report/prompt.md +6 -0
  45. data/examples/mcp/generate_review/prompt.md +16 -0
  46. data/examples/mcp/github_workflow.yml +32 -0
  47. data/examples/mcp/multi_mcp_workflow.yml +58 -0
  48. data/examples/mcp/post_review/prompt.md +3 -0
  49. data/examples/mcp/save_report/prompt.md +6 -0
  50. data/examples/mcp/search_issues/prompt.md +2 -0
  51. data/examples/mcp/summarize/prompt.md +1 -0
  52. data/examples/mcp/test_filesystem/prompt.md +6 -0
  53. data/examples/mcp/test_github/prompt.md +8 -0
  54. data/examples/mcp/test_read/prompt.md +1 -0
  55. data/examples/mcp/workflow.yml +35 -0
  56. data/examples/shared_config/README.md +52 -0
  57. data/examples/shared_config/example_with_shared_config/workflow.yml +6 -0
  58. data/examples/shared_config/shared.yml +7 -0
  59. data/examples/step_configuration/README.md +0 -3
  60. data/examples/step_configuration/workflow.yml +0 -3
  61. data/examples/tool_config_example/README.md +109 -0
  62. data/examples/tool_config_example/example_step/prompt.md +42 -0
  63. data/examples/tool_config_example/workflow.yml +17 -0
  64. data/examples/workflow_generator/workflow.yml +0 -1
  65. data/lib/roast/helpers/function_caching_interceptor.rb +0 -4
  66. data/lib/roast/helpers/prompt_loader.rb +0 -1
  67. data/lib/roast/tools/bash.rb +62 -0
  68. data/lib/roast/tools/cmd.rb +121 -34
  69. data/lib/roast/tools/coding_agent.rb +86 -7
  70. data/lib/roast/tools/helpers/coding_agent_message_formatter.rb +87 -0
  71. data/lib/roast/tools/search_file.rb +13 -1
  72. data/lib/roast/tools.rb +5 -5
  73. data/lib/roast/version.rb +1 -1
  74. data/lib/roast/workflow/base_iteration_step.rb +5 -4
  75. data/lib/roast/workflow/base_step.rb +30 -21
  76. data/lib/roast/workflow/base_workflow.rb +8 -10
  77. data/lib/roast/workflow/configuration.rb +12 -3
  78. data/lib/roast/workflow/configuration_loader.rb +63 -4
  79. data/lib/roast/workflow/configuration_parser.rb +0 -3
  80. data/lib/roast/workflow/error_handler.rb +0 -1
  81. data/lib/roast/workflow/file_state_repository.rb +0 -1
  82. data/lib/roast/workflow/iteration_executor.rb +4 -2
  83. data/lib/roast/workflow/output_manager.rb +0 -1
  84. data/lib/roast/workflow/step_executor_coordinator.rb +5 -3
  85. data/lib/roast/workflow/step_executors/hash_step_executor.rb +1 -1
  86. data/lib/roast/workflow/step_loader.rb +35 -8
  87. data/lib/roast/workflow/step_orchestrator.rb +4 -2
  88. data/lib/roast/workflow/workflow_execution_context.rb +0 -2
  89. data/lib/roast/workflow/workflow_executor.rb +2 -4
  90. data/lib/roast/workflow/workflow_initializer.rb +66 -2
  91. data/lib/roast/workflow/workflow_runner.rb +1 -2
  92. data/lib/roast.rb +8 -0
  93. data/package-lock.json +6 -0
  94. data/roast.gemspec +2 -1
  95. metadata +72 -3
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Roast
7
+ module Tools
8
+ module CodingAgent
9
+ module CodingAgentMessageFormatter
10
+ extend self
11
+
12
+ def format_messages(json)
13
+ messages = json.dig("message", "content")
14
+ messages.map(&method(:format_message))
15
+ end
16
+
17
+ private
18
+
19
+ def format_message(message)
20
+ case message["type"]
21
+ when "text"
22
+ format_text(message["text"])
23
+ when "tool_use"
24
+ name = message["name"]
25
+ input = message["input"].except("description", "old_string", "new_string")
26
+ case name
27
+ when "Task"
28
+ "→ #{name}#{format_task_input(input)}"
29
+ when "TodoWrite"
30
+ "→ #{name}#{format_todo_write_input(input)}"
31
+ when "Bash", "Read", "Edit"
32
+ "→ #{name}(#{format_arguments(input)})"
33
+ else
34
+ "→ #{name} #{format_text(input.to_yaml)}"
35
+ end
36
+ when "tool_result"
37
+ # Ignore these message types
38
+ else
39
+ message.except("id").to_yaml
40
+ end
41
+ end
42
+
43
+ def format_text(text)
44
+ text.lines.map do |line|
45
+ "\t#{line}"
46
+ end.join.lstrip
47
+ end
48
+
49
+ def format_task_input(input)
50
+ prompt = input["prompt"].lines.filter { |line| !line.blank? }.map { |line| "\t#{line}" }.join
51
+ args = format_arguments(input.except("prompt"))
52
+ "(#{args})\n#{prompt}"
53
+ end
54
+
55
+ def format_todo_write_input(input)
56
+ todos = input["todos"].map(&method(:format_todo_write_input_item)).join("\n")
57
+ args = format_arguments(input.except("todos"))
58
+ "(#{args})\n#{todos}"
59
+ end
60
+
61
+ def format_todo_write_input_item(item)
62
+ id = item["id"]
63
+ content = item["content"]
64
+ status = case item["status"]
65
+ when "pending"
66
+ "[ ]"
67
+ when "in_progress"
68
+ "[-]"
69
+ when "completed"
70
+ "[x]"
71
+ end
72
+ "\t#{id}. #{status} #{content}"
73
+ end
74
+
75
+ def format_arguments(arguments)
76
+ if arguments.length == 1
77
+ arguments.first[1].to_json
78
+ else
79
+ arguments.map do |key, value|
80
+ "#{key}: #{value.to_json}"
81
+ end.join(", ")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -15,7 +15,7 @@ module Roast
15
15
  :search_for_file,
16
16
  "Search for a file in the project using a glob pattern.",
17
17
  glob_pattern: { type: "string", description: "A glob pattern to search for. Example: 'test/**/*_test.rb'" },
18
- path: { type: "string", description: "path to search from" },
18
+ path: { type: "string", description: "path to search from", default: "." },
19
19
  ) do |params|
20
20
  Roast::Tools::SearchFile.call(params[:glob_pattern], params[:path]).tap do |result|
21
21
  Roast::Helpers::Logger.debug(result) if ENV["DEBUG"]
@@ -26,6 +26,18 @@ module Roast
26
26
  end
27
27
 
28
28
  def call(glob_pattern, path = ".")
29
+ raise ArgumentError, "glob_pattern is required" if glob_pattern.nil?
30
+
31
+ path ||= "."
32
+
33
+ unless File.exist?(path)
34
+ Roast::Helpers::Logger.error("Path does not exist: #{path}")
35
+ return "Path does not exist: #{path}"
36
+ end
37
+
38
+ # prefix **/ to the glob pattern if it doesn't already have it
39
+ glob_pattern = "**/#{glob_pattern}" unless glob_pattern.start_with?("**")
40
+
29
41
  Roast::Helpers::Logger.info("🔍 Searching for: '#{glob_pattern}' in '#{File.expand_path(path)}'\n")
30
42
  search_for(glob_pattern, path).then do |results|
31
43
  return "No results found for #{glob_pattern} in #{path}" if results.empty?
data/lib/roast/tools.rb CHANGED
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/cache"
4
3
  require "English"
5
4
  require "fileutils"
6
5
 
6
+ require "roast/tools/ask_user"
7
+ require "roast/tools/bash"
8
+ require "roast/tools/cmd"
9
+ require "roast/tools/coding_agent"
7
10
  require "roast/tools/grep"
8
11
  require "roast/tools/read_file"
9
12
  require "roast/tools/search_file"
10
- require "roast/tools/write_file"
11
13
  require "roast/tools/update_files"
12
- require "roast/tools/cmd"
13
- require "roast/tools/coding_agent"
14
- require "roast/tools/ask_user"
14
+ require "roast/tools/write_file"
15
15
 
16
16
  module Roast
17
17
  module Tools
data/lib/roast/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Roast
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -12,11 +12,12 @@ module Roast
12
12
 
13
13
  DEFAULT_MAX_ITERATIONS = 100
14
14
 
15
- attr_reader :steps
15
+ attr_reader :steps, :config_hash
16
16
 
17
- def initialize(workflow, steps:, **kwargs)
17
+ def initialize(workflow, steps:, config_hash: {}, **kwargs)
18
18
  super(workflow, **kwargs)
19
19
  @steps = steps
20
+ @config_hash = config_hash
20
21
  # Don't initialize cmd_tool here - we'll do it lazily when needed
21
22
  end
22
23
 
@@ -65,7 +66,7 @@ module Roast
65
66
 
66
67
  # Execute nested steps
67
68
  def execute_nested_steps(steps, context, executor = nil)
68
- executor ||= WorkflowExecutor.new(context, {}, context_path)
69
+ executor ||= WorkflowExecutor.new(context, config_hash, context_path)
69
70
  results = []
70
71
 
71
72
  steps.each do |step|
@@ -139,7 +140,7 @@ module Roast
139
140
  # Execute a step by name and return its result
140
141
  def execute_step_by_name(step_name, context)
141
142
  # Reuse existing step execution logic
142
- executor = WorkflowExecutor.new(context, {}, context_path)
143
+ executor = WorkflowExecutor.new(context, config_hash, context_path)
143
144
  executor.execute_step(step_name)
144
145
  end
145
146
 
@@ -9,20 +9,20 @@ module Roast
9
9
  class BaseStep
10
10
  extend Forwardable
11
11
 
12
- attr_accessor :model, :print_response, :auto_loop, :json, :params, :resource, :coerce_to
12
+ attr_accessor :model, :print_response, :json, :params, :resource, :coerce_to
13
13
  attr_reader :workflow, :name, :context_path
14
14
 
15
15
  def_delegator :workflow, :append_to_final_output
16
16
  def_delegator :workflow, :chat_completion
17
17
  def_delegator :workflow, :transcript
18
18
 
19
- def initialize(workflow, model: "anthropic:claude-opus-4", name: nil, context_path: nil, auto_loop: true)
19
+ # TODO: is this really the model we want to default to, and is this the right place to set it?
20
+ def initialize(workflow, model: "anthropic:claude-opus-4", name: nil, context_path: nil)
20
21
  @workflow = workflow
21
22
  @model = model
22
23
  @name = name || self.class.name.underscore.split("/").last
23
24
  @context_path = context_path || ContextPathResolver.resolve(self.class)
24
25
  @print_response = false
25
- @auto_loop = auto_loop
26
26
  @json = false
27
27
  @params = {}
28
28
  @coerce_to = nil
@@ -31,7 +31,7 @@ module Roast
31
31
 
32
32
  def call
33
33
  prompt(read_sidecar_prompt)
34
- result = chat_completion(print_response:, auto_loop:, json:, params:)
34
+ result = chat_completion(print_response:, json:, params:)
35
35
 
36
36
  # Apply coercion if configured
37
37
  apply_coercion(result)
@@ -39,25 +39,24 @@ module Roast
39
39
 
40
40
  protected
41
41
 
42
- def chat_completion(print_response: nil, auto_loop: nil, json: nil, params: nil)
42
+ def chat_completion(print_response: nil, json: nil, params: nil)
43
43
  # Use instance variables as defaults if parameters are not provided
44
44
  print_response = @print_response if print_response.nil?
45
- auto_loop = @auto_loop if auto_loop.nil?
46
45
  json = @json if json.nil?
47
46
  params = @params if params.nil?
48
47
 
49
- workflow.chat_completion(openai: workflow.openai? && model, loop: auto_loop, model: model, json:, params:).then do |response|
50
- case response
51
- in Array if json
52
- response.flatten.first
53
- in Array
54
- # For non-JSON responses, join array elements
55
- response.map(&:presence).compact.join("\n")
56
- else
57
- response
48
+ workflow.chat_completion(openai: workflow.openai? && model, model: model, json:, params:).tap do |result|
49
+ process_output(result, print_response:)
50
+
51
+ begin
52
+ if json
53
+ return nil if result.strip.empty? # Explicitly handle empty string
54
+
55
+ return JSON.parse(result)
56
+ end
57
+ rescue JSON::ParserError
58
+ # If JSON parsing fails, leave it as a string
58
59
  end
59
- end.tap do |response|
60
- process_output(response, print_response:)
61
60
  end
62
61
  end
63
62
 
@@ -90,11 +89,11 @@ module Roast
90
89
  private
91
90
 
92
91
  def apply_coercion(result)
93
- return result unless @coerce_to
94
-
95
92
  case @coerce_to
96
93
  when :boolean
97
- # Simple boolean coercion
94
+ # Simple boolean coercion - empty string is false
95
+ return false if result.nil? || result == ""
96
+
98
97
  !!result
99
98
  when :llm_boolean
100
99
  # Use LLM boolean coercer for natural language responses
@@ -104,9 +103,19 @@ module Roast
104
103
  # Ensure result is iterable
105
104
  return result if result.respond_to?(:each)
106
105
 
106
+ # Try to parse as JSON array first
107
+ if result.is_a?(String) && result.strip.start_with?("[")
108
+ begin
109
+ parsed = JSON.parse(result)
110
+ return parsed if parsed.is_a?(Array)
111
+ rescue JSON::ParserError
112
+ # Fall through to split by newlines
113
+ end
114
+ end
115
+
107
116
  result.to_s.split("\n")
108
117
  else
109
- # Unknown coercion type, return as-is
118
+ # Unknown or nil coercion type, return as-is
110
119
  result
111
120
  end
112
121
  end
@@ -2,12 +2,10 @@
2
2
 
3
3
  require "raix/chat_completion"
4
4
  require "raix/function_dispatch"
5
- require "active_support"
6
- require "active_support/isolated_execution_state"
7
- require "active_support/notifications"
8
- require "active_support/core_ext/hash/indifferent_access"
9
- require "roast/workflow/output_manager"
5
+
10
6
  require "roast/workflow/context_path_resolver"
7
+ require "roast/workflow/dot_access_hash"
8
+ require "roast/workflow/output_manager"
11
9
 
12
10
  module Roast
13
11
  module Workflow
@@ -24,22 +22,22 @@ module Roast
24
22
  :resource,
25
23
  :session_name,
26
24
  :session_timestamp,
27
- :configuration,
28
- :model
25
+ :model,
26
+ :workflow_configuration
29
27
 
30
28
  attr_reader :pre_processing_data
31
29
 
32
- delegate :api_provider, :openai?, to: :configuration
30
+ delegate :api_provider, :openai?, to: :workflow_configuration, allow_nil: true
33
31
  delegate :output, :output=, :append_to_final_output, :final_output, to: :output_manager
34
32
 
35
- def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, configuration: nil, pre_processing_data: nil)
33
+ def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, workflow_configuration: nil, pre_processing_data: nil)
36
34
  @file = file
37
35
  @name = name || self.class.name.underscore.split("/").last
38
36
  @context_path = context_path || ContextPathResolver.resolve(self.class)
39
37
  @resource = resource || Roast::Resources.for(file)
40
38
  @session_name = session_name || @name
41
39
  @session_timestamp = nil
42
- @configuration = configuration
40
+ @workflow_configuration = workflow_configuration
43
41
  @pre_processing_data = pre_processing_data ? DotAccessHash.new(pre_processing_data).freeze : nil
44
42
 
45
43
  # Initialize managers
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/module/delegation"
4
3
  require "roast/workflow/api_configuration"
5
4
  require "roast/workflow/configuration_loader"
6
5
  require "roast/workflow/resource_resolver"
@@ -11,7 +10,9 @@ module Roast
11
10
  # Encapsulates workflow configuration data and provides structured access
12
11
  # to the configuration settings
13
12
  class Configuration
14
- attr_reader :config_hash, :workflow_path, :name, :steps, :pre_processing, :post_processing, :tools, :function_configs, :model, :resource
13
+ MCPTool = Struct.new(:name, :config, :only, :except, keyword_init: true)
14
+
15
+ attr_reader :config_hash, :workflow_path, :name, :steps, :pre_processing, :post_processing, :tools, :tool_configs, :mcp_tools, :function_configs, :model, :resource
15
16
  attr_accessor :target
16
17
 
17
18
  delegate :api_provider, :openrouter?, :openai?, :uri_base, to: :api_configuration
@@ -32,7 +33,8 @@ module Roast
32
33
  @steps = ConfigurationLoader.extract_steps(@config_hash)
33
34
  @pre_processing = ConfigurationLoader.extract_pre_processing(@config_hash)
34
35
  @post_processing = ConfigurationLoader.extract_post_processing(@config_hash)
35
- @tools = ConfigurationLoader.extract_tools(@config_hash)
36
+ @tools, @tool_configs = ConfigurationLoader.extract_tools(@config_hash)
37
+ @mcp_tools = ConfigurationLoader.extract_mcp_tools(@config_hash)
36
38
  @function_configs = ConfigurationLoader.extract_functions(@config_hash)
37
39
  @model = ConfigurationLoader.extract_model(@config_hash)
38
40
 
@@ -82,6 +84,13 @@ module Roast
82
84
  @function_configs[function_name.to_s] || {}
83
85
  end
84
86
 
87
+ # Get configuration for a specific tool
88
+ # @param tool_name [String] The name of the tool (e.g., 'Roast::Tools::Cmd')
89
+ # @return [Hash] The configuration for the tool or empty hash if not found
90
+ def tool_config(tool_name)
91
+ @tool_configs[tool_name.to_s] || {}
92
+ end
93
+
85
94
  private
86
95
 
87
96
  attr_reader :api_configuration
@@ -12,7 +12,21 @@ module Roast
12
12
  # @return [Hash] The parsed configuration hash
13
13
  def load(workflow_path)
14
14
  validate_path!(workflow_path)
15
- config_hash = YAML.load_file(workflow_path)
15
+
16
+ # Load shared.yml if it exists one level above
17
+ parent_dir = File.dirname(workflow_path)
18
+ shared_path = File.join(parent_dir, "..", "shared.yml")
19
+
20
+ yaml_content = ""
21
+
22
+ if File.exist?(shared_path)
23
+ yaml_content += File.read(shared_path)
24
+ yaml_content += "\n"
25
+ end
26
+
27
+ yaml_content += File.read(workflow_path)
28
+ config_hash = YAML.load(yaml_content, aliases: true)
29
+
16
30
  validate_config!(config_hash)
17
31
  config_hash
18
32
  end
@@ -46,11 +60,56 @@ module Roast
46
60
  config_hash["post_processing"] || []
47
61
  end
48
62
 
49
- # Extract tools from the configuration
63
+ # Extract tools and tool configurations from the configuration
50
64
  # @param config_hash [Hash] The configuration hash
51
- # @return [Array] The tools array or empty array
65
+ # @return [Array, Hash] The tools array or empty array
52
66
  def extract_tools(config_hash)
53
- config_hash["tools"] || []
67
+ tools_config = config_hash["tools"] || []
68
+ tools = []
69
+ tool_configs = {}
70
+
71
+ tools_config.each do |tool_entry|
72
+ case tool_entry
73
+ when String
74
+ tools << tool_entry
75
+ when Hash
76
+ tool_entry.each do |tool_name, config|
77
+ # Skip MCP tool configurations (those with url or command)
78
+ if config.is_a?(Hash) && (config["url"] || config["command"])
79
+ next
80
+ end
81
+
82
+ tools << tool_name
83
+ tool_configs[tool_name] = config || {}
84
+ end
85
+ end
86
+ end
87
+
88
+ [tools, tool_configs]
89
+ end
90
+
91
+ # Extract MCP tools from the configuration
92
+ # @param config_hash [Hash] The configuration hash
93
+ # @return [Array] The MCP tools array or empty array
94
+ def extract_mcp_tools(config_hash)
95
+ tools = config_hash["tools"]&.select { |tool| tool.is_a?(Hash) } || []
96
+ return [] if tools.none?
97
+
98
+ mcp_tools = []
99
+ tools.each do |tool|
100
+ tool.each do |tool_name, config|
101
+ next unless config.is_a?(Hash) && (config["url"] || config["command"])
102
+
103
+ mcp_tools << Configuration::MCPTool.new(
104
+ name: tool_name,
105
+ config: config,
106
+ only: config["only"],
107
+ except: config["except"],
108
+ )
109
+ end
110
+ end
111
+
112
+ mcp_tools
54
113
  end
55
114
 
56
115
  # Extract function configurations
@@ -3,9 +3,6 @@
3
3
  require "roast/workflow/configuration"
4
4
  require "roast/workflow/workflow_initializer"
5
5
  require "roast/workflow/workflow_runner"
6
- require "active_support"
7
- require "active_support/isolated_execution_state"
8
- require "active_support/notifications"
9
6
 
10
7
  module Roast
11
8
  module Workflow
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/notifications"
4
3
  require "roast/helpers/logger"
5
4
  require "roast/workflow/command_executor"
6
5
 
@@ -79,7 +79,6 @@ module Roast
79
79
  )
80
80
  output_file = File.join(session_dir, "final_output.txt")
81
81
  File.write(output_file, output_content)
82
- $stderr.puts "Final output saved to: #{output_file}"
83
82
  output_file
84
83
  rescue => e
85
84
  $stderr.puts "Failed to save final output: #{e.message}"
@@ -4,10 +4,11 @@ module Roast
4
4
  module Workflow
5
5
  # Handles execution of iteration steps (repeat and each)
6
6
  class IterationExecutor
7
- def initialize(workflow, context_path, state_manager)
7
+ def initialize(workflow, context_path, state_manager, config_hash = {})
8
8
  @workflow = workflow
9
9
  @context_path = context_path
10
10
  @state_manager = state_manager
11
+ @config_hash = config_hash
11
12
  end
12
13
 
13
14
  def execute_repeat(repeat_config)
@@ -31,6 +32,7 @@ module Roast
31
32
  max_iterations: max_iterations,
32
33
  name: "repeat_#{@workflow.output.size}",
33
34
  context_path: @context_path,
35
+ config_hash: @config_hash,
34
36
  )
35
37
 
36
38
  # Apply configuration if provided
@@ -70,6 +72,7 @@ module Roast
70
72
  steps: steps,
71
73
  name: "each_#{variable_name}",
72
74
  context_path: @context_path,
75
+ config_hash: @config_hash,
73
76
  )
74
77
 
75
78
  # Apply configuration if provided
@@ -92,7 +95,6 @@ module Roast
92
95
  # Apply configuration settings to a step
93
96
  def apply_step_configuration(step, step_config)
94
97
  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
98
  step.json = step_config["json"] if step_config.key?("json")
97
99
  step.params = step_config["params"] if step_config.key?("params")
98
100
  step.model = step_config["model"] if step_config.key?("model")
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/hash/indifferent_access"
4
3
  require "roast/workflow/dot_access_hash"
5
4
 
6
5
  module Roast
@@ -207,7 +207,8 @@ module Roast
207
207
  exit_on_error = context.exit_on_error?(interpolated_name)
208
208
 
209
209
  # Execute the command directly using the appropriate executor
210
- result = execute(interpolated_command, { exit_on_error: exit_on_error })
210
+ # Pass the original key name for configuration lookup
211
+ result = execute(interpolated_command, { exit_on_error: exit_on_error, step_key: interpolated_name })
211
212
  context.workflow.output[interpolated_name] = result
212
213
  result
213
214
  end
@@ -227,13 +228,14 @@ module Roast
227
228
  execute_command_step(interpolated_step, { exit_on_error: exit_on_error })
228
229
  else
229
230
  exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
230
- execute_standard_step(interpolated_step, { exit_on_error: exit_on_error })
231
+ execute_standard_step(interpolated_step, options.merge(exit_on_error: exit_on_error))
231
232
  end
232
233
  end
233
234
 
234
235
  def execute_standard_step(step, options)
235
236
  exit_on_error = options.fetch(:exit_on_error, true)
236
- step_orchestrator.execute_step(step, exit_on_error: exit_on_error)
237
+ step_key = options[:step_key]
238
+ step_orchestrator.execute_step(step, exit_on_error: exit_on_error, step_key: step_key)
237
239
  end
238
240
 
239
241
  def validate_each_step!(step)
@@ -24,7 +24,7 @@ module Roast
24
24
  step_config = config_hash[interpolated_name]
25
25
  exit_on_error = step_config.is_a?(Hash) ? step_config.fetch("exit_on_error", true) : true
26
26
 
27
- workflow.output[interpolated_name] = step_runner.execute_step(interpolated_command, exit_on_error: exit_on_error)
27
+ workflow.output[interpolated_name] = step_runner.execute_step(interpolated_command, exit_on_error: exit_on_error, step_key: interpolated_name)
28
28
  end
29
29
  end
30
30
 
@@ -46,25 +46,32 @@ module Roast
46
46
  # Finds and loads a step by name
47
47
  #
48
48
  # @param step_name [String, StepName] The name of the step to load
49
+ # @param step_key [String] The configuration key for the step (optional)
49
50
  # @return [BaseStep] The loaded step instance
50
- def load(step_name)
51
+ def load(step_name, step_key: nil)
51
52
  name = step_name.is_a?(Roast::ValueObjects::StepName) ? step_name : Roast::ValueObjects::StepName.new(step_name)
52
53
 
54
+ # Get step config for per-step path
55
+ step_config = config_hash[name.to_s] || {}
56
+ per_step_path = step_config["path"]
57
+
53
58
  # First check for a prompt step (contains spaces)
54
59
  if name.plain_text?
55
- step = Roast::Workflow::PromptStep.new(workflow, name: name.to_s, auto_loop: true)
56
- configure_step(step, name.to_s)
60
+ step = Roast::Workflow::PromptStep.new(workflow, name: name.to_s)
61
+ # Use step_key for configuration if provided, otherwise use name
62
+ config_key = step_key || name.to_s
63
+ configure_step(step, config_key)
57
64
  return step
58
65
  end
59
66
 
60
67
  # Look for Ruby file in various locations
61
- step_file_path = find_step_file(name.to_s)
68
+ step_file_path = find_step_file(name.to_s, per_step_path)
62
69
  if step_file_path
63
70
  return load_ruby_step(step_file_path, name.to_s)
64
71
  end
65
72
 
66
73
  # Look for step directory
67
- step_directory = find_step_directory(name.to_s)
74
+ step_directory = find_step_directory(name.to_s, per_step_path)
68
75
  unless step_directory
69
76
  raise StepNotFoundError.new("Step directory or file not found: #{name}", step_name: name.to_s)
70
77
  end
@@ -74,8 +81,22 @@ module Roast
74
81
 
75
82
  private
76
83
 
84
+ def resolve_path(path)
85
+ return unless path
86
+ return path if Pathname.new(path).absolute?
87
+
88
+ File.expand_path(path, context_path)
89
+ end
90
+
77
91
  # Find a Ruby step file in various locations
78
- def find_step_file(step_name)
92
+ def find_step_file(step_name, per_step_path = nil)
93
+ # Check in per-step path first
94
+ if per_step_path
95
+ resolved_per_step_path = resolve_path(per_step_path)
96
+ custom_rb_path = File.join(resolved_per_step_path, "#{step_name}.rb")
97
+ return custom_rb_path if File.file?(custom_rb_path)
98
+ end
99
+
79
100
  # Check in phase-specific directory first
80
101
  if phase != :steps
81
102
  phase_rb_path = File.join(context_path, phase.to_s, "#{step_name}.rb")
@@ -94,7 +115,14 @@ module Roast
94
115
  end
95
116
 
96
117
  # Find a step directory
97
- def find_step_directory(step_name)
118
+ def find_step_directory(step_name, per_step_path = nil)
119
+ # Check in per-step path first
120
+ if per_step_path
121
+ resolved_per_step_path = resolve_path(per_step_path)
122
+ custom_step_path = File.join(resolved_per_step_path, step_name)
123
+ return custom_step_path if File.directory?(custom_step_path)
124
+ end
125
+
98
126
  # Check in phase-specific directory first
99
127
  if phase != :steps
100
128
  phase_step_path = File.join(context_path, phase.to_s, step_name)
@@ -158,7 +186,6 @@ module Roast
158
186
  # Apply configuration settings to a step
159
187
  def apply_step_configuration(step, step_config)
160
188
  step.print_response = step_config["print_response"] if step_config.key?("print_response")
161
- step.auto_loop = step_config["loop"] if step_config.key?("loop")
162
189
  step.json = step_config["json"] if step_config.key?("json")
163
190
  step.params = step_config["params"] if step_config.key?("params")
164
191
  step.coerce_to = step_config["coerce_to"].to_sym if step_config.key?("coerce_to")
@@ -22,13 +22,15 @@ module Roast
22
22
  @workflow_executor = workflow_executor
23
23
  end
24
24
 
25
- def execute_step(name, exit_on_error: true)
25
+ def execute_step(name, exit_on_error: true, step_key: nil)
26
26
  resource_type = @workflow.respond_to?(:resource) ? @workflow.resource&.type : nil
27
27
 
28
28
  @error_handler.with_error_handling(name, resource_type: resource_type) do
29
29
  $stderr.puts "Executing: #{name} (Resource type: #{resource_type || "unknown"})"
30
30
 
31
- step_object = @step_loader.load(name)
31
+ # Use step_key for loading if provided, otherwise use name
32
+ load_key = step_key || name
33
+ step_object = @step_loader.load(name, step_key: load_key)
32
34
  step_result = step_object.call
33
35
 
34
36
  # Store result in workflow output