roast-ai 1.0.2 → 1.2.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/docs/write-comments.md +1 -1
  3. data/.rubocop.yml +12 -1
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +149 -34
  6. data/README.md +56 -3
  7. data/examples/agent_with_multiple_prompts.rb +27 -0
  8. data/examples/custom_logging.rb +4 -2
  9. data/examples/demo/Gemfile.lock +49 -15
  10. data/examples/plugin-gem-example/Gemfile.lock +19 -15
  11. data/examples/simple_chat.rb +1 -1
  12. data/examples/simple_pi_agent.rb +18 -0
  13. data/internal/rubocop/cop/roast/no_test_class_nesting.rb +126 -0
  14. data/internal/rubocop/rubocop-roast.yml +6 -0
  15. data/internal/workflows/maintenance/branch_docs_impact.rb +97 -0
  16. data/internal/workflows/maintenance/deprecated_models_docs_updater.rb +78 -0
  17. data/lib/roast/cog/config.rb +1 -1
  18. data/lib/roast/cog/output.rb +2 -1
  19. data/lib/roast/cog/registry.rb +3 -3
  20. data/lib/roast/cog_input_manager.rb +28 -7
  21. data/lib/roast/cogs/agent/config.rb +2 -2
  22. data/lib/roast/cogs/agent/input.rb +20 -22
  23. data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +13 -5
  24. data/lib/roast/cogs/agent/providers/claude/messages/result_message.rb +1 -1
  25. data/lib/roast/cogs/agent/providers/claude/tool_result.rb +344 -4
  26. data/lib/roast/cogs/agent/providers/claude/tool_use.rb +356 -1
  27. data/lib/roast/cogs/agent/providers/claude.rb +16 -3
  28. data/lib/roast/cogs/agent/providers/pi/messages/tool_call_message.rb +60 -0
  29. data/lib/roast/cogs/agent/providers/pi/messages/tool_result_message.rb +57 -0
  30. data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +352 -0
  31. data/lib/roast/cogs/agent/providers/pi.rb +41 -0
  32. data/lib/roast/cogs/agent/stats.rb +29 -0
  33. data/lib/roast/cogs/agent/usage.rb +22 -0
  34. data/lib/roast/cogs/agent.rb +5 -6
  35. data/lib/roast/cogs/chat/config.rb +28 -2
  36. data/lib/roast/cogs/chat.rb +82 -10
  37. data/lib/roast/event.rb +1 -0
  38. data/lib/roast/event_monitor.rb +35 -3
  39. data/lib/roast/log.rb +21 -0
  40. data/lib/roast/log_formatter.rb +9 -7
  41. data/lib/roast/version.rb +1 -1
  42. data/lib/roast.rb +1 -3
  43. data/roast-ai.gemspec +2 -1
  44. data/sorbet/rbi/gems/activesupport@8.0.2.rbi +549 -383
  45. data/sorbet/rbi/gems/addressable@2.8.7.rbi +46 -44
  46. data/sorbet/rbi/gems/ast@2.4.3.rbi +7 -6
  47. data/sorbet/rbi/gems/async@2.34.0.rbi +21 -3
  48. data/sorbet/rbi/gems/benchmark@0.4.1.rbi +7 -7
  49. data/sorbet/rbi/gems/bigdecimal@3.2.2.rbi +198 -1
  50. data/sorbet/rbi/gems/concurrent-ruby@1.3.5.rbi +405 -328
  51. data/sorbet/rbi/gems/console@1.34.2.rbi +2 -2
  52. data/sorbet/rbi/gems/docile@1.4.1.rbi +30 -30
  53. data/sorbet/rbi/gems/drb@2.2.3.rbi +25 -25
  54. data/sorbet/rbi/gems/erubi@1.13.1.rbi +2 -0
  55. data/sorbet/rbi/gems/faraday-net_http@3.4.2.rbi +2 -77
  56. data/sorbet/rbi/gems/faraday-retry@2.3.2.rbi +2 -57
  57. data/sorbet/rbi/gems/faraday@2.14.1.rbi +382 -75
  58. data/sorbet/rbi/gems/guard-compat@1.2.1.rbi +1 -110
  59. data/sorbet/rbi/gems/guard-minitest@2.4.6.rbi +0 -139
  60. data/sorbet/rbi/gems/guard@2.19.1.rbi +38 -38
  61. data/sorbet/rbi/gems/hashdiff@1.2.0.rbi +3 -3
  62. data/sorbet/rbi/gems/i18n@1.14.7.rbi +53 -29
  63. data/sorbet/rbi/gems/io-event@1.14.0.rbi +67 -10
  64. data/sorbet/rbi/gems/json@2.18.1.rbi +227 -5
  65. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +83 -0
  66. data/sorbet/rbi/gems/listen@3.9.0.rbi +7 -7
  67. data/sorbet/rbi/gems/logger@1.7.0.rbi +3 -3
  68. data/sorbet/rbi/gems/lumberjack@1.2.10.rbi +21 -21
  69. data/sorbet/rbi/gems/marcel@1.1.0.rbi +1 -1
  70. data/sorbet/rbi/gems/minitest-rg@5.3.0.rbi +0 -96
  71. data/sorbet/rbi/gems/minitest@5.25.5.rbi +1 -16
  72. data/sorbet/rbi/gems/net-http@0.9.1.rbi +27 -19
  73. data/sorbet/rbi/gems/netrc@0.11.0.rbi +18 -0
  74. data/sorbet/rbi/gems/notiffany@0.1.3.rbi +20 -20
  75. data/sorbet/rbi/gems/ostruct@0.6.2.rbi +149 -15
  76. data/sorbet/rbi/gems/parser@3.3.8.0.rbi +141 -139
  77. data/sorbet/rbi/gems/prism@1.4.0.rbi +922 -864
  78. data/sorbet/rbi/gems/public_suffix@6.0.2.rbi +56 -35
  79. data/sorbet/rbi/gems/racc@1.8.1.rbi +10 -2
  80. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +12 -12
  81. data/sorbet/rbi/gems/rake@13.3.0.rbi +219 -318
  82. data/sorbet/rbi/gems/{rbi@0.3.6.rbi → rbi@0.3.9.rbi} +612 -2267
  83. data/sorbet/rbi/gems/{rbs@3.9.4.rbi → rbs@4.0.0.dev.5.rbi} +2013 -680
  84. data/sorbet/rbi/gems/regexp_parser@2.10.0.rbi +151 -113
  85. data/sorbet/rbi/gems/require-hooks@0.2.3.rbi +110 -0
  86. data/sorbet/rbi/gems/rexml@3.4.2.rbi +24 -51
  87. data/sorbet/rbi/gems/rubocop-ast@1.45.1.rbi +506 -815
  88. data/sorbet/rbi/gems/rubocop-sorbet@0.10.5.rbi +16 -16
  89. data/sorbet/rbi/gems/rubocop@1.77.0.rbi +2692 -2327
  90. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +8 -8
  91. data/sorbet/rbi/gems/ruby_llm@1.8.2.rbi +38 -23
  92. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +1 -1
  93. data/sorbet/rbi/gems/simplecov-html@0.13.2.rbi +2 -131
  94. data/sorbet/rbi/gems/simplecov@0.22.0.rbi +28 -127
  95. data/sorbet/rbi/gems/{spoom@1.6.3.rbi → spoom@1.7.11.rbi} +1139 -2246
  96. data/sorbet/rbi/gems/sqlite3@2.9.0.rbi +91 -1
  97. data/sorbet/rbi/gems/{tapioca@0.16.11.rbi → tapioca@0.17.10.rbi} +721 -835
  98. data/sorbet/rbi/gems/thor@1.4.0.rbi +53 -53
  99. data/sorbet/rbi/gems/tsort@0.2.0.rbi +393 -0
  100. data/sorbet/rbi/gems/type_toolkit@0.0.5.rbi +49 -0
  101. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +144 -143
  102. data/sorbet/rbi/gems/uri@1.1.1.rbi +7 -7
  103. data/sorbet/rbi/gems/vcr@6.3.1.rbi +53 -36
  104. data/sorbet/rbi/gems/webmock@3.25.1.rbi +38 -13
  105. data/sorbet/rbi/gems/zeitwerk@2.7.3.rbi +39 -272
  106. data/sorbet/rbi/shims/lib/roast/execution_context.rbi +3 -3
  107. data/tutorial/01_your_first_workflow/README.md +9 -5
  108. data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
  109. data/tutorial/02_chaining_cogs/README.md +2 -2
  110. data/tutorial/02_chaining_cogs/code_review.rb +1 -1
  111. data/tutorial/02_chaining_cogs/session_resumption.rb +1 -1
  112. data/tutorial/03_targets_and_params/README.md +1 -1
  113. data/tutorial/04_configuration_options/README.md +2 -2
  114. data/tutorial/08_iterative_workflows/README.md +1 -1
  115. data/tutorial/README.md +1 -1
  116. metadata +39 -17
  117. data/docs/AGENT_STEPS.md +0 -288
  118. data/docs/INSTRUMENTATION.md +0 -243
  119. data/docs/ITERATION_SYNTAX.md +0 -147
  120. data/docs/VALIDATION.md +0 -178
  121. data/lib/roast/nil_assertions.rb +0 -23
  122. /data/internal/documentation/{architectural-notes.md → comments/architectural-notes.md} +0 -0
  123. /data/internal/documentation/{doc-comments-external.md → comments/doc-comments-external.md} +0 -0
  124. /data/internal/documentation/{doc-comments-internal.md → comments/doc-comments-internal.md} +0 -0
  125. /data/internal/documentation/{doc-comments.md → comments/doc-comments.md} +0 -0
@@ -27,11 +27,366 @@ module Roast
27
27
  format_unknown
28
28
  end
29
29
 
30
+ # Truncate long formatted tool-use strings to keep terminal output to generally one line, accounting for logger prefixing.
31
+ TRUNCATE_LIMIT = 45
32
+
30
33
  private
31
34
 
35
+ # Truncates a string to at most TRUNCATE_LIMIT characters, appending
36
+ # "..." when the string is cut. The returned string — including the
37
+ # ellipsis — is guaranteed to be ≤ TRUNCATE_LIMIT characters.
38
+ #
39
+ # nil is coerced to "" (so callers needn't nil-check).
40
+ # Strings at or below the limit are returned unchanged.
41
+ #
42
+ # Input:
43
+ # str (String?) – the string to truncate
44
+ #
45
+ # Output: the (possibly shortened) string
46
+ #
47
+ # Examples:
48
+ # truncate("ls -la") # => "ls -la"
49
+ # truncate("a" * 50) # => "aaa...aaa..." (45 chars)
50
+ # truncate(nil) # => ""
51
+ #
52
+ #: (String?) -> String
53
+ def truncate(str)
54
+ s = str.to_s
55
+ s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
56
+ end
57
+
58
+ # Formats a Bash tool-use line.
59
+ #
60
+ # Input fields:
61
+ # :command (String) – shell command to execute [required]
62
+ # :description (String) – human-readable label [optional]
63
+ #
64
+ # Output: "BASH <command>", with " (<description>)" appended when
65
+ # :description is present. :command is truncated to TRUNCATE_LIMIT chars.
66
+ #
67
+ # Examples:
68
+ # BASH ls -la (List directory contents)
69
+ # BASH ls -la
70
+ #
32
71
  #: () -> String
33
72
  def format_bash
34
- "BASH #{input.inspect}"
73
+ command = truncate(input[:command])
74
+ description = input[:description]
75
+ label = command.empty? ? "BASH" : "BASH #{command}"
76
+ description ? "#{label} (#{description})" : label
77
+ end
78
+
79
+ # Formats a Read tool-use line.
80
+ #
81
+ # Input fields:
82
+ # :file_path (String) – absolute path to read [required]
83
+ # :offset (Integer) – 1-based first line [optional]
84
+ # :limit (Integer) – max number of lines to read [optional]
85
+ #
86
+ # Output: "READ <file_path>", with a line range appended when :offset
87
+ # and/or :limit is given:
88
+ # :limit set → " (lines <start>–<end>)", start = :offset (default
89
+ # 1), end = start + :limit - 1
90
+ # :offset only → " (from line <offset>)" (reads to end of file)
91
+ # With neither, the bare "READ <file_path>".
92
+ #
93
+ # Examples:
94
+ # READ /app/models/user.rb (lines 30–80)
95
+ # READ /app/models/user.rb (from line 30)
96
+ # READ /app/models/user.rb
97
+ #
98
+ #: () -> String
99
+ def format_read
100
+ file_path, offset, limit = input.values_at(:file_path, :offset, :limit)
101
+ details = if limit
102
+ offset ||= 1
103
+ "lines #{offset}–#{offset + limit - 1}"
104
+ elsif offset
105
+ "from line #{offset}"
106
+ end
107
+ label = file_path.to_s.empty? ? "READ" : "READ #{file_path}"
108
+ details ? "#{label} (#{details})" : label
109
+ end
110
+
111
+ # Formats a Glob tool-use line.
112
+ #
113
+ # Input fields:
114
+ # :pattern (String) – glob pattern to match [required]
115
+ # :path (String) – directory to search in [optional]
116
+ #
117
+ # Output: "GLOB <pattern>", with " (in <path>)" appended when :path
118
+ # is present.
119
+ #
120
+ # Pattern is not truncated because glob expressions are typically short
121
+ # and meaningful in their entirety, unlike shell commands.
122
+ #
123
+ # Examples:
124
+ # GLOB **/*.rb (in lib/roast)
125
+ # GLOB **/*.rb
126
+ #
127
+ #: () -> String
128
+ def format_glob
129
+ pattern, path = input.values_at(:pattern, :path)
130
+ path ? "GLOB #{pattern} (in #{path})" : "GLOB #{pattern}"
131
+ end
132
+
133
+ # Formats a Grep tool-use line.
134
+ #
135
+ # Input fields:
136
+ # :pattern (String) – regex to search for [required]
137
+ # :path (String) – file or directory to search [optional]
138
+ # :glob (String) – filter files by glob [optional]
139
+ # :type (String) – filter files by type [optional]
140
+ # :"-i" (bool) – case-insensitive match [optional]
141
+ #
142
+ # Output: 'GREP "<pattern>"', then " <path>" when :path is present,
143
+ # then " (<modifiers>)" when any of :glob, :type, :"-i" are set —
144
+ # joined with " · " in that order. :pattern is truncated to
145
+ # TRUNCATE_LIMIT chars; other grep options (such as :"-n") are not
146
+ # displayed, as they shape grep's output rather than the search itself.
147
+ #
148
+ # Examples:
149
+ # GREP "def format" lib/roast (glob=*.rb · -i)
150
+ # GREP "TODO"
151
+ #
152
+ #: () -> String
153
+ def format_grep
154
+ pattern, path = input.values_at(:pattern, :path)
155
+ base = "GREP \"#{truncate(pattern)}\""
156
+ base = "#{base} #{path}" if path
157
+ modifiers = [
158
+ ("glob=#{input[:glob]}" if input[:glob]),
159
+ ("type=#{input[:type]}" if input[:type]),
160
+ ("-i" if input[:"-i"]),
161
+ ].compact.join(" · ")
162
+ modifiers.empty? ? base : "#{base} (#{modifiers})"
163
+ end
164
+
165
+ # Formats a Write tool-use line.
166
+ #
167
+ # Input fields:
168
+ # :file_path (String) – absolute path to write [required]
169
+ # :content (String) – full file contents [required]
170
+ #
171
+ # Output: 'WRITE <file_path> "<preview>" (+<n> lines)', where
172
+ # <preview> is the first line of :content (stripped, truncated to
173
+ # TRUNCATE_LIMIT chars) and <n> is the total number of lines written
174
+ # (singular " line" when <n> is 1). The count is always shown.
175
+ #
176
+ # Examples:
177
+ # WRITE /app/models/user.rb "class User < ApplicationRecord" (+10 lines)
178
+ # WRITE /config/app.yml "enabled: true" (+1 line)
179
+ #
180
+ #: () -> String
181
+ def format_write
182
+ file_path, content = input.values_at(:file_path, :content)
183
+ lines = content.to_s.lines
184
+ preview = truncate(lines.first.to_s.strip)
185
+ count = lines.length
186
+ line_label = count == 1 ? "line" : "lines"
187
+ "WRITE #{file_path} \"#{preview}\" (+#{count} #{line_label})"
188
+ end
189
+
190
+ # Formats an Edit tool-use line.
191
+ #
192
+ # Input fields:
193
+ # :file_path (String) – absolute path to edit [required]
194
+ # :old_string (String) – text being replaced [required]
195
+ # :new_string (String) – replacement text [required]
196
+ # :replace_all (bool) – replace every occurrence [optional]
197
+ #
198
+ # Output: "EDIT <file_path> (-<old> +<new> lines)", where <old> and
199
+ # <new> are the line counts of :old_string and :new_string. The
200
+ # count is always labeled " lines"; " · replace all" is appended
201
+ # when :replace_all is set.
202
+ #
203
+ # Examples:
204
+ # EDIT /app/models/user.rb (-6 +6 lines)
205
+ # EDIT /config/routes.rb (-1 +2 lines · replace all)
206
+ #
207
+ #: () -> String
208
+ def format_edit
209
+ file_path, old_string, new_string = input.values_at(:file_path, :old_string, :new_string)
210
+ old_count = old_string.to_s.lines.length
211
+ new_count = new_string.to_s.lines.length
212
+ details = "-#{old_count} +#{new_count} lines"
213
+ details = "#{details} · replace all" if input[:replace_all]
214
+ "EDIT #{file_path} (#{details})"
215
+ end
216
+
217
+ # Formats a TodoWrite tool-use line.
218
+ #
219
+ # Input fields:
220
+ # :todos (Array) – the full todo list [required]
221
+ #
222
+ # Output: "TODOWRITE <n> todos" (singular "todo" when n is 1). When the
223
+ # list is non-empty, " (<breakdown>)" is appended — each distinct :status
224
+ # with its count ("<count> <status>"), joined with " · " in the order the
225
+ # statuses first appear.
226
+ #
227
+ # Examples:
228
+ # TODOWRITE 3 todos (2 completed · 1 in_progress)
229
+ # TODOWRITE 1 todo (1 in_progress)
230
+ # TODOWRITE 0 todos
231
+ #
232
+ #: () -> String
233
+ def format_todowrite
234
+ todos = input[:todos] || []
235
+ counts = todos.group_by { |todo| todo[:status] }.transform_values(&:length)
236
+ summary = counts.map { |status, count| "#{count} #{status}" }.join(" · ")
237
+ label = todos.length == 1 ? "todo" : "todos"
238
+ base = "TODOWRITE #{todos.length} #{label}"
239
+ summary.empty? ? base : "#{base} (#{summary})"
240
+ end
241
+
242
+ # Formats a Skill tool-use line.
243
+ #
244
+ # Input fields:
245
+ # :skill (String) – name of the skill to invoke [required]
246
+ # :args (String) – arguments passed to the skill [optional]
247
+ #
248
+ # Output: "SKILL <skill>", with " (<args>)" appended when :args is
249
+ # present. :args is truncated to TRUNCATE_LIMIT chars; :skill is not.
250
+ #
251
+ # Examples:
252
+ # SKILL pr-description (draft for the auth changes)
253
+ # SKILL pr-description
254
+ #
255
+ #: () -> String
256
+ def format_skill
257
+ skill, args = input.values_at(:skill, :args)
258
+ args ? "SKILL #{skill} (#{truncate(args)})" : "SKILL #{skill}"
259
+ end
260
+
261
+ # Formats a Task tool-use line.
262
+ #
263
+ # Input fields:
264
+ # :description (String) – short label for the task [required]
265
+ # :subagent_type (String) – which agent type to spawn [optional]
266
+ # :run_in_background (bool) – run the agent asynchronously [optional]
267
+ # :model (String) – model override for the agent [optional]
268
+ #
269
+ # Output: "TASK <description>", with " (<details>)" appended when any
270
+ # optional field is set: :subagent_type, then "background" (when
271
+ # :run_in_background is set), then :model — joined with " · " in that
272
+ # order and shown as raw values (no key= labels). :description is
273
+ # truncated to TRUNCATE_LIMIT chars; the other fields are not.
274
+ #
275
+ # Examples:
276
+ # TASK Find all callers (Explore · background · opus)
277
+ # TASK Summarize the diff
278
+ #
279
+ #: () -> String
280
+ def format_task
281
+ description = truncate(input[:description])
282
+ details = [
283
+ input[:subagent_type],
284
+ ("background" if input[:run_in_background]),
285
+ input[:model],
286
+ ].compact.join(" · ")
287
+ details.empty? ? "TASK #{description}" : "TASK #{description} (#{details})"
288
+ end
289
+
290
+ # Formats an Agent tool-use line.
291
+ #
292
+ # Input fields:
293
+ # :description (String) – short label for the work [required]
294
+ # :subagent_type (String) – which agent type to spawn [optional]
295
+ # :run_in_background (bool) – run the agent asynchronously [optional]
296
+ #
297
+ # Output: "AGENT <description>", with " (<details>)" appended when any
298
+ # optional field is set: :subagent_type, then "background" (when
299
+ # :run_in_background is set) — joined with " · " in that order and shown
300
+ # as raw values (no key= labels). :description is truncated to
301
+ # TRUNCATE_LIMIT chars; :subagent_type is not.
302
+ #
303
+ # Examples:
304
+ # AGENT Find all callers (Explore · background)
305
+ # AGENT Summarize the diff
306
+ #
307
+ #: () -> String
308
+ def format_agent
309
+ description = truncate(input[:description])
310
+ details = [
311
+ input[:subagent_type],
312
+ ("background" if input[:run_in_background]),
313
+ ].compact.join(" · ")
314
+ details.empty? ? "AGENT #{description}" : "AGENT #{description} (#{details})"
315
+ end
316
+
317
+ # Formats a TaskUpdate tool-use line.
318
+ #
319
+ # Input fields:
320
+ # :taskId (Integer) – id of the task to update [required]
321
+ # :status (String) – the task's new status [required]
322
+ #
323
+ # Output: "TASKUPDATE #<taskId> → <status>" — the id is prefixed with
324
+ # "#" and joined to the status with " → ". Both fields are always
325
+ # shown and neither is truncated.
326
+ #
327
+ # Examples:
328
+ # TASKUPDATE #1 → completed
329
+ # TASKUPDATE #2 → in_progress
330
+ #
331
+ #: () -> String
332
+ def format_taskupdate
333
+ "TASKUPDATE ##{input[:taskId]} → #{input[:status]}"
334
+ end
335
+
336
+ # Formats a TaskCreate tool-use line.
337
+ #
338
+ # Input fields:
339
+ # :subject (String) – title of the task to create [required]
340
+ #
341
+ # Output: "TASKCREATE <subject>", with :subject truncated to
342
+ # TRUNCATE_LIMIT chars.
343
+ #
344
+ # Examples:
345
+ # TASKCREATE Run formatter stress test
346
+ # TASKCREATE Review multiline edit output in tmp/formatter_st...
347
+ #
348
+ #: () -> String
349
+ def format_taskcreate
350
+ "TASKCREATE #{truncate(input[:subject])}"
351
+ end
352
+
353
+ # Formats a TaskOutput tool-use line.
354
+ #
355
+ # Input fields:
356
+ # :task_id (String) – id of the task to read [required]
357
+ # :block (bool) – wait for the task to complete [optional]
358
+ # :timeout (Integer) – max wait in milliseconds [optional]
359
+ #
360
+ # Output: "TASKOUTPUT #<task_id>", with " (<details>)" appended for any
361
+ # set options. :block renders its mode only when explicitly set — "sync"
362
+ # (true) or "async" (false) — and nothing when omitted. :timeout follows
363
+ # as "<n>s timeout" (whole seconds drop the decimal). Modifiers join with
364
+ # " · " in that order. :task_id is truncated to TRUNCATE_LIMIT chars.
365
+ #
366
+ # Examples:
367
+ # TASKOUTPUT #abc123 (sync · 120s timeout)
368
+ # TASKOUTPUT #abc123 (async · 5s timeout)
369
+ # TASKOUTPUT #abc123
370
+ #
371
+ #: () -> String
372
+ def format_taskoutput
373
+ task_id = truncate(input[:task_id])
374
+ timeout_ms = input[:timeout]
375
+ timeout_str = if timeout_ms
376
+ seconds = ActiveSupport::NumberHelper.number_to_rounded(
377
+ timeout_ms / 1000.0,
378
+ delimiter: "",
379
+ precision: 3,
380
+ strip_insignificant_zeros: true,
381
+ )
382
+ "#{seconds}s timeout"
383
+ end
384
+ block_label = case input[:block]
385
+ when true then "sync"
386
+ when false then "async"
387
+ end
388
+ details = [block_label, timeout_str].compact.join(" · ")
389
+ details.empty? ? "TASKOUTPUT ##{task_id}" : "TASKOUTPUT ##{task_id} (#{details})"
35
390
  end
36
391
 
37
392
  #: () -> String
@@ -18,9 +18,22 @@ module Roast
18
18
 
19
19
  #: (Agent::Input) -> Agent::Output
20
20
  def invoke(input)
21
- invocation = ClaudeInvocation.new(@config, input)
22
- invocation.run!
23
- Output.new(invocation.result)
21
+ invocations = [] #: Array[ClaudeInvocation]
22
+ input.prompts.each do |prompt|
23
+ previous_session = invocations.last&.result&.session
24
+ invocation = ClaudeInvocation.new(
25
+ @config,
26
+ prompt,
27
+ previous_session || input.session,
28
+ fork_session: previous_session.nil?,
29
+ )
30
+ invocation.run!
31
+ invocations << invocation
32
+ break unless invocation.result.success
33
+ end
34
+ final_result = invocations.last.not_nil!.result
35
+ final_result.stats = invocations.filter_map { |i| i.result.stats }.reduce(:+) if invocations.size > 1
36
+ Output.new(final_result)
24
37
  end
25
38
  end
26
39
  end
@@ -0,0 +1,60 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module Cogs
6
+ class Agent < Cog
7
+ module Providers
8
+ class Pi < Provider
9
+ module Messages
10
+ # Represents a tool call made by the Pi agent
11
+ #
12
+ # In Pi's JSON protocol, tool calls appear as `toolcall_end` events within
13
+ # `message_update` messages, containing the tool name, id, and arguments.
14
+ class ToolCallMessage
15
+ #: String?
16
+ attr_reader :id
17
+
18
+ #: String?
19
+ attr_reader :name
20
+
21
+ #: Hash[Symbol, untyped]
22
+ attr_reader :arguments
23
+
24
+ #: (id: String?, name: String?, arguments: Hash[Symbol, untyped]) -> void
25
+ def initialize(id:, name:, arguments:)
26
+ @id = id
27
+ @name = name
28
+ @arguments = arguments
29
+ end
30
+
31
+ #: () -> String?
32
+ def format
33
+ return unless name
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}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,57 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module Cogs
6
+ class Agent < Cog
7
+ module Providers
8
+ class Pi < Provider
9
+ module Messages
10
+ # Represents a tool execution result from the Pi agent
11
+ #
12
+ # In Pi's JSON protocol, tool results appear as `tool_execution_end` events
13
+ # containing the result content and error status.
14
+ class ToolResultMessage
15
+ #: String?
16
+ attr_reader :tool_call_id
17
+
18
+ #: String?
19
+ attr_reader :tool_name
20
+
21
+ #: String?
22
+ attr_reader :content
23
+
24
+ #: bool
25
+ attr_reader :is_error
26
+
27
+ #: (tool_call_id: String?, tool_name: String?, content: String?, is_error: bool) -> void
28
+ def initialize(tool_call_id:, tool_name:, content:, is_error:)
29
+ @tool_call_id = tool_call_id
30
+ @tool_name = tool_name
31
+ @content = content
32
+ @is_error = is_error
33
+ end
34
+
35
+ #: (PiInvocation::Context) -> String?
36
+ 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}" : ""}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end