roast-ai 0.1.0 → 0.1.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/cla.yml +1 -1
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +20 -0
  5. data/CLAUDE.md +3 -1
  6. data/Gemfile +0 -1
  7. data/Gemfile.lock +3 -4
  8. data/README.md +418 -4
  9. data/Rakefile +1 -6
  10. data/docs/INSTRUMENTATION.md +202 -0
  11. data/examples/api_workflow/README.md +85 -0
  12. data/examples/api_workflow/fetch_api_data/prompt.md +10 -0
  13. data/examples/api_workflow/generate_report/prompt.md +10 -0
  14. data/examples/api_workflow/prompt.md +10 -0
  15. data/examples/api_workflow/transform_data/prompt.md +10 -0
  16. data/examples/api_workflow/workflow.yml +30 -0
  17. data/examples/grading/workflow.yml +2 -2
  18. data/examples/instrumentation.rb +76 -0
  19. data/examples/rspec_to_minitest/README.md +68 -0
  20. data/examples/rspec_to_minitest/analyze_spec/prompt.md +30 -0
  21. data/examples/rspec_to_minitest/create_minitest/prompt.md +33 -0
  22. data/examples/rspec_to_minitest/run_and_improve/prompt.md +35 -0
  23. data/examples/rspec_to_minitest/workflow.md +10 -0
  24. data/examples/rspec_to_minitest/workflow.yml +40 -0
  25. data/lib/roast/helpers/function_caching_interceptor.rb +72 -8
  26. data/lib/roast/helpers/prompt_loader.rb +2 -0
  27. data/lib/roast/resources/api_resource.rb +137 -0
  28. data/lib/roast/resources/base_resource.rb +47 -0
  29. data/lib/roast/resources/directory_resource.rb +40 -0
  30. data/lib/roast/resources/file_resource.rb +33 -0
  31. data/lib/roast/resources/none_resource.rb +29 -0
  32. data/lib/roast/resources/url_resource.rb +63 -0
  33. data/lib/roast/resources.rb +100 -0
  34. data/lib/roast/tools/coding_agent.rb +69 -0
  35. data/lib/roast/tools.rb +1 -0
  36. data/lib/roast/version.rb +1 -1
  37. data/lib/roast/workflow/base_step.rb +21 -17
  38. data/lib/roast/workflow/base_workflow.rb +49 -16
  39. data/lib/roast/workflow/configuration.rb +83 -8
  40. data/lib/roast/workflow/configuration_parser.rb +171 -3
  41. data/lib/roast/workflow/file_state_repository.rb +126 -0
  42. data/lib/roast/workflow/prompt_step.rb +16 -0
  43. data/lib/roast/workflow/session_manager.rb +82 -0
  44. data/lib/roast/workflow/state_repository.rb +21 -0
  45. data/lib/roast/workflow/workflow_executor.rb +99 -9
  46. data/lib/roast/workflow.rb +4 -0
  47. data/lib/roast.rb +2 -5
  48. data/roast.gemspec +1 -1
  49. data/schema/workflow.json +12 -0
  50. metadata +31 -6
  51. data/.rspec +0 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
4
+ require "active_support/isolated_execution_state"
3
5
  require "active_support/cache"
4
6
  require "active_support/notifications"
5
7
  require_relative "logger"
@@ -10,17 +12,79 @@ module Roast
10
12
  # This module wraps around Raix::FunctionDispatch to provide caching for tool functions
11
13
  module FunctionCachingInterceptor
12
14
  def dispatch_tool_function(function_name, params)
13
- # legacy workflows don't have a configuration
14
- return super(function_name, params) if configuration.blank?
15
+ start_time = Time.now
15
16
 
16
- function_config = configuration.function_config(function_name)
17
- if function_config&.dig("cache", "enabled")
18
- # Call the original function and pass in the cache
19
- super(function_name, params, cache: Roast::Tools::CACHE)
20
- else
21
- Roast::Helpers::Logger.debug("⚠️ Caching not enabled for #{function_name}")
17
+ ActiveSupport::Notifications.instrument("roast.tool.execute", {
18
+ function_name: function_name,
19
+ params: params,
20
+ })
21
+
22
+ # Handle workflows with or without configuration
23
+ result = if !respond_to?(:configuration) || configuration.nil?
22
24
  super(function_name, params)
25
+ else
26
+ function_config = if configuration.respond_to?(:function_config)
27
+ configuration.function_config(function_name)
28
+ else
29
+ {}
30
+ end
31
+
32
+ # Check if caching is enabled - handle both formats:
33
+ # 1. cache: true (boolean format)
34
+ # 2. cache: { enabled: true } (hash format)
35
+ cache_enabled = if function_config.is_a?(Hash)
36
+ cache_config = function_config["cache"]
37
+ if cache_config.is_a?(Hash)
38
+ cache_config["enabled"]
39
+ else
40
+ # Direct boolean value
41
+ cache_config
42
+ end
43
+ else
44
+ false
45
+ end
46
+
47
+ if cache_enabled
48
+ # Call the original function and pass in the cache
49
+ super(function_name, params, cache: Roast::Tools::CACHE)
50
+ else
51
+ Roast::Helpers::Logger.debug("⚠️ Caching not enabled for #{function_name}")
52
+ super(function_name, params)
53
+ end
54
+ end
55
+
56
+ execution_time = Time.now - start_time
57
+
58
+ # Determine if caching was enabled for metrics
59
+ cache_enabled = if defined?(function_config) && function_config.is_a?(Hash)
60
+ cache_config = function_config["cache"]
61
+ if cache_config.is_a?(Hash)
62
+ cache_config["enabled"]
63
+ else
64
+ # Direct boolean value
65
+ cache_config
66
+ end
67
+ else
68
+ false
23
69
  end
70
+
71
+ ActiveSupport::Notifications.instrument("roast.tool.complete", {
72
+ function_name: function_name,
73
+ execution_time: execution_time,
74
+ cache_enabled: cache_enabled,
75
+ })
76
+
77
+ result
78
+ rescue => e
79
+ execution_time = Time.now - start_time
80
+
81
+ ActiveSupport::Notifications.instrument("roast.tool.error", {
82
+ function_name: function_name,
83
+ error: e.class.name,
84
+ message: e.message,
85
+ execution_time: execution_time,
86
+ })
87
+ raise
24
88
  end
25
89
  end
26
90
  end
@@ -74,6 +74,8 @@ module Roast
74
74
  end
75
75
 
76
76
  def extract_file_extensions
77
+ return [] if target_file.nil?
78
+
77
79
  file_basename = File.basename(target_file)
78
80
 
79
81
  if file_basename.end_with?(".md") && file_basename.count(".") > 1
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Roast
8
+ module Resources
9
+ # Resource implementation for API endpoints using Fetch API-style format
10
+ class ApiResource < BaseResource
11
+ def process
12
+ # For API resources, the target might be a JSON configuration or endpoint URL
13
+ target
14
+ end
15
+
16
+ def config
17
+ return @config if defined?(@config)
18
+
19
+ @config = if target.is_a?(String) && target.match?(/^\s*{/)
20
+ begin
21
+ JSON.parse(target)
22
+ rescue JSON::ParserError
23
+ nil
24
+ end
25
+ end
26
+ end
27
+
28
+ def api_url
29
+ if config && config["url"]
30
+ config["url"]
31
+ else
32
+ # Assume direct URL
33
+ target
34
+ end
35
+ end
36
+
37
+ def options
38
+ return {} unless config
39
+
40
+ config["options"] || {}
41
+ end
42
+
43
+ def http_method
44
+ method_name = (options["method"] || "GET").upcase
45
+ case method_name
46
+ when "GET" then Net::HTTP::Get
47
+ when "POST" then Net::HTTP::Post
48
+ when "PUT" then Net::HTTP::Put
49
+ when "DELETE" then Net::HTTP::Delete
50
+ when "PATCH" then Net::HTTP::Patch
51
+ when "HEAD" then Net::HTTP::Head
52
+ else Net::HTTP::Get
53
+ end
54
+ end
55
+
56
+ def exists?
57
+ return false unless target
58
+
59
+ uri = URI.parse(api_url)
60
+ http = Net::HTTP.new(uri.host, uri.port)
61
+ http.use_ssl = (uri.scheme == "https")
62
+
63
+ # Use HEAD request to check existence
64
+ request = Net::HTTP::Head.new(uri.path.empty? ? "/" : uri.path)
65
+
66
+ # Add headers if present in options
67
+ if options["headers"].is_a?(Hash)
68
+ options["headers"].each do |key, value|
69
+ # Process any environment variables in header values
70
+ processed_value = process_env_vars(value.to_s)
71
+ request[key] = processed_value
72
+ end
73
+ end
74
+
75
+ # Make the request
76
+ response = http.request(request)
77
+
78
+ # Consider 2xx and 3xx as success
79
+ response.code.to_i < 400
80
+ rescue StandardError => e
81
+ # Log the error but don't crash
82
+ Roast::Helpers::Logger.error("Error checking API existence: #{e.message}")
83
+ false
84
+ end
85
+
86
+ def contents
87
+ return unless target
88
+
89
+ # If it's a configuration, return a prepared request object
90
+ if config
91
+ JSON.pretty_generate({
92
+ "url" => api_url,
93
+ "method" => (options["method"] || "GET").upcase,
94
+ "headers" => options["headers"] || {},
95
+ "body" => options["body"],
96
+ })
97
+ else
98
+ # Assume it's a direct API URL, do a simple GET
99
+ begin
100
+ uri = URI.parse(target)
101
+ Net::HTTP.get(uri)
102
+ rescue StandardError => e
103
+ # Log the error but don't crash
104
+ Roast::Helpers::Logger.error("Error fetching API contents: #{e.message}")
105
+ nil
106
+ end
107
+ end
108
+ end
109
+
110
+ def name
111
+ if target
112
+ if config && config["url"]
113
+ "API #{config["url"]} (#{(options["method"] || "GET").upcase})"
114
+ else
115
+ "API #{target}"
116
+ end
117
+ else
118
+ "Unnamed API"
119
+ end
120
+ end
121
+
122
+ def type
123
+ :api
124
+ end
125
+
126
+ private
127
+
128
+ # Replace environment variables in the format ${VAR_NAME}
129
+ def process_env_vars(text)
130
+ text.gsub(/\${([^}]+)}/) do |_match|
131
+ var_name = ::Regexp.last_match(1).strip
132
+ ENV.fetch(var_name, "${#{var_name}}")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Resources
5
+ # Base class for all resource types
6
+ # Follows the Strategy pattern to handle different resource types in a polymorphic way
7
+ class BaseResource
8
+ attr_reader :target
9
+
10
+ # Initialize a resource with a target
11
+ # @param target [String] The target specified in the workflow, can be nil
12
+ def initialize(target)
13
+ @target = target
14
+ end
15
+
16
+ # Process the resource to prepare it for use
17
+ # @return [String] The processed target
18
+ def process
19
+ target
20
+ end
21
+
22
+ # Check if the resource exists
23
+ # @return [Boolean] true if the resource exists
24
+ def exists?
25
+ false # Override in subclasses
26
+ end
27
+
28
+ # Get the contents of the resource
29
+ # @return [String] The contents of the resource
30
+ def contents
31
+ nil # Override in subclasses
32
+ end
33
+
34
+ # Get a name for the resource to display in logs
35
+ # @return [String] A descriptive name for the resource
36
+ def name
37
+ target || "Unnamed Resource"
38
+ end
39
+
40
+ # Get the type of this resource as a symbol
41
+ # @return [Symbol] The resource type
42
+ def type
43
+ :unknown
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Resources
5
+ # Resource implementation for directories
6
+ class DirectoryResource < BaseResource
7
+ def process
8
+ if target.include?("*") || target.include?("?")
9
+ # If it's a glob pattern, return only directories
10
+ Dir.glob(target).select { |f| Dir.exist?(f) }.map { |d| File.expand_path(d) }.join("\n")
11
+ else
12
+ File.expand_path(target)
13
+ end
14
+ end
15
+
16
+ def exists?
17
+ Dir.exist?(target)
18
+ end
19
+
20
+ def contents
21
+ # Return a listing of files in the directory
22
+ if exists?
23
+ Dir.entries(target).reject { |f| f == "." || f == ".." }.join("\n")
24
+ end
25
+ end
26
+
27
+ def name
28
+ if target
29
+ File.basename(target) + "/"
30
+ else
31
+ "Unnamed Directory"
32
+ end
33
+ end
34
+
35
+ def type
36
+ :directory
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Resources
5
+ # Resource implementation for files
6
+ class FileResource < BaseResource
7
+ def process
8
+ # Handle glob patterns in the target
9
+ if target.include?("*") || target.include?("?")
10
+ Dir.glob(target).map { |f| File.expand_path(f) unless Dir.exist?(f) }.compact.join("\n")
11
+ else
12
+ File.expand_path(target)
13
+ end
14
+ end
15
+
16
+ def exists?
17
+ File.exist?(target) && !Dir.exist?(target)
18
+ end
19
+
20
+ def contents
21
+ File.read(target) if exists?
22
+ end
23
+
24
+ def name
25
+ File.basename(target) if target
26
+ end
27
+
28
+ def type
29
+ :file
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Resources
5
+ # Resource implementation for workflows with no target (targetless workflows)
6
+ class NoneResource < BaseResource
7
+ def process
8
+ nil
9
+ end
10
+
11
+ def exists?
12
+ # There's no target to check, so this is always true
13
+ true
14
+ end
15
+
16
+ def contents
17
+ nil
18
+ end
19
+
20
+ def name
21
+ "Targetless Resource"
22
+ end
23
+
24
+ def type
25
+ :none
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Roast
7
+ module Resources
8
+ # Resource implementation for URLs
9
+ class UrlResource < BaseResource
10
+ def process
11
+ # URLs don't need special processing, just return as is
12
+ target
13
+ end
14
+
15
+ def exists?
16
+ return false unless target
17
+
18
+ begin
19
+ uri = URI.parse(target)
20
+ http = Net::HTTP.new(uri.host, uri.port)
21
+ http.use_ssl = (uri.scheme == "https")
22
+
23
+ # Just check the head to see if the resource exists
24
+ response = http.request_head(uri.path.empty? ? "/" : uri.path)
25
+
26
+ # Consider 2xx and 3xx as success
27
+ response.code.to_i < 400
28
+ rescue StandardError => e
29
+ # Log the error but don't crash
30
+ Roast::Helpers::Logger.error("Error checking URL existence: #{e.message}")
31
+ false
32
+ end
33
+ end
34
+
35
+ def contents
36
+ return unless target
37
+
38
+ begin
39
+ uri = URI.parse(target)
40
+ Net::HTTP.get(uri)
41
+ rescue StandardError => e
42
+ # Log the error but don't crash
43
+ Roast::Helpers::Logger.error("Error fetching URL contents: #{e.message}")
44
+ nil
45
+ end
46
+ end
47
+
48
+ def name
49
+ if target
50
+ URI.parse(target).host
51
+ else
52
+ "Unnamed URL"
53
+ end
54
+ rescue URI::InvalidURIError
55
+ "Invalid URL"
56
+ end
57
+
58
+ def type
59
+ :url
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resources/base_resource"
4
+ require_relative "resources/file_resource"
5
+ require_relative "resources/directory_resource"
6
+ require_relative "resources/url_resource"
7
+ require_relative "resources/api_resource"
8
+ require_relative "resources/none_resource"
9
+ require "uri"
10
+
11
+ module Roast
12
+ # The Resources module contains classes for handling different types of resources
13
+ # that workflows can operate on. Each resource type implements a common interface.
14
+ module Resources
15
+ extend self
16
+
17
+ # Create the appropriate resource based on the target
18
+ # @param target [String] The target specified in the workflow
19
+ # @return [BaseResource] A resource object of the appropriate type
20
+ def for(target)
21
+ type = detect_type(target)
22
+
23
+ case type
24
+ when :file
25
+ FileResource.new(target)
26
+ when :directory
27
+ DirectoryResource.new(target)
28
+ when :url
29
+ UrlResource.new(target)
30
+ when :api
31
+ ApiResource.new(target)
32
+ when :command
33
+ CommandResource.new(target)
34
+ when :none
35
+ NoneResource.new(target)
36
+ else
37
+ BaseResource.new(target) # Default to base resource
38
+ end
39
+ end
40
+
41
+ # Determines the resource type from the target
42
+ # @param target [String] The target specified in the workflow
43
+ # @return [Symbol] :file, :directory, :url, :api, or :none
44
+ def detect_type(target)
45
+ return :none if target.nil? || target.strip.empty?
46
+
47
+ # Check for command syntax $(...)
48
+ if target.match?(/^\$\(.*\)$/)
49
+ return :command
50
+ end
51
+
52
+ # Check for URLs
53
+ if target.start_with?("http://", "https://", "ftp://")
54
+ return :url
55
+ end
56
+
57
+ # Try to parse as URI to detect other URL schemes
58
+ begin
59
+ uri = URI.parse(target)
60
+ return :url if uri.scheme && uri.host
61
+ rescue URI::InvalidURIError
62
+ # Not a URL, continue with other checks
63
+ end
64
+
65
+ # Check for directory
66
+ if Dir.exist?(target)
67
+ return :directory
68
+ end
69
+
70
+ # Check for glob patterns (containing * or ?)
71
+ if target.include?("*") || target.include?("?")
72
+ matches = Dir.glob(target)
73
+ return :none if matches.empty?
74
+ # If the glob matches only directories, treat as directory type
75
+ return :directory if matches.all? { |path| Dir.exist?(path) }
76
+
77
+ # Otherwise treat as file type (could be mixed or all files)
78
+ return :file
79
+ end
80
+
81
+ # Check for file
82
+ if File.exist?(target)
83
+ return :file
84
+ end
85
+
86
+ # Check for API configuration in Fetch API style format
87
+ begin
88
+ potential_config = JSON.parse(target)
89
+ if potential_config.is_a?(Hash) && potential_config.key?("url") && potential_config.key?("options")
90
+ return :api
91
+ end
92
+ rescue JSON::ParserError
93
+ # Not a JSON string, continue with other checks
94
+ end
95
+
96
+ # Default to file for anything else
97
+ :file
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/helpers/logger"
4
+ require "open3"
5
+ require "tempfile"
6
+ require "securerandom"
7
+
8
+ module Roast
9
+ module Tools
10
+ module CodingAgent
11
+ extend self
12
+
13
+ class << self
14
+ def included(base)
15
+ base.class_eval do
16
+ function(
17
+ :coding_agent,
18
+ "AI-powered coding agent that runs Claude Code CLI with the given prompt",
19
+ prompt: { type: "string", description: "The prompt to send to Claude Code" },
20
+ ) do |params|
21
+ Roast::Tools::CodingAgent.call(params[:prompt])
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def call(prompt)
28
+ Roast::Helpers::Logger.info("🤖 Running CodingAgent\n")
29
+ run_claude_code(prompt)
30
+ rescue StandardError => e
31
+ "Error running CodingAgent: #{e.message}".tap do |error_message|
32
+ Roast::Helpers::Logger.error(error_message + "\n")
33
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def run_claude_code(prompt)
40
+ Roast::Helpers::Logger.debug("🤖 Executing Claude Code CLI with prompt: #{prompt}\n")
41
+
42
+ # Create a temporary file with a unique name
43
+ timestamp = Time.now.to_i
44
+ random_id = SecureRandom.hex(8)
45
+ pid = Process.pid
46
+ temp_file = Tempfile.new(["claude_prompt_#{timestamp}_#{pid}_#{random_id}", ".txt"])
47
+
48
+ begin
49
+ # Write the prompt to the file
50
+ temp_file.write(prompt)
51
+ temp_file.close
52
+
53
+ # Run Claude Code CLI using the temp file as input
54
+ claude_code_command = ENV.fetch("CLAUDE_CODE_COMMAND", "claude -p")
55
+ stdout, stderr, status = Open3.capture3("cat #{temp_file.path} | #{claude_code_command}")
56
+
57
+ if status.success?
58
+ stdout
59
+ else
60
+ "Error running ClaudeCode: #{stderr}"
61
+ end
62
+ ensure
63
+ # Always clean up the temp file
64
+ temp_file.unlink
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/roast/tools.rb CHANGED
@@ -8,6 +8,7 @@ require "roast/tools/read_file"
8
8
  require "roast/tools/search_file"
9
9
  require "roast/tools/write_file"
10
10
  require "roast/tools/cmd"
11
+ require "roast/tools/coding_agent"
11
12
 
12
13
  module Roast
13
14
  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.1.0"
4
+ VERSION = "0.1.1"
5
5
  end