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.
- checksums.yaml +4 -4
- data/Gemfile.lock +8 -8
- data/README.md +26 -14
- data/examples/demo/Gemfile.lock +2 -2
- data/examples/plugin-gem-example/Gemfile.lock +4 -4
- data/examples/simple_agent.rb +1 -1
- data/examples/targets_and_params.rb +12 -0
- data/internal/documentation/comments/doc-comments-external.md +5 -3
- data/internal/documentation/comments/doc-comments.md +2 -1
- data/lib/roast/cog_input_manager.rb +2 -44
- data/lib/roast/cogs/agent/config.rb +60 -4
- data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +5 -1
- data/lib/roast/cogs/agent/providers/pi/messages/tool_call_message.rb +218 -18
- data/lib/roast/cogs/agent/providers/pi/messages/tool_result_message.rb +208 -13
- data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +2 -0
- data/lib/roast/cogs/chat/config.rb +53 -2
- data/lib/roast/cogs/chat.rb +13 -5
- data/lib/roast/config_manager.rb +29 -2
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow.rb +10 -7
- data/lib/roast/workflow_param_accessors.rb +53 -0
- data/sorbet/rbi/shims/lib/roast/cog/config.rbi +251 -0
- data/sorbet/rbi/shims/lib/roast/config_context.rbi +5 -1
- data/tutorial/01_your_first_workflow/README.md +3 -2
- data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
- data/tutorial/02_chaining_cogs/README.md +23 -2
- data/tutorial/02_chaining_cogs/code_review.rb +2 -2
- data/tutorial/02_chaining_cogs/session_resumption.rb +1 -0
- data/tutorial/03_targets_and_params/README.md +10 -1
- data/tutorial/04_configuration_options/README.md +19 -0
- data/tutorial/README.md +1 -1
- 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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
38
|
-
name = tool_name ||
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
data/lib/roast/cogs/chat.rb
CHANGED
|
@@ -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: #{
|
|
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 #{
|
|
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
|