roast-ai 1.2.0 → 1.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +8 -8
  3. data/README.md +26 -14
  4. data/examples/demo/Gemfile.lock +2 -2
  5. data/examples/plugin-gem-example/Gemfile.lock +4 -4
  6. data/examples/simple_agent.rb +1 -1
  7. data/examples/targets_and_params.rb +12 -0
  8. data/internal/documentation/comments/doc-comments-external.md +5 -3
  9. data/internal/documentation/comments/doc-comments.md +2 -1
  10. data/lib/roast/cog_input_manager.rb +2 -44
  11. data/lib/roast/cogs/agent/config.rb +60 -4
  12. data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +5 -1
  13. data/lib/roast/cogs/agent/providers/pi/messages/tool_call_message.rb +218 -18
  14. data/lib/roast/cogs/agent/providers/pi/messages/tool_result_message.rb +208 -13
  15. data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +2 -0
  16. data/lib/roast/cogs/chat/config.rb +53 -2
  17. data/lib/roast/cogs/chat.rb +13 -5
  18. data/lib/roast/config_manager.rb +29 -2
  19. data/lib/roast/version.rb +1 -1
  20. data/lib/roast/workflow.rb +10 -7
  21. data/lib/roast/workflow_param_accessors.rb +53 -0
  22. data/sorbet/rbi/shims/lib/roast/cog/config.rbi +251 -0
  23. data/sorbet/rbi/shims/lib/roast/config_context.rbi +5 -1
  24. data/tutorial/01_your_first_workflow/README.md +3 -2
  25. data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
  26. data/tutorial/02_chaining_cogs/README.md +23 -2
  27. data/tutorial/02_chaining_cogs/code_review.rb +2 -2
  28. data/tutorial/02_chaining_cogs/session_resumption.rb +1 -0
  29. data/tutorial/03_targets_and_params/README.md +10 -1
  30. data/tutorial/04_configuration_options/README.md +19 -0
  31. data/tutorial/README.md +1 -1
  32. metadata +4 -2
@@ -25,31 +25,231 @@ module Roast
25
25
  def initialize(id:, name:, arguments:)
26
26
  @id = id
27
27
  @name = name
28
- @arguments = arguments
28
+ @arguments = arguments.is_a?(Hash) ? arguments : {}
29
29
  end
30
30
 
31
31
  #: () -> String?
32
32
  def format
33
33
  return unless name
34
34
 
35
- case name.to_s.downcase
36
- when "bash"
37
- "BASH #{arguments[:command]}"
38
- when "read"
39
- "READ #{arguments[:path]}"
40
- when "edit"
41
- "EDIT #{arguments[:path]}"
42
- when "write"
43
- "WRITE #{arguments[:path]}"
44
- when "grep"
45
- "GREP #{arguments[:pattern]} #{arguments[:path]}"
46
- when "find"
47
- "FIND #{arguments[:pattern]} #{arguments[:path]}"
48
- when "ls"
49
- "LS #{arguments[:path]}"
50
- else
51
- "TOOL [#{name}] #{arguments.inspect}"
35
+ format_method_name = "format_#{name.to_s.downcase}".to_sym
36
+ return send(format_method_name) if respond_to?(format_method_name, true)
37
+
38
+ format_unknown
39
+ rescue StandardError
40
+ format_unknown
41
+ end
42
+
43
+ # Truncates each value in a formatted tool-call string to keep terminal output concise.
44
+ TRUNCATE_LIMIT = 45
45
+
46
+ private
47
+
48
+ # Formats a bash tool call.
49
+ #
50
+ # Input fields:
51
+ # :command (String) – shell command to execute [required]
52
+ #
53
+ # Output: "BASH <command>", with :command truncated to TRUNCATE_LIMIT
54
+ # chars. A missing command renders the bare "BASH".
55
+ #
56
+ # Examples:
57
+ # BASH ls -la
58
+ # BASH
59
+ #
60
+ #: () -> String
61
+ def format_bash
62
+ command = truncate(arguments[:command])
63
+ command.empty? ? "BASH" : "BASH #{command}"
64
+ end
65
+
66
+ # Formats a read tool call.
67
+ #
68
+ # Input fields:
69
+ # :path (String) – file to read [required]
70
+ # :offset (Integer) – 1-indexed first line [optional]
71
+ # :limit (Integer) – maximum number of lines [optional]
72
+ #
73
+ # Output: "READ <path>", with a line range appended when :offset and/or
74
+ # :limit is given:
75
+ # :limit >= 1 → " (lines <start>–<end>)", start = :offset (default 1),
76
+ # end = start + :limit - 1
77
+ # :offset only → " (from line <offset>)" (reads to end of file); also the
78
+ # fallback when :limit <= 0, which has no sensible range
79
+ # With neither, the bare "READ <path>". A missing path renders "READ".
80
+ #
81
+ # Examples:
82
+ # READ lib/roast.rb (lines 30–80)
83
+ # READ lib/roast.rb (from line 30)
84
+ # READ lib/roast.rb
85
+ #
86
+ #: () -> String
87
+ def format_read
88
+ path, offset, limit = arguments.values_at(:path, :offset, :limit)
89
+ path = path.to_s
90
+ details = if limit&.positive?
91
+ offset ||= 1
92
+ "lines #{offset}–#{offset + limit - 1}"
93
+ elsif offset
94
+ "from line #{offset}"
52
95
  end
96
+ label = path.empty? ? "READ" : "READ #{path}"
97
+ details ? "#{label} (#{details})" : label
98
+ end
99
+
100
+ # Formats a write tool call.
101
+ #
102
+ # Input fields:
103
+ # :path (String) – file to write [required]
104
+ # :content (String) – file contents [required]
105
+ #
106
+ # Output: 'WRITE <path> "<preview>" (+<n> <line|lines>)' – <preview> is the
107
+ # first line of :content (stripped, truncated to TRUNCATE_LIMIT chars) and
108
+ # <n> the number of lines written. The content summary is appended only
109
+ # when :content is present; a missing path renders the bare "WRITE".
110
+ #
111
+ # Examples:
112
+ # WRITE lib/roast.rb "class Roast" (+10 lines)
113
+ # WRITE config/app.yml "enabled: true" (+1 line)
114
+ # WRITE lib/roast.rb
115
+ # WRITE
116
+ #
117
+ #: () -> String
118
+ def format_write
119
+ path, content = arguments.values_at(:path, :content)
120
+ path = path.to_s
121
+ label = path.empty? ? "WRITE" : "WRITE #{path}"
122
+ return label if content.nil?
123
+
124
+ lines = content.to_s.lines
125
+ preview = truncate(lines.first.to_s.strip)
126
+ count = lines.length
127
+ "#{label} \"#{preview}\" (+#{count} #{"line".pluralize(count)})"
128
+ end
129
+
130
+ # Formats an edit tool call.
131
+ #
132
+ # Input fields:
133
+ # :path (String) – file to edit [required]
134
+ # :edits (Array) – edit blocks, each {oldText, newText} [required]
135
+ #
136
+ # Output: "EDIT <path> (<n> <edit|edits>)" – <n> is the number of edit
137
+ # blocks applied in the call. The count is always shown.
138
+ #
139
+ # Examples:
140
+ # EDIT lib/roast.rb (1 edit)
141
+ # EDIT config/app.yml (3 edits)
142
+ #
143
+ #: () -> String
144
+ def format_edit
145
+ path = arguments[:path]
146
+ edits = arguments[:edits] || []
147
+ count = edits.length
148
+ "EDIT #{path} (#{count} #{"edit".pluralize(count)})"
149
+ end
150
+
151
+ # Formats a grep tool call.
152
+ #
153
+ # Input fields:
154
+ # :pattern (String) – regex/text to search for [required]
155
+ # :path (String) – file or directory to search [optional]
156
+ # :glob (String) – glob filter for matched files [optional]
157
+ # :limit (Integer) – max number of matches [optional]
158
+ #
159
+ # Output: 'GREP "<pattern>" <path> (glob: <glob>, limit: <limit>)' –
160
+ # <pattern> is truncated to TRUNCATE_LIMIT chars; <path> is shown only
161
+ # when present. The "glob:" and "limit:" details are each included only
162
+ # when present and joined with ", " inside a single trailing "(...)".
163
+ # `path`, `glob` and `limit` are left untruncated as they are usually short and
164
+ # contain important information that we don't want to truncate.
165
+ #
166
+ # Examples:
167
+ # GREP "def format" lib (glob: *.rb, limit: 50)
168
+ # GREP "TODO" (limit: 50)
169
+ # GREP "TODO"
170
+ #
171
+ #: () -> String
172
+ def format_grep
173
+ pattern, path, glob, limit = arguments.values_at(:pattern, :path, :glob, :limit)
174
+ base = "GREP \"#{truncate(pattern)}\""
175
+ base = "#{base} #{path}" if path.present?
176
+ details = [
177
+ ("glob: #{glob}" if glob.present?),
178
+ ("limit: #{limit}" if limit.present?),
179
+ ].compact
180
+ details.any? ? "#{base} (#{details.join(", ")})" : base
181
+ end
182
+
183
+ # Formats a find tool call.
184
+ #
185
+ # Input fields:
186
+ # :pattern (String) – filename glob to match [required]
187
+ # :path (String) – directory to search in [optional]
188
+ # :limit (Integer) – max number of results [optional]
189
+ #
190
+ # Output: "FIND <pattern> (path: <path>, limit: <limit>)" – the "path:"
191
+ # and "limit:" details are each included only when present and joined
192
+ # with ", " inside a single trailing "(...)".
193
+ #
194
+ # Examples:
195
+ # FIND *.rb (path: lib, limit: 50)
196
+ # FIND *.rb (limit: 50)
197
+ # FIND *.rb
198
+ #
199
+ #: () -> String
200
+ def format_find
201
+ pattern, path, limit = arguments.values_at(:pattern, :path, :limit)
202
+ details = [
203
+ ("path: #{path}" if path.present?),
204
+ ("limit: #{limit}" if limit.present?),
205
+ ].compact
206
+ details.any? ? "FIND #{pattern} (#{details.join(", ")})" : "FIND #{pattern}"
207
+ end
208
+
209
+ # Formats an ls tool call.
210
+ #
211
+ # Input fields:
212
+ # :path (String) – directory to list [optional]
213
+ #
214
+ # Output: "LS <path>" – a missing path renders the bare "LS".
215
+ #
216
+ # Examples:
217
+ # LS lib/roast
218
+ # LS
219
+ #
220
+ #: () -> String
221
+ def format_ls
222
+ path = arguments[:path]
223
+ path.to_s.empty? ? "LS" : "LS #{path}"
224
+ end
225
+
226
+ # Formats a tool call for which Roast has no dedicated formatter.
227
+ #
228
+ # Output: "<NAME> <key>: <value>, ..." – the upcased tool name, then each
229
+ # argument as "<key>: <inspected value>", ordered shortest pair first so the
230
+ # most compact arguments stay visible, joined with ", ". Every value is
231
+ # truncated to TRUNCATE_LIMIT chars so one large argument can't flood the
232
+ # line; keys are always shown. No arguments renders the bare "<NAME>".
233
+ #
234
+ # Examples:
235
+ # WEB_SEARCH max_results: 5, query: "ruby pluralize"
236
+ # DEPLOY
237
+ #
238
+ #: () -> String
239
+ def format_unknown
240
+ label = name.to_s.upcase
241
+ return label if arguments.empty?
242
+
243
+ details = arguments.map { |key, value| "#{key}: #{truncate(value.inspect)}" }.sort_by { |s| [s.length, s] }.join(", ")
244
+ "#{label} #{details}"
245
+ end
246
+
247
+ # Truncates to TRUNCATE_LIMIT chars, appending "..." when cut. nil -> "".
248
+ #
249
+ #: (String?) -> String
250
+ def truncate(str)
251
+ s = str.to_s
252
+ s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
53
253
  end
54
254
  end
55
255
  end
@@ -30,23 +30,218 @@ module Roast
30
30
  @tool_name = tool_name
31
31
  @content = content
32
32
  @is_error = is_error
33
+ @name = (tool_name || "unknown").to_s #: String
34
+ @input = {} #: Hash[Symbol, untyped]
33
35
  end
34
36
 
35
37
  #: (PiInvocation::Context) -> String?
36
38
  def format(context)
37
- tool_call = context.tool_call(tool_call_id)
38
- name = tool_name || tool_call&.name || "unknown"
39
- status = is_error ? "ERROR" : "OK"
40
-
41
- # Truncate long tool results for progress display
42
- c = content
43
- display_content = if c && c.length > 200
44
- "#{c[0..197]}..."
45
- else
46
- c
47
- end
48
-
49
- "#{name.upcase} #{status}#{display_content ? " #{display_content}" : ""}"
39
+ call = context.tool_call(tool_call_id)
40
+ @name = (tool_name || call&.name || "unknown").to_s
41
+ @input = call&.arguments || {}
42
+
43
+ return error_line if is_error
44
+
45
+ format_method_name = "format_#{@name.downcase}".to_sym
46
+ return send(format_method_name) if respond_to?(format_method_name, true)
47
+
48
+ format_unknown
49
+ rescue StandardError
50
+ format_unknown
51
+ end
52
+
53
+ # Truncates each element in a formatted tool-result string to keep terminal output concise.
54
+ TRUNCATE_LIMIT = 45
55
+
56
+ private
57
+
58
+ # Formats a bash tool result.
59
+ #
60
+ # Content: the command's output text.
61
+ #
62
+ # Output: "BASH OK <n> <line|lines> · <preview>" – <n> is the line count
63
+ # (pluralized) and <preview> is the first line, stripped and truncated to
64
+ # TRUNCATE_LIMIT chars. The preview is omitted when there is no output.
65
+ #
66
+ # Examples:
67
+ # BASH OK 12 lines · Cloning into 'roast'...
68
+ # BASH OK 1 line · hello world
69
+ # BASH OK 0 lines
70
+ #
71
+ #: () -> String
72
+ def format_bash
73
+ lines = content.to_s.lines
74
+ count = lines.length
75
+ preview = truncate(lines.first.to_s.strip)
76
+ ok_line("#{count} #{"line".pluralize(count)}", preview)
77
+ end
78
+
79
+ # Formats a read tool result.
80
+ #
81
+ # Content: the file's text.
82
+ #
83
+ # Output: "READ OK <n> <line|lines>" – <n> is the file's line count
84
+ # (pluralized). Blank lines are counted: this is the file's own length, so
85
+ # it deliberately differs from entry-listing tools (find/ls), which drop
86
+ # blanks because a blank isn't an entry.
87
+ #
88
+ # Examples:
89
+ # READ OK 42 lines
90
+ # READ OK 1 line
91
+ # READ OK 0 lines
92
+ #
93
+ #: () -> String
94
+ def format_read
95
+ count = content.to_s.lines.length
96
+ ok_line("#{count} #{"line".pluralize(count)}")
97
+ end
98
+
99
+ # Formats a write tool result.
100
+ #
101
+ # Input: :path – the path that was written, from the originating call.
102
+ #
103
+ # Output: "WRITE OK <path>" – the file path, omitted when the call
104
+ # had none.
105
+ #
106
+ # Examples:
107
+ # WRITE OK lib/roast/version.rb
108
+ # WRITE OK
109
+ #
110
+ #: () -> String
111
+ def format_write
112
+ ok_line(@input[:path])
113
+ end
114
+
115
+ # Formats an edit tool result.
116
+ #
117
+ # Input: :path – the path that was edited, from the originating call.
118
+ #
119
+ # Output: "EDIT OK <path>" – the file path, omitted when the call
120
+ # had none.
121
+ #
122
+ # Examples:
123
+ # EDIT OK lib/roast/version.rb
124
+ # EDIT OK
125
+ #
126
+ #: () -> String
127
+ def format_edit
128
+ ok_line(@input[:path])
129
+ end
130
+
131
+ # Formats a grep tool result.
132
+ #
133
+ # Content: matching lines, and possibly informational notes, one per
134
+ # line.
135
+ #
136
+ # Output: "GREP OK <n> <match|matches>[ · NOTE <notes>]" – <n> counts
137
+ # lines that look like matches (a leading path or line-number prefix);
138
+ # any remaining lines are joined into a truncated NOTE, shown only when
139
+ # there are both matches and notes.
140
+ #
141
+ # Examples:
142
+ # GREP OK 3 matches
143
+ # GREP OK 1 match
144
+ # GREP OK 0 matches
145
+ #
146
+ #: () -> String
147
+ def format_grep
148
+ lines = content.to_s.lines.map(&:strip).reject(&:empty?)
149
+ matches, notes = lines.partition { |line| line.match?(%r{\A\S+/}) || line.match?(/\A(?:\S+:)?\d+:/) }
150
+ count = matches.length
151
+ note = "NOTE #{truncate(notes.join(" "))}" if matches.any? && notes.any?
152
+ ok_line("#{count} #{"match".pluralize(count)}", note)
153
+ end
154
+
155
+ # Formats a find tool result.
156
+ #
157
+ # Content: matching paths, one per line, plus an optional status line –
158
+ # either a bracketed notice ("[2 results limit reached. ...]") or the
159
+ # no-results prose ("No files found matching pattern").
160
+ #
161
+ # Output: "FIND OK <n> <path|paths>[ · NOTE <status>]" – <n> counts the
162
+ # path lines only, and the notice's brackets are dropped. As in #format_grep,
163
+ # the NOTE is shown only alongside results: "0 paths" already says what the
164
+ # no-results prose would.
165
+ #
166
+ # Examples:
167
+ # FIND OK 12 paths
168
+ # FIND OK 1 path
169
+ # FIND OK 2 paths · NOTE 2 results limit reached. Use limit=4 for m...
170
+ # FIND OK 0 paths
171
+ #
172
+ #: () -> String
173
+ def format_find
174
+ lines = content.to_s.lines.map(&:strip).reject(&:empty?)
175
+ notes, paths = lines.partition { |line| line.sub!(/\A\[(.*)\]\z/, '\1') || line.match?(/\ANo files found/) }
176
+ count = paths.length
177
+ note = "NOTE #{truncate(notes.join(" "))}" if paths.any? && notes.any?
178
+ ok_line("#{count} #{"path".pluralize(count)}", note)
179
+ end
180
+
181
+ # Formats an ls tool result.
182
+ #
183
+ # Content: directory entries, one per line.
184
+ #
185
+ # Output: "LS OK <n> <entry|entries>" – <n> is the number of non-blank
186
+ # lines (pluralized).
187
+ #
188
+ # Examples:
189
+ # LS OK 8 entries
190
+ # LS OK 1 entry
191
+ # LS OK 0 entries
192
+ #
193
+ #: () -> String
194
+ def format_ls
195
+ count = content.to_s.lines.map(&:strip).reject(&:empty?).length
196
+ ok_line("#{count} #{"entry".pluralize(count)}")
197
+ end
198
+
199
+ # Formats a result for which Roast has no dedicated formatter.
200
+ #
201
+ # Content: the tool's output text.
202
+ #
203
+ # Output: "<NAME> OK <preview>" – the first line of content, stripped and
204
+ # truncated to TRUNCATE_LIMIT chars. The preview is omitted when there is
205
+ # no content.
206
+ #
207
+ # Examples:
208
+ # WEB_SEARCH OK 3 results for "ruby pluralize"
209
+ # DEPLOY OK
210
+ #
211
+ #: () -> String
212
+ def format_unknown
213
+ preview = truncate(content.to_s.lines.first.to_s.strip)
214
+ ok_line(preview)
215
+ end
216
+
217
+ # Renders "<TOOL> OK[ <part> · <part> · ...]"; the success-side twin of
218
+ # #error_line. Blank/nil parts are dropped and the rest joined with " · ",
219
+ # so callers pass each piece of the summary without minding separators.
220
+ #
221
+ #: (*String?) -> String
222
+ def ok_line(*parts)
223
+ summary = parts.select(&:present?).join(" · ")
224
+ prefix = "#{@name.upcase} OK"
225
+ summary.present? ? "#{prefix} #{summary}" : prefix
226
+ end
227
+
228
+ # Renders "<TOOL> ERROR <message>". The content is shown as-is and intentionally not truncated,
229
+ # preserving the full diagnostic.
230
+ #
231
+ # Examples:
232
+ # READ ERROR ENOENT: no such file or directory
233
+ #
234
+ #: () -> String
235
+ def error_line
236
+ "#{@name.upcase} ERROR #{content.to_s.strip}".strip
237
+ end
238
+
239
+ # Truncates to TRUNCATE_LIMIT chars, appending "..." when cut. nil -> "".
240
+ #
241
+ #: (String?) -> String
242
+ def truncate(str)
243
+ s = str.to_s
244
+ s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
50
245
  end
51
246
  end
52
247
  end
@@ -60,6 +60,7 @@ module Roast
60
60
  @append_system_prompt = config.valid_append_system_prompt #: String?
61
61
  @replace_system_prompt = config.valid_replace_system_prompt #: String?
62
62
  @working_directory = config.valid_working_directory #: Pathname?
63
+ @timeout = config.valid_timeout #: Integer?
63
64
  @prompt = prompt #: String
64
65
  @session = session #: String?
65
66
  @context = Context.new #: Context
@@ -86,6 +87,7 @@ module Roast
86
87
  command_line,
87
88
  working_directory: @working_directory,
88
89
  stdin_content: @prompt,
90
+ timeout: @timeout,
89
91
  stdout_handler: lambda { |line| handle_stdout(line) },
90
92
  )
91
93
  @end_time_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i #: Integer?
@@ -5,6 +5,14 @@ module Roast
5
5
  module Cogs
6
6
  class Chat < Cog
7
7
  class Config < Cog::Config
8
+ # Environment variable that overrides the built-in default chat provider.
9
+ #
10
+ # When a chat cog does not explicitly configure a provider, Roast uses the provider named by
11
+ # this variable, falling back to the built-in default (`PROVIDERS.keys.first`, i.e. `:openai`) when it
12
+ # is unset or blank. The value is normalized (surrounding whitespace stripped, then downcased)
13
+ # before lookup, and an explicit `provider` configured on the cog always takes precedence over it.
14
+ DEFAULT_PROVIDER_ENV_VAR = "ROAST_DEFAULT_CHAT_PROVIDER" #: String
15
+
8
16
  PROVIDERS = {
9
17
  openai: {
10
18
  api_key_env_var: "OPENAI_API_KEY",
@@ -28,6 +36,10 @@ module Roast
28
36
  default_base_url: "https://generativelanguage.googleapis.com/v1beta",
29
37
  default_model: "gemini-3.1-flash-lite",
30
38
  },
39
+ bedrock: {
40
+ api_key_env_var: "AWS_ACCESS_KEY_ID",
41
+ default_model: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
42
+ },
31
43
  }.freeze #: Hash[Symbol, Hash[Symbol, String]]
32
44
 
33
45
  # Configure the cog to use a specified API provider when invoking the llm
@@ -42,7 +54,8 @@ module Roast
42
54
 
43
55
  # Configure the cog to use the default provider when invoking the llm
44
56
  #
45
- # The default LLM provider used by Roast is OpenAI (`:openai`).
57
+ # The default LLM provider is the one named by the `ROAST_DEFAULT_CHAT_PROVIDER` environment
58
+ # variable, or OpenAI (`:openai`) when that variable is unset.
46
59
  #
47
60
  # #### See Also
48
61
  # - `provider`
@@ -54,6 +67,10 @@ module Roast
54
67
 
55
68
  # Get the validated provider name that the cog is configured to use when invoking the llm
56
69
  #
70
+ # The provider is resolved in order of precedence: the provider explicitly configured on the cog,
71
+ # then the `ROAST_DEFAULT_CHAT_PROVIDER` environment variable (normalized by stripping surrounding
72
+ # whitespace and downcasing), then the built-in default (`PROVIDERS.keys.first`, i.e. `:openai`).
73
+ #
57
74
  # Note: this method will return the name of a valid provider or raise an `InvalidConfigError`.
58
75
  # It will __not__, however, validate that the you have access to the provider's API.
59
76
  # If you have not correctly configured API access, you will likely experience a failure when Roast attempts to
@@ -65,7 +82,8 @@ module Roast
65
82
  #
66
83
  #: () -> Symbol
67
84
  def valid_provider!
68
- provider = @values[:provider] || PROVIDERS.keys.first
85
+ env_default = ENV[DEFAULT_PROVIDER_ENV_VAR].presence&.strip&.downcase&.to_sym
86
+ provider = @values[:provider] || env_default || PROVIDERS.keys.first
69
87
  unless PROVIDERS.include?(provider)
70
88
  raise InvalidConfigError, "'#{provider}' is not a valid provider. Available providers include: #{PROVIDERS.keys.join(", ")}"
71
89
  end
@@ -94,6 +112,7 @@ module Roast
94
112
  # - Anthropic Provider: ANTHROPIC_API_KEY
95
113
  # - Perplexity Provider: PERPLEXITY_API_KEY
96
114
  # - Gemini Provider: GEMINI_API_KEY
115
+ # - Bedrock Provider: AWS_ACCESS_KEY_ID
97
116
  #
98
117
  # #### See Also
99
118
  # - `api_key`
@@ -113,6 +132,7 @@ module Roast
113
132
  # - Anthropic Provider: ANTHROPIC_API_KEY
114
133
  # - Perplexity Provider: PERPLEXITY_API_KEY
115
134
  # - Gemini Provider: GEMINI_API_KEY
135
+ # - Bedrock Provider: AWS_ACCESS_KEY_ID
116
136
  #
117
137
  # #### See Also
118
138
  # - `api_key`
@@ -176,6 +196,37 @@ module Roast
176
196
  PROVIDERS.dig(valid_provider!, :default_base_url)
177
197
  end
178
198
 
199
+ # Get the AWS secret access key used to sign requests when the Bedrock provider is selected
200
+ #
201
+ # Only the Bedrock provider uses this value. It is read from the `AWS_SECRET_ACCESS_KEY` environment variable.
202
+ # This method will raise InvalidConfigError if that variable is unset or blank.
203
+ #
204
+ #: () -> String
205
+ def valid_aws_secret_access_key!
206
+ ENV["AWS_SECRET_ACCESS_KEY"].presence || raise(InvalidConfigError, "AWS_SECRET_ACCESS_KEY is not set")
207
+ end
208
+
209
+ # Get the AWS session token used to sign requests with temporary credentials, such as those from AWS SSO
210
+ #
211
+ # Only the Bedrock provider uses this value. It is read from the `AWS_SESSION_TOKEN` environment variable.
212
+ # Returns nil if that variable is unset or blank, because long-term credentials need no session token.
213
+ #
214
+ #: () -> String?
215
+ def valid_aws_session_token
216
+ ENV["AWS_SESSION_TOKEN"].presence
217
+ end
218
+
219
+ # Get the AWS region that the Bedrock provider sends requests to
220
+ #
221
+ # Only the Bedrock provider uses this value. It is read from the `AWS_REGION` environment variable only.
222
+ # `AWS_DEFAULT_REGION` is ignored, so that a region left in a shell profile never selects a region silently.
223
+ # This method will raise InvalidConfigError if `AWS_REGION` is unset or blank.
224
+ #
225
+ #: () -> String
226
+ def valid_aws_region!
227
+ ENV["AWS_REGION"].presence || raise(InvalidConfigError, "AWS_REGION is not set")
228
+ end
229
+
179
230
  # Configure the cog to use a specific model when invoking the agent
180
231
  #
181
232
  # The model name format is provider-specific.
@@ -59,16 +59,19 @@ module Roast
59
59
  Event << { block: { header: "UNKNOWN", content: message.content } } if config.show_prompt? || config.show_response?
60
60
  end
61
61
  end
62
+ # The Bedrock Converse response has no modelId field, so ruby_llm leaves response.model_id nil for it.
63
+ # The chat model holds the ID that ruby_llm sent.
64
+ model_id = response.model_id || chat.model.id
62
65
  if config.show_stats?
63
66
  temperature = chat.instance_variable_get(:@temperature)
64
- lines = ["Model: #{response.model_id}"]
67
+ lines = ["Model: #{model_id}"]
65
68
  lines << "Temperature: #{format("%0.2f", temperature)}" if temperature
66
69
  lines << "Input Tokens: #{response.input_tokens}"
67
70
  lines << "Output Tokens: #{response.output_tokens}"
68
71
  Event << { block: { header: "LLM STATS", content: lines.join("\n") } }
69
72
  end
70
73
 
71
- verify_response_not_truncated!(response)
74
+ verify_response_not_truncated!(response, model_id)
72
75
 
73
76
  Output.new(Session.from_chat(chat), response.content)
74
77
  end
@@ -95,15 +98,15 @@ module Roast
95
98
  # on a natural boundary. This is an unavoidable trade-off of the heuristic approach until
96
99
  # ruby_llm exposes finish_reason.
97
100
  #
98
- #: (RubyLLM::Message) -> void
99
- def verify_response_not_truncated!(response)
101
+ #: (RubyLLM::Message, String) -> void
102
+ def verify_response_not_truncated!(response, model_id)
100
103
  max_tokens = effective_max_tokens
101
104
  return unless max_tokens
102
105
  return unless response.output_tokens
103
106
 
104
107
  if response.output_tokens >= max_tokens
105
108
  raise MaxTokensExceededError,
106
- "LLM response from #{response.model_id} was truncated at the max token limit " \
109
+ "LLM response from #{model_id} was truncated at the max token limit " \
107
110
  "(output: #{response.output_tokens} tokens, limit: #{max_tokens} tokens). " \
108
111
  "The response content is likely incomplete and should not be used."
109
112
  end
@@ -143,6 +146,11 @@ module Roast
143
146
  when :gemini
144
147
  context.gemini_api_key = config.valid_api_key!
145
148
  context.gemini_api_base = config.valid_base_url
149
+ when :bedrock
150
+ context.bedrock_api_key = config.valid_api_key!
151
+ context.bedrock_secret_key = config.valid_aws_secret_access_key!
152
+ context.bedrock_session_token = config.valid_aws_session_token
153
+ context.bedrock_region = config.valid_aws_region!
146
154
  end
147
155
  end
148
156
  end