roast-ai 1.2.0 → 1.4.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/CONTRIBUTING.md +0 -2
- 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 +103 -4
- data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +5 -1
- data/lib/roast/cogs/agent/providers/claude/message.rb +4 -2
- 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/agent/stats.rb +15 -0
- data/lib/roast/cogs/agent/usage.rb +7 -0
- data/lib/roast/cogs/agent.rb +57 -4
- data/lib/roast/cogs/chat/config.rb +53 -2
- data/lib/roast/cogs/chat.rb +13 -5
- data/lib/roast/cogs/cmd.rb +35 -0
- data/lib/roast/command_runner.rb +3 -1
- 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 +13 -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?
|
|
@@ -100,6 +100,21 @@ module Roast
|
|
|
100
100
|
lines.join("\n")
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# Get the statistics as a hash, with aggregate and per-model usage as nested hashes
|
|
104
|
+
#
|
|
105
|
+
# #### See Also
|
|
106
|
+
# - `Agent::Usage#to_h`
|
|
107
|
+
#
|
|
108
|
+
#: () -> Hash[Symbol, untyped]
|
|
109
|
+
def to_h
|
|
110
|
+
{
|
|
111
|
+
duration_ms:,
|
|
112
|
+
num_turns:,
|
|
113
|
+
usage: usage.to_h,
|
|
114
|
+
model_usage: model_usage.transform_values(&:to_h),
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
103
118
|
private
|
|
104
119
|
|
|
105
120
|
#: (Numeric?, Numeric?) -> Numeric?
|
data/lib/roast/cogs/agent.rb
CHANGED
|
@@ -45,17 +45,70 @@ module Roast
|
|
|
45
45
|
# The agent may make multiple turns (back-and-forth exchanges) during execution,
|
|
46
46
|
# especially when using tools. Each turn is counted in the execution statistics.
|
|
47
47
|
#
|
|
48
|
+
# If the cog is configured with `record_agent_runs_to`, a record of the run is appended to that file
|
|
49
|
+
# whether the run succeeds or fails.
|
|
50
|
+
#
|
|
48
51
|
#: (Input) -> Output
|
|
49
52
|
def execute(input)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
started_at = Time.now
|
|
54
|
+
begin
|
|
55
|
+
output = provider.invoke(input)
|
|
56
|
+
if config.show_stats?
|
|
57
|
+
Event << { block: { header: "AGENT STATS", content: "#{output.stats}\nSession ID: #{output.session}" } }
|
|
58
|
+
end
|
|
59
|
+
output
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
error = e
|
|
62
|
+
raise
|
|
63
|
+
ensure
|
|
64
|
+
record_run(input, output, error, started_at) if config.valid_record_agent_runs_to_path
|
|
53
65
|
end
|
|
54
|
-
output
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
private
|
|
58
69
|
|
|
70
|
+
#: (Input, Output?, StandardError?, Time) -> void
|
|
71
|
+
def record_run(input, output, error, started_at)
|
|
72
|
+
record = {
|
|
73
|
+
started_at: started_at.utc.iso8601(3),
|
|
74
|
+
finished_at: Time.now.utc.iso8601(3),
|
|
75
|
+
cog: { name: anonymous? ? nil : name, path: record_path },
|
|
76
|
+
provider: config.valid_provider!,
|
|
77
|
+
model: config.valid_model,
|
|
78
|
+
prompts: input.prompts,
|
|
79
|
+
system_prompt: { append: config.valid_append_system_prompt, replace: config.valid_replace_system_prompt },
|
|
80
|
+
status: run_status(output, error),
|
|
81
|
+
error: error && { class: error.class.name, message: error.message },
|
|
82
|
+
response: output&.response,
|
|
83
|
+
session: output&.session,
|
|
84
|
+
stats: output&.stats&.to_h,
|
|
85
|
+
}
|
|
86
|
+
path = config.valid_record_agent_runs_to_path.not_nil!
|
|
87
|
+
path.dirname.mkpath
|
|
88
|
+
File.write(path.to_s, "#{JSON.generate(record)}\n", mode: "a")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: () -> Array[Hash[Symbol, untyped]]
|
|
92
|
+
def record_path
|
|
93
|
+
TaskContext.path.filter_map do |element|
|
|
94
|
+
cog = element.cog
|
|
95
|
+
execution_manager = element.execution_manager
|
|
96
|
+
if cog.present?
|
|
97
|
+
{ cog: cog.type, name: cog.anonymous? ? nil : cog.name }
|
|
98
|
+
elsif execution_manager&.scope
|
|
99
|
+
{ scope: execution_manager.scope, index: execution_manager.scope_index }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#: (Output?, StandardError?) -> String
|
|
105
|
+
def run_status(output, error)
|
|
106
|
+
return "timed_out" if error.is_a?(CommandRunner::TimeoutError)
|
|
107
|
+
return "failed" if error || output.nil?
|
|
108
|
+
|
|
109
|
+
"succeeded"
|
|
110
|
+
end
|
|
111
|
+
|
|
59
112
|
#: () -> Provider
|
|
60
113
|
def provider
|
|
61
114
|
@provider ||= case config.valid_provider!
|