roast-ai 0.2.3 → 0.3.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/CLAUDE.md +3 -1
  4. data/Gemfile +1 -0
  5. data/Gemfile.lock +15 -10
  6. data/README.md +214 -20
  7. data/bin/roast +1 -1
  8. data/examples/bash_prototyping/README.md +53 -0
  9. data/examples/bash_prototyping/analyze_network/prompt.md +13 -0
  10. data/examples/bash_prototyping/analyze_system/prompt.md +11 -0
  11. data/examples/bash_prototyping/api_testing.yml +14 -0
  12. data/examples/bash_prototyping/check_processes/prompt.md +11 -0
  13. data/examples/bash_prototyping/generate_report/prompt.md +16 -0
  14. data/examples/bash_prototyping/process_json_response/prompt.md +24 -0
  15. data/examples/bash_prototyping/system_analysis.yml +14 -0
  16. data/examples/bash_prototyping/test_public_api/prompt.md +22 -0
  17. data/examples/cmd/README.md +99 -0
  18. data/examples/cmd/analyze_project/prompt.md +57 -0
  19. data/examples/cmd/basic_demo/prompt.md +48 -0
  20. data/examples/cmd/basic_workflow.yml +17 -0
  21. data/examples/cmd/check_repository/prompt.md +57 -0
  22. data/examples/cmd/create_and_verify/prompt.md +56 -0
  23. data/examples/cmd/dev_workflow.yml +26 -0
  24. data/examples/cmd/explore_project/prompt.md +67 -0
  25. data/examples/cmd/explorer_workflow.yml +21 -0
  26. data/examples/cmd/smart_tool_selection/prompt.md +99 -0
  27. data/examples/grading/README.md +71 -0
  28. data/examples/grading/read_dependencies/prompt.md +4 -2
  29. data/examples/grading/run_coverage.rb +9 -0
  30. data/examples/grading/workflow.yml +0 -2
  31. data/examples/mcp/README.md +223 -0
  32. data/examples/mcp/analyze_changes/prompt.md +8 -0
  33. data/examples/mcp/analyze_issues/prompt.md +4 -0
  34. data/examples/mcp/analyze_schema/prompt.md +4 -0
  35. data/examples/mcp/check_data_quality/prompt.md +5 -0
  36. data/examples/mcp/check_documentation/prompt.md +4 -0
  37. data/examples/mcp/create_recommendations/prompt.md +5 -0
  38. data/examples/mcp/database_workflow.yml +29 -0
  39. data/examples/mcp/env_demo/workflow.yml +34 -0
  40. data/examples/mcp/fetch_pr_context/prompt.md +4 -0
  41. data/examples/mcp/filesystem_demo/create_test_file/prompt.md +2 -0
  42. data/examples/mcp/filesystem_demo/list_files/prompt.md +6 -0
  43. data/examples/mcp/filesystem_demo/read_with_mcp/prompt.md +7 -0
  44. data/examples/mcp/filesystem_demo/workflow.yml +38 -0
  45. data/examples/mcp/generate_insights/prompt.md +4 -0
  46. data/examples/mcp/generate_report/prompt.md +6 -0
  47. data/examples/mcp/generate_review/prompt.md +16 -0
  48. data/examples/mcp/github_workflow.yml +32 -0
  49. data/examples/mcp/multi_mcp_workflow.yml +58 -0
  50. data/examples/mcp/post_review/prompt.md +3 -0
  51. data/examples/mcp/save_report/prompt.md +6 -0
  52. data/examples/mcp/search_issues/prompt.md +2 -0
  53. data/examples/mcp/summarize/prompt.md +1 -0
  54. data/examples/mcp/test_filesystem/prompt.md +6 -0
  55. data/examples/mcp/test_github/prompt.md +8 -0
  56. data/examples/mcp/test_read/prompt.md +1 -0
  57. data/examples/mcp/workflow.yml +35 -0
  58. data/examples/shared_config/README.md +52 -0
  59. data/examples/shared_config/example_with_shared_config/workflow.yml +6 -0
  60. data/examples/shared_config/shared.yml +7 -0
  61. data/examples/step_configuration/README.md +0 -3
  62. data/examples/step_configuration/workflow.yml +0 -3
  63. data/examples/tool_config_example/README.md +109 -0
  64. data/examples/tool_config_example/example_step/prompt.md +42 -0
  65. data/examples/tool_config_example/workflow.yml +17 -0
  66. data/examples/workflow_generator/workflow.yml +0 -1
  67. data/lib/roast/helpers/function_caching_interceptor.rb +0 -4
  68. data/lib/roast/helpers/prompt_loader.rb +0 -1
  69. data/lib/roast/tools/bash.rb +62 -0
  70. data/lib/roast/tools/cmd.rb +121 -34
  71. data/lib/roast/tools/coding_agent.rb +86 -7
  72. data/lib/roast/tools/helpers/coding_agent_message_formatter.rb +87 -0
  73. data/lib/roast/tools/search_file.rb +13 -1
  74. data/lib/roast/tools.rb +5 -5
  75. data/lib/roast/version.rb +1 -1
  76. data/lib/roast/workflow/base_step.rb +29 -21
  77. data/lib/roast/workflow/base_workflow.rb +8 -10
  78. data/lib/roast/workflow/configuration.rb +68 -3
  79. data/lib/roast/workflow/configuration_loader.rb +63 -4
  80. data/lib/roast/workflow/configuration_parser.rb +0 -3
  81. data/lib/roast/workflow/error_handler.rb +0 -1
  82. data/lib/roast/workflow/file_state_repository.rb +0 -1
  83. data/lib/roast/workflow/iteration_executor.rb +0 -1
  84. data/lib/roast/workflow/output_manager.rb +0 -1
  85. data/lib/roast/workflow/prompt_step.rb +1 -1
  86. data/lib/roast/workflow/step_executor_coordinator.rb +5 -3
  87. data/lib/roast/workflow/step_executors/hash_step_executor.rb +1 -1
  88. data/lib/roast/workflow/step_loader.rb +35 -8
  89. data/lib/roast/workflow/step_orchestrator.rb +4 -2
  90. data/lib/roast/workflow/workflow_execution_context.rb +0 -2
  91. data/lib/roast/workflow/workflow_executor.rb +1 -3
  92. data/lib/roast/workflow/workflow_initializer.rb +66 -2
  93. data/lib/roast/workflow/workflow_runner.rb +1 -2
  94. data/lib/roast.rb +8 -0
  95. data/package-lock.json +6 -0
  96. data/roast.gemspec +2 -1
  97. metadata +73 -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.3"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -9,7 +9,7 @@ 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
@@ -17,13 +17,12 @@ module Roast
17
17
  def_delegator :workflow, :transcript
18
18
 
19
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, auto_loop: true)
20
+ def initialize(workflow, model: "anthropic:claude-opus-4", name: nil, context_path: nil)
21
21
  @workflow = workflow
22
22
  @model = model
23
23
  @name = name || self.class.name.underscore.split("/").last
24
24
  @context_path = context_path || ContextPathResolver.resolve(self.class)
25
25
  @print_response = false
26
- @auto_loop = auto_loop
27
26
  @json = false
28
27
  @params = {}
29
28
  @coerce_to = nil
@@ -32,7 +31,7 @@ module Roast
32
31
 
33
32
  def call
34
33
  prompt(read_sidecar_prompt)
35
- result = chat_completion(print_response:, auto_loop:, json:, params:)
34
+ result = chat_completion(print_response:, json:, params:)
36
35
 
37
36
  # Apply coercion if configured
38
37
  apply_coercion(result)
@@ -40,25 +39,24 @@ module Roast
40
39
 
41
40
  protected
42
41
 
43
- def chat_completion(print_response: nil, auto_loop: nil, json: nil, params: nil)
42
+ def chat_completion(print_response: nil, json: nil, params: nil)
44
43
  # Use instance variables as defaults if parameters are not provided
45
44
  print_response = @print_response if print_response.nil?
46
- auto_loop = @auto_loop if auto_loop.nil?
47
45
  json = @json if json.nil?
48
46
  params = @params if params.nil?
49
47
 
50
- workflow.chat_completion(openai: workflow.openai? && model, loop: auto_loop, model: model, json:, params:).then do |response|
51
- case response
52
- in Array if json
53
- response.flatten.first
54
- in Array
55
- # For non-JSON responses, join array elements
56
- response.map(&:presence).compact.join("\n")
57
- else
58
- 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
59
59
  end
60
- end.tap do |response|
61
- process_output(response, print_response:)
62
60
  end
63
61
  end
64
62
 
@@ -91,11 +89,11 @@ module Roast
91
89
  private
92
90
 
93
91
  def apply_coercion(result)
94
- return result unless @coerce_to
95
-
96
92
  case @coerce_to
97
93
  when :boolean
98
- # Simple boolean coercion
94
+ # Simple boolean coercion - empty string is false
95
+ return false if result.nil? || result == ""
96
+
99
97
  !!result
100
98
  when :llm_boolean
101
99
  # Use LLM boolean coercer for natural language responses
@@ -105,9 +103,19 @@ module Roast
105
103
  # Ensure result is iterable
106
104
  return result if result.respond_to?(:each)
107
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
+
108
116
  result.to_s.split("\n")
109
117
  else
110
- # Unknown coercion type, return as-is
118
+ # Unknown or nil coercion type, return as-is
111
119
  result
112
120
  end
113
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
 
@@ -43,6 +45,8 @@ module Roast
43
45
  # Process target and resource
44
46
  @target = ConfigurationLoader.extract_target(@config_hash, options)
45
47
  process_resource
48
+
49
+ mark_last_step_for_output
46
50
  end
47
51
 
48
52
  def context_path
@@ -82,6 +86,13 @@ module Roast
82
86
  @function_configs[function_name.to_s] || {}
83
87
  end
84
88
 
89
+ # Get configuration for a specific tool
90
+ # @param tool_name [String] The name of the tool (e.g., 'Roast::Tools::Cmd')
91
+ # @return [Hash] The configuration for the tool or empty hash if not found
92
+ def tool_config(tool_name)
93
+ @tool_configs[tool_name.to_s] || {}
94
+ end
95
+
85
96
  private
86
97
 
87
98
  attr_reader :api_configuration
@@ -93,6 +104,60 @@ module Roast
93
104
  @target = @resource.value if has_target?
94
105
  end
95
106
  end
107
+
108
+ def mark_last_step_for_output
109
+ return if @steps.empty?
110
+
111
+ last_step = find_last_executable_step(@steps.last)
112
+ return unless last_step
113
+
114
+ # Get the step name/key
115
+ step_key = extract_step_key(last_step)
116
+ return unless step_key
117
+
118
+ # Ensure config exists for this step
119
+ @config_hash[step_key] ||= {}
120
+
121
+ # Only set print_response if not already explicitly configured
122
+ @config_hash[step_key]["print_response"] = true unless @config_hash[step_key].key?("print_response")
123
+ end
124
+
125
+ def find_last_executable_step(step)
126
+ case step
127
+ when String
128
+ step
129
+ when Hash
130
+ # Check if it's a special step type (if, unless, each, repeat, case)
131
+ if step.key?("if") || step.key?("unless")
132
+ # For conditional steps, try to find the last step in the "then" branch
133
+ then_steps = step["then"] || step["steps"]
134
+ find_last_executable_step(then_steps.last) if then_steps&.any?
135
+ elsif step.key?("each") || step.key?("repeat")
136
+ # For iteration steps, we can't reliably determine the last step
137
+ nil
138
+ elsif step.key?("case")
139
+ # For case steps, we can't reliably determine the last step
140
+ nil
141
+ elsif step.size == 1
142
+ # Regular hash step with variable assignment
143
+ step
144
+ end
145
+ when Array
146
+ # For parallel steps, we can't determine a single "last" step
147
+ nil
148
+ else
149
+ step
150
+ end
151
+ end
152
+
153
+ def extract_step_key(step)
154
+ case step
155
+ when String
156
+ step
157
+ when Hash
158
+ step.keys.first
159
+ end
160
+ end
96
161
  end
97
162
  end
98
163
  end
@@ -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}"
@@ -95,7 +95,6 @@ module Roast
95
95
  # Apply configuration settings to a step
96
96
  def apply_step_configuration(step, step_config)
97
97
  step.print_response = step_config["print_response"] if step_config.key?("print_response")
98
- step.auto_loop = step_config["loop"] if step_config.key?("loop")
99
98
  step.json = step_config["json"] if step_config.key?("json")
100
99
  step.params = step_config["params"] if step_config.key?("params")
101
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
@@ -9,7 +9,7 @@ module Roast
9
9
 
10
10
  def call
11
11
  prompt(name)
12
- result = chat_completion
12
+ result = chat_completion(print_response:, json:, params:)
13
13
 
14
14
  # Apply coercion if configured
15
15
  apply_coercion(result)
@@ -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")