roast-ai 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1986abc657a0e2c822c55ee0d5f2b2682c1e5cfed5a2d5f22e535a17fb074863
4
- data.tar.gz: 59c0f6d1ac0c23a54e3ae05a7e1c0739da7355e8fd859b857670abc9887f7dd5
3
+ metadata.gz: a5ba7cc0a49bb69453853fcd6f4e2976d06260a9fc0c5c949007f313fba05a24
4
+ data.tar.gz: bd2a41c501b808ccea6970c7cb2af195848708fc23acb2c35aae6fa3a143b05d
5
5
  SHA512:
6
- metadata.gz: 59a575e029f23d5e599755effbca910ff7e7853095c4eee445e74c65ad7bfcff57c5c4e43a4a7c8df402660def83b33dffcd88b0280a018edf31206575aafdf0
7
- data.tar.gz: 0dced7351e53860f44a105e6604282583903953c85f25c5bffc03acc1d5785ad556c0be5473da514d827b17ff5acce295ae8447b2a434b311809c75832c36327
6
+ metadata.gz: 475cd2ca97a13f701fd4c699257090b2b89b559bde22b1391506daa8c876dbbd6a3d2711d6e46a2a22f34db1c83477a373728575eeb9178c113447ec624048d5
7
+ data.tar.gz: 4a68745484d9aa4da868cf99340eb27578d507c632a2d935c5e6aa5dc037501db56ed271042f295d0e3d8a4e8247cef9eec5aaa66dad02295a9e020cd2d3085f
data/Gemfile.lock CHANGED
@@ -1,9 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- roast-ai (1.0.0)
4
+ roast-ai (1.0.1)
5
5
  activesupport (~> 8.0)
6
6
  async (>= 2.34)
7
+ rainbow (>= 3.0.0)
7
8
  ruby_llm (>= 1.8)
8
9
  zeitwerk (>= 2.6)
9
10
 
@@ -0,0 +1,13 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ config do
7
+ cmd { display! }
8
+ end
9
+
10
+ execute do
11
+ cmd(:out) { "echo hello world" }
12
+ cmd(:err) { "echo goodnight moon >&2" }
13
+ end
@@ -0,0 +1,27 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ # By default, Roast logs to standard error at the WARN level. You can easily override this with the ROAST_LOG_LEVEL
7
+ # environment variable, without having to touch the logger's configuration itself.
8
+ # Valid levels are DEBUG, INFO, WARN, ERROR, or FATAL (not case-sensitive).
9
+ # For more advanced configuration, such as configuring a custom log formatter, or logging to a custom output location,
10
+ # you can configure ore replace the logger instance used by Roast (Roast::Log.logger)
11
+
12
+ # Log to standard output, always at the DEBUG level
13
+ Roast::Log.logger = Logger.new($stdout).tap { |logger| logger.level = ::Logger::DEBUG }
14
+
15
+ # Format log lines in a particular way
16
+ Roast::Log.logger.formatter = proc do |severity, time, progname, msg|
17
+ "#{severity[0..0]}, #{msg.strip} (at #{time})\n"
18
+ end
19
+
20
+
21
+ config do
22
+ cmd(:echo) { display! }
23
+ end
24
+
25
+ execute do
26
+ cmd(:echo) { "echo hello world" }
27
+ end
@@ -1,9 +1,10 @@
1
1
  PATH
2
2
  remote: ../..
3
3
  specs:
4
- roast-ai (1.0.0)
4
+ roast-ai (1.0.1)
5
5
  activesupport (~> 8.0)
6
6
  async (>= 2.34)
7
+ rainbow (>= 3.0.0)
7
8
  ruby_llm (>= 1.8)
8
9
  zeitwerk (>= 2.6)
9
10
 
@@ -71,6 +72,7 @@ GEM
71
72
  multipart-post (2.4.1)
72
73
  net-http (0.9.1)
73
74
  uri (>= 0.11.1)
75
+ rainbow (3.1.1)
74
76
  ruby_llm (1.8.2)
75
77
  base64
76
78
  event_stream_parser (~> 1)
@@ -1,9 +1,10 @@
1
1
  PATH
2
2
  remote: ../..
3
3
  specs:
4
- roast-ai (1.0.0)
4
+ roast-ai (1.0.1)
5
5
  activesupport (~> 8.0)
6
6
  async (>= 2.34)
7
+ rainbow (>= 3.0.0)
7
8
  ruby_llm (>= 1.8)
8
9
  zeitwerk (>= 2.6)
9
10
 
@@ -0,0 +1,37 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ #: self as Roast::Workflow
5
+
6
+ # If you have untrusted strings you want to use in `cmd` cog shell commands,
7
+ # you have several built-in options for safety
8
+
9
+ config do
10
+ cmd { display! }
11
+ end
12
+
13
+ execute do
14
+ cmd(:attack_succeeds) do
15
+ bad_param = "; echo bad"
16
+ "echo hello world #{bad_param}"
17
+ end
18
+
19
+ cmd(:attack_fails_because_of_sanitization) do
20
+ # Use `.shellescape` on any untrusted strings you're interpolating into shell command strings
21
+ bad_param = "; echo bad"
22
+ "echo hello world #{bad_param.shellescape}"
23
+ end
24
+
25
+ cmd(:attack_fails_because_of_explicit_args) do |my|
26
+ # Use an array of arguments instead of string interpolation. This skips the shell entirely and just run the command.
27
+ bad_param = "; echo bad"
28
+ my.command = "echo"
29
+ my.args = ["hello", "world", bad_param]
30
+ end
31
+
32
+ cmd(:attack_fails_because_of_explicit_args_shorthand) do
33
+ # Simply returning an array instead of a string is convenient shorthand
34
+ bad_param = "; echo bad"
35
+ ["echo", "hello", "world", bad_param]
36
+ end
37
+ end
data/lib/roast/cog.rb CHANGED
@@ -44,9 +44,10 @@ module Roast
44
44
  #: Cog::Output?
45
45
  attr_reader :output
46
46
 
47
- #: (Symbol, ^(Cog::Input) -> untyped) -> void
48
- def initialize(name, cog_input_proc)
47
+ #: (Symbol, ^(Cog::Input) -> untyped, ?anonymous: bool) -> void
48
+ def initialize(name, cog_input_proc, anonymous: false)
49
49
  @name = name
50
+ @anonymous = anonymous
50
51
  @cog_input_proc = cog_input_proc #: ^(Cog::Input) -> untyped
51
52
  @output = nil #: Cog::Output?
52
53
  @skipped = false #: bool
@@ -56,12 +57,23 @@ module Roast
56
57
  @config = self.class.config_class.new #: untyped
57
58
  end
58
59
 
60
+ #: () -> bool
61
+ def anonymous?
62
+ @anonymous
63
+ end
64
+
65
+ #: () -> String
66
+ def type
67
+ self.class.name.not_nil!.demodulize.underscore
68
+ end
69
+
59
70
  #: (Async::Barrier, Cog::Config, CogInputContext, untyped, Integer) -> Async::Task
60
71
  def run!(barrier, config, input_context, executor_scope_value, executor_scope_index)
61
72
  raise CogAlreadyStartedError if @task
62
73
 
63
74
  @task = barrier.async(finished: false) do |task|
64
- task.annotate("#{self.class.name.not_nil!.demodulize.camelcase} Cog: #{@name}")
75
+ task.annotate("Cog #{type}(:#{@name})")
76
+ TaskContext.begin_cog(self)
65
77
  @config = config
66
78
  input_instance = self.class.input_class.new
67
79
  input_return = input_context.instance_exec(
@@ -84,6 +96,8 @@ module Roast
84
96
  rescue StandardError => e
85
97
  @failed = true
86
98
  raise e
99
+ ensure
100
+ TaskContext.end
87
101
  end
88
102
  end
89
103
 
@@ -158,6 +158,8 @@ module Roast
158
158
  Roast::Log.debug("[FULL MESSAGE: #{message.type}]")
159
159
  Roast::Log.debug(message.inspect)
160
160
  end
161
+
162
+ raise ClaudeFailedError, message.error if message.error.present?
161
163
  end
162
164
 
163
165
  #: () -> Array[String]
@@ -31,6 +31,8 @@ module Roast
31
31
  Messages::SystemMessage.new(type:, hash:)
32
32
  when :text
33
33
  Messages::TextMessage.new(type:, hash:)
34
+ when :thinking
35
+ Messages::ThinkingMessage.new(type:, hash:)
34
36
  when :tool_result
35
37
  Messages::ToolResultMessage.new(type:, hash:)
36
38
  when :tool_use
@@ -49,6 +51,9 @@ module Roast
49
51
  #: Symbol
50
52
  attr_reader :type
51
53
 
54
+ #: String?
55
+ attr_reader :error
56
+
52
57
  #: Hash[Symbol, untyped]
53
58
  attr_reader :unparsed
54
59
 
@@ -56,6 +61,7 @@ module Roast
56
61
  def initialize(type:, hash:)
57
62
  @session_id = hash.delete(:session_id)
58
63
  @type = type
64
+ @error = hash.delete(:error)
59
65
  hash.except!(*IGNORED_FIELDS)
60
66
  @unparsed = hash
61
67
  end
@@ -11,6 +11,7 @@ module Roast
11
11
  IGNORED_FIELDS = [
12
12
  :duration_api_ms,
13
13
  :permission_denials,
14
+ :stop_reason,
14
15
  :usage,
15
16
  :uuid,
16
17
  ].freeze
@@ -9,23 +9,27 @@ module Roast
9
9
  module Messages
10
10
  class SystemMessage < Message
11
11
  IGNORED_FIELDS = [
12
- :subtype,
13
- :cwd,
14
- :tools,
15
- :mcp_servers,
16
- :permissionMode,
17
- :slash_commands,
12
+ :agents,
18
13
  :apiKeySource,
14
+ :compact_metadata,
19
15
  :claude_code_version,
16
+ :cwd,
17
+ :exit_code,
18
+ :fast_mode_state,
19
+ :hook_event,
20
+ :hook_name,
21
+ :mcp_servers,
20
22
  :output_style,
21
- :agents,
22
- :skills,
23
+ :permissionMode,
23
24
  :plugins,
24
- :hook_name,
25
- :hook_event,
26
- :stdout,
25
+ :skills,
26
+ :slash_commands,
27
+ # TODO: "status": "compacting" indicates compaction in progress. We might want to handle that someday
28
+ :status,
27
29
  :stderr,
28
- :exit_code,
30
+ :stdout,
31
+ :subtype,
32
+ :tools,
29
33
  ].freeze
30
34
 
31
35
  #: String?
@@ -0,0 +1,36 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module Cogs
6
+ class Agent < Cog
7
+ module Providers
8
+ class Claude < Provider
9
+ module Messages
10
+ class ThinkingMessage < Message
11
+ IGNORED_FIELDS = [
12
+ :signature,
13
+ :role,
14
+ ].freeze
15
+
16
+ #: String
17
+ attr_reader :thinking
18
+
19
+ #: (type: Symbol, hash: Hash[Symbol, untyped]) -> void
20
+ def initialize(type:, hash:)
21
+ @thinking = hash.delete(:thinking) || ""
22
+ hash.except!(*IGNORED_FIELDS)
23
+ super(type:, hash:)
24
+ end
25
+
26
+ #: (ClaudeInvocation::Context) -> String?
27
+ def format(context)
28
+ @thinking
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -9,6 +9,7 @@ module Roast
9
9
  module Messages
10
10
  class ToolUseMessage < Message
11
11
  IGNORED_FIELDS = [
12
+ :caller,
12
13
  :role,
13
14
  ].freeze
14
15
 
@@ -0,0 +1,75 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class Event
6
+ class << self
7
+ #: (Hash[Symbol, untyped]) -> void
8
+ def <<(event)
9
+ EventMonitor.accept(Event.new(TaskContext.path, event))
10
+ end
11
+ end
12
+
13
+ LOG_TYPE_KEYS = [
14
+ :fatal,
15
+ :error,
16
+ :warn,
17
+ :info,
18
+ :debug,
19
+ :unknown,
20
+ ].freeze #: Array[Symbol]
21
+
22
+ OTHER_TYPE_KEYS = [
23
+ :begin,
24
+ :end,
25
+ :stdout,
26
+ :stderr,
27
+ ].freeze #: Array[Symbol]
28
+
29
+ #: Array[TaskContext::PathElement]
30
+ attr_reader :path
31
+
32
+ #: Hash[Symbol, untyped] :payload
33
+ attr_reader :payload
34
+
35
+ #: Time
36
+ attr_reader :time
37
+
38
+ delegate :[], :key?, :keys, to: :payload
39
+
40
+ #: (Array[TaskContext::PathElement] path, Hash[Symbol, untyped]) -> void
41
+ def initialize(path, payload)
42
+ @path = path
43
+ @payload = payload
44
+ @time = Time.now
45
+ end
46
+
47
+ #: () -> Symbol
48
+ def type
49
+ return :log if (LOG_TYPE_KEYS & @payload.keys).present?
50
+
51
+ (OTHER_TYPE_KEYS & @payload.keys).first || :unknown
52
+ end
53
+
54
+ #: () -> Integer
55
+ def log_severity
56
+ severity = case type
57
+ when :log
58
+ (LOG_TYPE_KEYS & @payload.keys).first || :unknown
59
+ when :stderr
60
+ :warn
61
+ else
62
+ :info
63
+ end
64
+ Logger::Severity.const_get(:LEVELS)[severity.to_s] # rubocop:disable Sorbet/ConstantsFromStrings
65
+ end
66
+
67
+ #: () -> String
68
+ def log_message
69
+ key = (LOG_TYPE_KEYS & @payload.keys).first
70
+ return "" unless key.present?
71
+
72
+ payload[key] || ""
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,163 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module EventMonitor
6
+ extend self
7
+ include Kernel
8
+
9
+ class EventMonitorError < StandardError; end
10
+
11
+ class EventMonitorAlreadyStartedError < EventMonitorError; end
12
+
13
+ class EventMonitorNotRunningError < EventMonitorError; end
14
+
15
+ @queue = Async::Queue.new.tap(&:close) #: Async::Queue
16
+ @task = nil #: Async::Task?
17
+
18
+ #: () -> bool
19
+ def running?
20
+ !@queue.closed?
21
+ end
22
+
23
+ #: () -> Async::Task
24
+ def start!
25
+ raise EventMonitorAlreadyStartedError if running?
26
+
27
+ OutputRouter.enable!
28
+ @queue = Async::Queue.new
29
+ @task = Async(transient: true) do
30
+ OutputRouter.mark_as_output_fiber!
31
+ loop do
32
+ event = @queue.pop #: as Event?
33
+ break if event.nil?
34
+
35
+ handle_event(event)
36
+ end
37
+ end
38
+ end
39
+
40
+ #: () -> void
41
+ def stop!
42
+ raise EventMonitorNotRunningError unless running?
43
+
44
+ OutputRouter.disable!
45
+ @queue.close
46
+ @task&.wait
47
+ @task = nil
48
+ end
49
+
50
+ #: () -> void
51
+ def reset!
52
+ OutputRouter.disable!
53
+ @queue.close
54
+ @task = nil
55
+ end
56
+
57
+ #: (Event) -> void
58
+ def accept(event)
59
+ if running?
60
+ @queue.push(event)
61
+ else
62
+ handle_event(event)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ #: (Event) -> void
69
+ def handle_event(event)
70
+ with_stubbed_class_method_returning(Time, :now, event.time) do
71
+ OutputRouter.mark_as_output_fiber!
72
+ handler_method_name = "handle_#{event.type}_event".to_sym
73
+ if respond_to?(handler_method_name, true)
74
+ send(handler_method_name, event)
75
+ else
76
+ handle_unknown_event(event)
77
+ end
78
+ end
79
+ end
80
+
81
+ #: (Event) -> void
82
+ def handle_begin_event(event)
83
+ # The first path element is always the top-level ExecutionManager
84
+ handle_begin_workflow_event(event) if event.path.length == 1
85
+ return unless event[:begin].cog.present?
86
+
87
+ Roast::Log.logger.info { "#{format_path(event)} Starting" }
88
+ end
89
+
90
+ def handle_begin_workflow_event(event)
91
+ execution_manager = event[:begin].execution_manager.not_nil!
92
+ workflow_context = execution_manager.workflow_context
93
+ Roast::Log.logger.info("🔥🔥🔥 Workflow Starting")
94
+ Roast::Log.logger.debug do
95
+ message = <<~MESSAGE
96
+ Workflow Context:
97
+ Targets: #{workflow_context.params.targets}
98
+ Args: #{workflow_context.params.args}
99
+ Kwargs: #{workflow_context.params.kwargs}
100
+ Temporary Directory: #{workflow_context.tmpdir}
101
+ Workflow Directory: #{workflow_context.workflow_dir}
102
+ Working Directory: #{Dir.pwd}
103
+ MESSAGE
104
+ message.strip
105
+ end
106
+ end
107
+
108
+ #: (Event) -> void
109
+ def handle_end_event(event)
110
+ # The first path element is always the top-level ExecutionManager
111
+ Roast::Log.logger.info("🔥🔥🔥 Workflow Complete") if event.path.length == 1
112
+ return unless event[:end].cog.present?
113
+
114
+ Roast::Log.logger.info { "#{format_path(event)} Complete" }
115
+ end
116
+
117
+ #: (Event) -> void
118
+ def handle_log_event(event)
119
+ Roast::Log.logger.add(event.log_severity, event.log_message)
120
+ end
121
+
122
+ #: (Event) -> void
123
+ def handle_stderr_event(event)
124
+ Roast::Log.logger.warn { "#{format_path(event)} ❯❯ #{event[:stderr]}" }
125
+ end
126
+
127
+ #: (Event) -> void
128
+ def handle_stdout_event(event)
129
+ Roast::Log.logger.info { "#{format_path(event)} ❯ #{event[:stdout]}" }
130
+ end
131
+
132
+ #: (Event) -> void
133
+ def handle_unknown_event(event)
134
+ Roast::Log.logger.unknown(event.inspect)
135
+ end
136
+
137
+ #: (Event) -> String
138
+ def format_path(event)
139
+ event.path.map do |element|
140
+ cog = element.cog
141
+ execution_manager = element.execution_manager
142
+ if cog.present?
143
+ "#{cog.type}#{cog.anonymous? ? "" : "(:#{cog.name})"}"
144
+ elsif execution_manager&.scope
145
+ "{:#{execution_manager.scope}}[#{execution_manager.scope_index}]"
146
+ end
147
+ end.compact.join(" -> ")
148
+ end
149
+
150
+ #: [T] (Class, Symbol, untyped) { () -> T } -> T
151
+ def with_stubbed_class_method_returning(klass, method_name, return_value, &blk)
152
+ original_method = klass.singleton_class.instance_method(method_name)
153
+ klass.singleton_class.silence_redefinition_of_method(method_name)
154
+ klass.define_singleton_method(method_name, proc { return_value })
155
+ blk.call
156
+ ensure
157
+ if original_method
158
+ klass.singleton_class.silence_redefinition_of_method(method_name)
159
+ klass.define_singleton_method(method_name, original_method)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -24,6 +24,18 @@ module Roast
24
24
 
25
25
  class OutputsAlreadyDefinedError < ExecutionManagerError; end
26
26
 
27
+ #: WorkflowContext
28
+ attr_reader :workflow_context
29
+
30
+ #: Symbol?
31
+ attr_reader :scope
32
+
33
+ #: untyped
34
+ attr_reader :scope_value
35
+
36
+ #: Integer
37
+ attr_reader :scope_index
38
+
27
39
  #: untyped
28
40
  attr_reader :final_output
29
41
 
@@ -79,6 +91,7 @@ module Roast
79
91
  @running = true
80
92
  Sync do |sync_task|
81
93
  sync_task.annotate("ExecutionManager #{@scope}")
94
+ TaskContext.begin_execution_manager(self)
82
95
  @cog_stack.each do |cog|
83
96
  cog_config = @config_manager.config_for(cog.class, cog.name)
84
97
  cog_task = cog.run!(
@@ -97,6 +110,7 @@ module Roast
97
110
  ensure
98
111
  @barrier.stop
99
112
  compute_final_output
113
+ TaskContext.end
100
114
  @running = false
101
115
  end
102
116
  end
@@ -199,8 +213,14 @@ module Roast
199
213
  raise NotImplementedError, "No system cog manager defined for #{cog_class}"
200
214
  end
201
215
  else
202
- cog_name = Array.wrap(cog_args).shift || Cog.generate_fallback_name
203
- cog_instance = cog_class.new(cog_name, cog_input_proc)
216
+ cog_name = Array.wrap(cog_args).shift
217
+ if cog_name
218
+ anonymous = false
219
+ else
220
+ anonymous = true
221
+ cog_name = Cog.generate_fallback_name
222
+ end
223
+ cog_instance = cog_class.new(cog_name, cog_input_proc, anonymous:)
204
224
  end
205
225
  add_cog_instance(cog_instance)
206
226
  end
data/lib/roast/log.rb CHANGED
@@ -4,8 +4,9 @@
4
4
  module Roast
5
5
  # Central logging interface for Roast.
6
6
  #
7
- # Provides a simple, testable logging API that wraps the standard library Logger.
8
- # Outputs to $stderr by default.
7
+ # Provides a simple, testable logging API that wraps the standard library Logger
8
+ # and leverages Roast's Event framework for clean async task integration with proper task hierarchy attribution.
9
+ # Outputs to STDERR by default.
9
10
  #
10
11
  # @example Basic usage
11
12
  # Roast::Log.info("Processing file...")
@@ -17,61 +18,83 @@ module Roast
17
18
  # Roast::Log.logger = Rails.logger
18
19
  #
19
20
  module Log
21
+ extend self
22
+ include Kernel
23
+
20
24
  LOG_LEVELS = {
21
- "DEBUG" => ::Logger::DEBUG,
22
- "INFO" => ::Logger::INFO,
23
- "WARN" => ::Logger::WARN,
24
- "ERROR" => ::Logger::ERROR,
25
- "FATAL" => ::Logger::FATAL,
26
- }.freeze
27
-
28
- class << self
29
- attr_writer :logger
30
-
31
- def debug(message)
32
- logger.debug(message)
33
- end
25
+ DEBUG: ::Logger::DEBUG,
26
+ INFO: ::Logger::INFO,
27
+ WARN: ::Logger::WARN,
28
+ ERROR: ::Logger::ERROR,
29
+ FATAL: ::Logger::FATAL,
30
+ }.freeze #: Hash[Symbol, Integer]
34
31
 
35
- def info(message)
36
- logger.info(message)
37
- end
32
+ attr_writer :logger
38
33
 
39
- def warn(message)
40
- logger.warn(message)
41
- end
34
+ #: (String) -> void
35
+ def debug(message)
36
+ Roast::Event << { debug: message }
37
+ end
42
38
 
43
- def error(message)
44
- logger.error(message)
45
- end
39
+ #: (String) -> void
40
+ def info(message)
41
+ Roast::Event << { info: message }
42
+ end
46
43
 
47
- def fatal(message)
48
- logger.fatal(message)
49
- end
44
+ #: (String) -> void
45
+ def warn(message)
46
+ Roast::Event << { warn: message }
47
+ end
50
48
 
51
- def logger
52
- @logger ||= create_logger
53
- end
49
+ #: (String) -> void
50
+ def error(message)
51
+ Roast::Event << { error: message }
52
+ end
54
53
 
55
- def reset!
56
- @logger = nil
57
- end
54
+ #: (String) -> void
55
+ def fatal(message)
56
+ Roast::Event << { fatal: message }
57
+ end
58
58
 
59
- private
59
+ #: (String) -> void
60
+ def unknown(message)
61
+ Roast::Event << { unknown: message }
62
+ end
60
63
 
61
- def create_logger
62
- ::Logger.new($stderr, progname: "roast").tap do |l|
63
- l.level = LOG_LEVELS.fetch(log_level)
64
- end
65
- end
64
+ #: () -> Logger
65
+ def logger
66
+ @logger ||= create_logger
67
+ end
68
+
69
+ #: () -> void
70
+ def reset!
71
+ @logger = nil
72
+ end
66
73
 
67
- def log_level
68
- level_str = (ENV["ROAST_LOG_LEVEL"] || "INFO").upcase
69
- unless LOG_LEVELS.key?(level_str)
70
- raise ArgumentError, "Invalid log level: #{level_str}. Valid levels are: #{LOG_LEVELS.keys.join(", ")}"
71
- end
74
+ #: () -> bool
75
+ def tty?
76
+ return false unless @logger
72
77
 
73
- level_str
78
+ logdev = @logger.instance_variable_get(:@logdev)&.dev
79
+ logdev&.respond_to?(:isatty) && logdev&.isatty
80
+ end
81
+
82
+ private
83
+
84
+ #: () -> Logger
85
+ def create_logger
86
+ ::Logger.new($stderr, progname: "Roast").tap do |l|
87
+ l.level = LOG_LEVELS.fetch(log_level("INFO"))
88
+ l.formatter = Roast::LogFormatter.new(tty: $stderr.tty?)
74
89
  end
75
90
  end
91
+
92
+ #: (String) -> Symbol
93
+ def log_level(default_level)
94
+ level = (ENV["ROAST_LOG_LEVEL"] || default_level).upcase.to_sym
95
+ raise ArgumentError, "Invalid log level: #{level}. Valid levels are: #{LOG_LEVELS.keys.join(", ")}" unless LOG_LEVELS.key?(level)
96
+
97
+ level
98
+ end
76
99
  end
77
100
  end
@@ -0,0 +1,56 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class LogFormatter < ::Logger::Formatter
6
+ TTY_FORMAT = "• %.1s, %s\n" #: String
7
+ NON_TTY_FORMAT = "%.1s, [%s] %5s -- %s\n" #: String
8
+ DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%6N" #: String
9
+
10
+ #: (tty: bool) -> void
11
+ def initialize(tty:)
12
+ super()
13
+ @tty = tty
14
+ @rainbow = Rainbow.new.tap { |r| r.enabled = tty }
15
+ end
16
+
17
+ def call(severity, time, _progname, msg)
18
+ line = if @tty
19
+ format(TTY_FORMAT, severity, msg2str(msg))
20
+ else
21
+ format(NON_TTY_FORMAT, severity, time.strftime(DATETIME_FORMAT), severity, msg2str(msg))
22
+ end
23
+ colourize(severity, line)
24
+ end
25
+
26
+ private
27
+
28
+ #: (String, String) -> String
29
+ def colourize(severity, line)
30
+ if line.include?("❯❯") # standard error lines
31
+ @rainbow.wrap(line).yellow
32
+ elsif line.include?("❯") # standard output lines
33
+ @rainbow.wrap(line)
34
+ else
35
+ case severity
36
+ when "ERROR", "FATAL" then @rainbow.wrap(line).red
37
+ when "WARN" then @rainbow.wrap(line).color("#FF8C00") # orange
38
+ when "INFO" then @rainbow.wrap(line).bright
39
+ when "DEBUG" then @rainbow.wrap(line).faint
40
+ else line
41
+ end
42
+ end
43
+ end
44
+
45
+ #: (String | Exception | untyped) -> String
46
+ def msg2str(msg)
47
+ msg = case msg
48
+ when ::String
49
+ msg.strip
50
+ else
51
+ msg
52
+ end
53
+ super(msg)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,76 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class OutputRouter
6
+ # This is the name of the alias methods for `:write` on the $stdout and $stderr objects
7
+ # that bypass OutputRouter's wrapper. Calling this method will write to those output stream directly.
8
+ WRITE_WITHOUT_ROAST = :write_without_roast
9
+
10
+ class << self
11
+ #: () -> bool
12
+ def enable!
13
+ return false if enabled?
14
+
15
+ activate($stdout, :stdout)
16
+ activate($stderr, :stderr)
17
+ mark_as_output_fiber!
18
+ true
19
+ end
20
+
21
+ #: () -> bool
22
+ def disable!
23
+ return false unless enabled?
24
+
25
+ deactivate($stdout)
26
+ deactivate($stderr)
27
+ @output_fiber = nil
28
+ true
29
+ end
30
+
31
+ #: () -> bool
32
+ def enabled?
33
+ $stdout.respond_to?(WRITE_WITHOUT_ROAST)
34
+ end
35
+
36
+ #: () -> bool
37
+ def output_fiber?
38
+ @output_fiber == Fiber.current
39
+ end
40
+
41
+ #: () -> void
42
+ def mark_as_output_fiber!
43
+ @output_fiber = Fiber.current
44
+ end
45
+
46
+ private
47
+
48
+ #: (IO stream, Symbol name) -> void
49
+ def activate(stream, name)
50
+ router = self
51
+ stream.singleton_class.send(:alias_method, WRITE_WITHOUT_ROAST, :write)
52
+ stream.define_singleton_method(:write) do |*args|
53
+ if router.output_fiber?
54
+ self #: as untyped # rubocop:disable Style/RedundantSelf
55
+ .send(WRITE_WITHOUT_ROAST, *args)
56
+ else
57
+ str = args.map(&:to_s).join
58
+ Event << case name
59
+ when :stdout then { stdout: str }
60
+ when :stderr then { stderr: str }
61
+ else { unknown: str }
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ #: (IO stream) -> void
68
+ def deactivate(stream)
69
+ sc = stream.singleton_class
70
+ sc.send(:remove_method, :write)
71
+ sc.send(:alias_method, :write, WRITE_WITHOUT_ROAST)
72
+ sc.send(:remove_method, WRITE_WITHOUT_ROAST)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -23,8 +23,16 @@ module Roast
23
23
  #
24
24
  #: (Symbol?) -> void
25
25
  def initialize(name)
26
+ @anonymous = name.nil? #: bool
26
27
  @name = name || Cog.generate_fallback_name
27
28
  end
29
+
30
+ # Whether the cog is using a fallback name, or was given one explicitly.
31
+ #
32
+ #: () -> bool
33
+ def anonymous?
34
+ @anonymous
35
+ end
28
36
  end
29
37
  end
30
38
  end
@@ -18,9 +18,9 @@ module Roast
18
18
  end
19
19
  end
20
20
 
21
- #: (Symbol, ^(Cog::Input) -> untyped) { (Cog::Input, Cog::Config) -> Cog::Output } -> void
22
- def initialize(name, cog_input_proc, &on_execute)
23
- super(name, cog_input_proc)
21
+ #: (Symbol, ^(Cog::Input) -> untyped, anonymous: bool) { (Cog::Input, Cog::Config) -> Cog::Output } -> void
22
+ def initialize(name, cog_input_proc, anonymous:, &on_execute)
23
+ super(name, cog_input_proc, anonymous:)
24
24
  @on_execute = on_execute
25
25
  end
26
26
 
@@ -89,7 +89,7 @@ module Roast
89
89
 
90
90
  #: (Params, ^(Cog::Input) -> untyped) -> SystemCogs::Call
91
91
  def create_call_system_cog(params, input_proc)
92
- SystemCogs::Call.new(params.name, input_proc) do |input|
92
+ SystemCogs::Call.new(params.name, input_proc, anonymous: params.anonymous?) do |input|
93
93
  input = input #: as Input
94
94
  raise ExecutionManager::ExecutionScopeNotSpecifiedError unless params.run.present?
95
95
 
@@ -257,7 +257,7 @@ module Roast
257
257
 
258
258
  #: (Params, ^(Cog::Input) -> untyped) -> SystemCogs::Map
259
259
  def create_map_system_cog(params, input_proc)
260
- SystemCogs::Map.new(params.name, input_proc) do |input, config|
260
+ SystemCogs::Map.new(params.name, input_proc, anonymous: params.anonymous?) do |input, config|
261
261
  raise ExecutionManager::ExecutionScopeNotSpecifiedError unless params.run.present?
262
262
 
263
263
  input = input #: as Input
@@ -206,7 +206,7 @@ module Roast
206
206
 
207
207
  #: (Params, ^(Cog::Input) -> untyped) -> SystemCogs::Repeat
208
208
  def create_repeat_system_cog(params, input_proc)
209
- SystemCogs::Repeat.new(params.name, input_proc) do |input|
209
+ SystemCogs::Repeat.new(params.name, input_proc, anonymous: params.anonymous?) do |input|
210
210
  input = input #: as Input
211
211
  raise ExecutionManager::ExecutionScopeNotSpecifiedError unless params.run.present?
212
212
 
@@ -0,0 +1,53 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module TaskContext
6
+ extend self
7
+
8
+ class PathElement
9
+ #: Cog?
10
+ attr_reader :cog
11
+
12
+ #: ExecutionManager?
13
+ attr_reader :execution_manager
14
+
15
+ #: (?cog: Cog?, ?execution_manager: ExecutionManager?) -> void
16
+ def initialize(cog: nil, execution_manager: nil)
17
+ @cog = cog
18
+ @execution_manager = execution_manager
19
+ end
20
+ end
21
+
22
+ #: () -> Array[PathElement]
23
+ def path
24
+ Fiber[:path]&.deep_dup || []
25
+ end
26
+
27
+ #: (Cog) -> Array[PathElement]
28
+ def begin_cog(cog)
29
+ begin_element(PathElement.new(cog:))
30
+ end
31
+
32
+ #: (ExecutionManager) -> Array[PathElement]
33
+ def begin_execution_manager(execution_manager)
34
+ begin_element(PathElement.new(execution_manager:))
35
+ end
36
+
37
+ #: () -> [PathElement, Array[PathElement]]
38
+ def end
39
+ Event << { end: Fiber[:path]&.last }
40
+ el = Fiber[:path]&.pop
41
+ [el, path]
42
+ end
43
+
44
+ private
45
+
46
+ #: (PathElement) -> Array[PathElement]
47
+ def begin_element(element)
48
+ Fiber[:path] = (Fiber[:path] || []) + [element]
49
+ Event << { begin: element }
50
+ path
51
+ end
52
+ end
53
+ end
data/lib/roast/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Roast
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.1"
6
6
  end
@@ -4,20 +4,28 @@
4
4
  module Roast
5
5
  class Workflow
6
6
  class WorkflowError < Roast::Error; end
7
+
7
8
  class WorkflowNotPreparedError < WorkflowError; end
9
+
8
10
  class WorkflowAlreadyPreparedError < WorkflowError; end
11
+
9
12
  class WorkflowAlreadyStartedError < WorkflowError; end
13
+
10
14
  class InvalidLoadableReference < WorkflowError; end
11
15
 
12
16
  class << self
13
17
  #: (String | Pathname, WorkflowParams) -> void
14
18
  def from_file(workflow_path, params)
15
- Dir.mktmpdir("roast-") do |tmpdir|
16
- workflow_dir = Pathname.new(workflow_path).dirname
17
- workflow_context = WorkflowContext.new(params: params, tmpdir: tmpdir, workflow_dir: workflow_dir)
18
- workflow = new(workflow_path, workflow_context)
19
- workflow.prepare!
20
- workflow.start!
19
+ Sync do
20
+ Dir.mktmpdir("roast-") do |tmpdir|
21
+ EventMonitor.start!
22
+ workflow_dir = Pathname.new(workflow_path).dirname
23
+ workflow_context = WorkflowContext.new(params: params, tmpdir: tmpdir, workflow_dir: workflow_dir)
24
+ workflow = new(workflow_path, workflow_context)
25
+ workflow.prepare!
26
+ workflow.start!
27
+ EventMonitor.stop!
28
+ end
21
29
  end
22
30
  end
23
31
  end
data/lib/roast.rb CHANGED
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  # Standard library requires
5
- require "digest"
6
5
  require "English"
6
+ require "digest"
7
7
  require "erb"
8
8
  require "fileutils"
9
9
  require "json"
@@ -24,14 +24,15 @@ require "active_support"
24
24
  require "active_support/cache"
25
25
  require "active_support/core_ext/array"
26
26
  require "active_support/core_ext/hash"
27
- require "active_support/core_ext/object/deep_dup"
28
27
  require "active_support/core_ext/module/delegation"
28
+ require "active_support/core_ext/object/deep_dup"
29
29
  require "active_support/core_ext/string"
30
30
  require "active_support/core_ext/string/inflections"
31
31
  require "active_support/isolated_execution_state"
32
32
  require "active_support/notifications"
33
33
  require "async"
34
34
  require "async/semaphore"
35
+ require "rainbow"
35
36
  require "ruby_llm"
36
37
 
37
38
  # Require project components that will not get automatically loaded
data/roast-ai.gemspec CHANGED
@@ -33,6 +33,7 @@ Gem::Specification.new do |spec|
33
33
 
34
34
  spec.add_dependency("activesupport", "~> 8.0")
35
35
  spec.add_dependency("async", ">= 2.34")
36
+ spec.add_dependency("rainbow", ">= 3.0.0")
36
37
  spec.add_dependency("ruby_llm", ">= 1.8")
37
38
  spec.add_dependency("zeitwerk", ">= 2.6")
38
39
  end
@@ -6,8 +6,8 @@ require "active_support"
6
6
  require "active_support/cache"
7
7
  require "active_support/core_ext/array"
8
8
  require "active_support/core_ext/hash"
9
- require "active_support/core_ext/object/deep_dup"
10
9
  require "active_support/core_ext/module/delegation"
10
+ require "active_support/core_ext/object/deep_dup"
11
11
  require "active_support/core_ext/string"
12
12
  require "active_support/core_ext/string/inflections"
13
13
  require "active_support/isolated_execution_state"
@@ -17,8 +17,9 @@ require "async/semaphore"
17
17
  require "bundler/setup"
18
18
  require "json"
19
19
  require "open3"
20
+ require "optparse"
21
+ require "rainbow"
20
22
  require "shellwords"
21
23
  require "sqlite3"
22
- require "optparse"
23
24
  require "timeout"
24
25
  require "zeitwerk"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roast-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.34'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rainbow
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.0.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.0.0
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: ruby_llm
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +121,8 @@ files:
107
121
  - examples/async_cogs_complex.rb
108
122
  - examples/call.rb
109
123
  - examples/collect_from.rb
124
+ - examples/console_output.rb
125
+ - examples/custom_logging.rb
110
126
  - examples/demo/Gemfile
111
127
  - examples/demo/Gemfile.lock
112
128
  - examples/demo/cogs/local.rb
@@ -133,6 +149,7 @@ files:
133
149
  - examples/prototype.rb
134
150
  - examples/repeat_loop_results.rb
135
151
  - examples/ruby_cog.rb
152
+ - examples/shell_sanitization.rb
136
153
  - examples/simple_agent.rb
137
154
  - examples/simple_chat.rb
138
155
  - examples/simple_repeat.rb
@@ -171,6 +188,7 @@ files:
171
188
  - lib/roast/cogs/agent/providers/claude/messages/result_message.rb
172
189
  - lib/roast/cogs/agent/providers/claude/messages/system_message.rb
173
190
  - lib/roast/cogs/agent/providers/claude/messages/text_message.rb
191
+ - lib/roast/cogs/agent/providers/claude/messages/thinking_message.rb
174
192
  - lib/roast/cogs/agent/providers/claude/messages/tool_result_message.rb
175
193
  - lib/roast/cogs/agent/providers/claude/messages/tool_use_message.rb
176
194
  - lib/roast/cogs/agent/providers/claude/messages/unknown_message.rb
@@ -191,15 +209,20 @@ files:
191
209
  - lib/roast/config_manager.rb
192
210
  - lib/roast/control_flow.rb
193
211
  - lib/roast/error.rb
212
+ - lib/roast/event.rb
213
+ - lib/roast/event_monitor.rb
194
214
  - lib/roast/execution_context.rb
195
215
  - lib/roast/execution_manager.rb
196
216
  - lib/roast/log.rb
217
+ - lib/roast/log_formatter.rb
197
218
  - lib/roast/nil_assertions.rb
219
+ - lib/roast/output_router.rb
198
220
  - lib/roast/system_cog.rb
199
221
  - lib/roast/system_cog/params.rb
200
222
  - lib/roast/system_cogs/call.rb
201
223
  - lib/roast/system_cogs/map.rb
202
224
  - lib/roast/system_cogs/repeat.rb
225
+ - lib/roast/task_context.rb
203
226
  - lib/roast/version.rb
204
227
  - lib/roast/workflow.rb
205
228
  - lib/roast/workflow_context.rb