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
@@ -53,20 +53,23 @@ module Roast
53
53
  end
54
54
  end
55
55
 
56
- #: (Agent::Config, Agent::Input) -> void
57
- def initialize(config, input)
56
+ #: (Agent::Config, String, String?, ?fork_session: bool) -> void
57
+ def initialize(config, prompt, session, fork_session: true)
58
58
  @base_command = config.valid_command #: (String | Array[String])?
59
59
  @model = config.valid_model #: String?
60
60
  @append_system_prompt = config.valid_append_system_prompt #: String?
61
61
  @replace_system_prompt = config.valid_replace_system_prompt #: String?
62
62
  @apply_permissions = config.apply_permissions? #: bool
63
63
  @working_directory = config.valid_working_directory #: Pathname?
64
- @prompt = input.valid_prompt! #: String
65
- @session = input.session #: String?
66
64
  @context = Context.new #: Context
67
65
  @result = Result.new #: Result
68
66
  @raw_dump_file = config.valid_dump_raw_agent_messages_to_path #: Pathname?
67
+ @show_prompt = config.show_prompt? #: bool
69
68
  @show_progress = config.show_progress? #: bool
69
+ @show_response = config.show_response? #: bool
70
+ @prompt = prompt
71
+ @session = session
72
+ @fork_session = fork_session #: bool
70
73
  end
71
74
 
72
75
  #: () -> void
@@ -74,6 +77,7 @@ module Roast
74
77
  raise ClaudeAlreadyStartedError if started?
75
78
 
76
79
  @started = true
80
+ Event << { block: { header: "USER PROMPT", content: @prompt } } if @show_prompt
77
81
  _stdout, stderr, status = CommandRunner.execute(
78
82
  command_line,
79
83
  working_directory: @working_directory,
@@ -83,6 +87,7 @@ module Roast
83
87
 
84
88
  if status.success?
85
89
  @completed = true
90
+ Event << { block: { header: "AGENT RESPONSE", content: @result.response } } if @show_response
86
91
  else
87
92
  @failed = true
88
93
  @result.success = false
@@ -180,7 +185,10 @@ module Roast
180
185
  command.push("--model", @model) if @model
181
186
  command.push("--system-prompt", @replace_system_prompt) if @replace_system_prompt
182
187
  command.push("--append-system-prompt", @append_system_prompt) if @append_system_prompt
183
- command.push("--fork-session", "--resume", @session) if @session.present?
188
+ if @session.present?
189
+ command.push("--fork-session") if @fork_session
190
+ command.push("--resume", @session)
191
+ end
184
192
  command << "--dangerously-skip-permissions" unless @apply_permissions
185
193
  command
186
194
  end
@@ -31,7 +31,7 @@ module Roast
31
31
  @content = hash.delete(:result) || ""
32
32
  @success = hash.delete(:success) || subtype == "success"
33
33
  if hash.delete(:is_error) || subtype == "error"
34
- @content = @content || hash.dig(:error, :message) || "Unknown error"
34
+ @content = @content.presence || hash.dig(:error, :message) || "Unknown error"
35
35
  hash.delete(:error)
36
36
  end
37
37
 
@@ -10,36 +10,376 @@ module Roast
10
10
  #: Symbol?
11
11
  attr_reader :tool_name
12
12
 
13
+ #: Hash[Symbol, untyped]
14
+ attr_reader :tool_use_input
15
+
13
16
  #: String?
14
17
  attr_reader :tool_use_description
15
18
 
16
- #: String?
19
+ #: (String | Array[Hash[Symbol, untyped]])?
17
20
  attr_reader :content
18
21
 
19
22
  #: bool
20
23
  attr_reader :is_error
21
24
 
22
- #: (tool_use: Messages::ToolUseMessage?, content: String?, is_error: bool) -> void
25
+ #: (tool_use: Messages::ToolUseMessage?, content: (String | Array[Hash[Symbol, untyped]])?, is_error: bool) -> void
23
26
  def initialize(tool_use:, content:, is_error:)
24
27
  @tool_name = tool_use&.name || :unknown
25
- @tool_use_description = tool_use&.input&.fetch(:description, nil) #: String?
28
+ @tool_use_input = tool_use&.input || {} #: Hash[Symbol, untyped]
29
+ @tool_use_description = @tool_use_input[:description] #: String?
26
30
  @content = content
27
31
  @is_error = is_error
28
32
  end
29
33
 
30
34
  #: () -> String
31
35
  def format
36
+ return error_line if is_error
37
+
32
38
  format_method_name = "format_#{tool_name}".to_sym
33
39
  return send(format_method_name) if respond_to?(format_method_name, true)
34
40
 
35
41
  format_unknown
36
42
  end
37
43
 
44
+ TRUNCATE_LIMIT = 45
45
+
38
46
  private
39
47
 
48
+ # Formats a Bash tool-result line.
49
+ #
50
+ # Content: the command's combined stdout/stderr, as a string.
51
+ #
52
+ # Output: "BASH OK <n> <line|lines> · <preview>" – <n> is the number of
53
+ # output lines (pluralized), and <preview> is the first line, stripped and
54
+ # truncated to TRUNCATE_LIMIT chars. The " · <preview>" suffix is omitted
55
+ # when the command produced no output.
56
+ #
57
+ # Examples:
58
+ # BASH OK 12 lines · Cloning into 'roast'...
59
+ # BASH OK 1 line · hello world
60
+ # BASH OK 0 lines
61
+ #
62
+ #: () -> String
63
+ def format_bash
64
+ lines = content.to_s.lines
65
+ count = lines.length
66
+ preview = truncate(lines.first.to_s.strip)
67
+ ok_line("#{count} #{"line".pluralize(count)}", preview)
68
+ end
69
+
70
+ # Formats a Read tool-result line.
71
+ #
72
+ # Content: the file's contents, as a string.
73
+ #
74
+ # Output: "READ OK <n> <line|lines>" – <n> is the number of lines
75
+ # read, pluralized.
76
+ #
77
+ # Examples:
78
+ # READ OK 42 lines
79
+ # READ OK 1 line
80
+ # READ OK 0 lines
81
+ #
82
+ #: () -> String
83
+ def format_read
84
+ count = content.to_s.lines.length
85
+ ok_line("#{count} #{"line".pluralize(count)}")
86
+ end
87
+
88
+ # Formats a Glob tool-result line.
89
+ #
90
+ # Content: newline-separated matches. Lines starting with "/" are
91
+ # file paths; any other line is a status message (a no-match
92
+ # sentinel, a truncation notice, etc.).
93
+ #
94
+ # Output: "GLOB OK <n> <file|files> found" – <n> is the number of
95
+ # path lines. When paths were found and a status message is present,
96
+ # it is appended as a truncated "NOTE <message>" part. A zero-result
97
+ # run omits the NOTE.
98
+ #
99
+ # Examples:
100
+ # GLOB OK 12 files found
101
+ # GLOB OK 1 file found
102
+ # GLOB OK 0 files found
103
+ # GLOB OK 8 files found · NOTE Results truncated...
104
+ #
105
+ #: () -> String
106
+ def format_glob
107
+ lines = content.to_s.lines.map(&:strip).reject(&:empty?)
108
+ files, notes = lines.partition { |line| line.start_with?("/") }
109
+ count = files.length
110
+ note = "NOTE #{truncate(notes.join(" "))}" if files.any? && notes.any?
111
+ ok_line("#{count} #{"file".pluralize(count)} found", note)
112
+ end
113
+
114
+ # Formats a Grep tool-result line.
115
+ #
116
+ # Content: newline-separated results. A line is a match when it
117
+ # starts with either a path segment containing "/" (a bare path or
118
+ # path:line:content), or an optional "file:" prefix followed by a
119
+ # "<digits>:" line number (line:content in single-file mode, or
120
+ # path:line:content for a root-level file with no "/"). Any other
121
+ # line is a status message (a no-match sentinel, a truncation
122
+ # notice, etc.).
123
+ #
124
+ # Output: "GREP OK <n> <match|matches>" – <n> is the number of match
125
+ # lines. When matches were found and a status message is present, it
126
+ # is appended as a truncated "NOTE <message>" part. A zero-result run
127
+ # omits the NOTE.
128
+ #
129
+ # Examples:
130
+ # GREP OK 12 matches
131
+ # GREP OK 1 match
132
+ # GREP OK 0 matches
133
+ # GREP OK 8 matches · NOTE Results truncated...
134
+ #
135
+ #: () -> String
136
+ def format_grep
137
+ lines = content.to_s.lines.map(&:strip).reject(&:empty?)
138
+ matches, notes = lines.partition { |line| line.match?(%r{\A\S+/}) || line.match?(/\A(?:\S+:)?\d+:/) }
139
+ count = matches.length
140
+ note = "NOTE #{truncate(notes.join(" "))}" if matches.any? && notes.any?
141
+ ok_line("#{count} #{"match".pluralize(count)}", note)
142
+ end
143
+
144
+ # Formats a Write tool-result line.
145
+ #
146
+ # Input: :file_path – the path that was written.
147
+ #
148
+ # Output: "WRITE OK <file_path>" – the file path, omitted if the
149
+ # input has none.
150
+ #
151
+ # Examples:
152
+ # WRITE OK lib/roast/version.rb
153
+ # WRITE OK
154
+ #
155
+ #: () -> String
156
+ def format_write
157
+ ok_line(tool_use_input[:file_path])
158
+ end
159
+
160
+ # Formats an Edit tool-result line.
161
+ #
162
+ # Input: :file_path – the path that was edited.
163
+ #
164
+ # Output: "EDIT OK <file_path>" – the file path, omitted if the
165
+ # input has none.
166
+ #
167
+ # Examples:
168
+ # EDIT OK lib/roast/version.rb
169
+ # EDIT OK
170
+ #
171
+ #: () -> String
172
+ def format_edit
173
+ ok_line(tool_use_input[:file_path])
174
+ end
175
+
176
+ # Formats a Skill tool-result line.
177
+ #
178
+ # Input: :skill – the name of the invoked skill.
179
+ #
180
+ # Output: "SKILL OK <skill>" – the skill name, omitted if the
181
+ # input has none.
182
+ #
183
+ # Examples:
184
+ # SKILL OK commit
185
+ # SKILL OK
186
+ #
187
+ #: () -> String
188
+ def format_skill
189
+ ok_line(tool_use_input[:skill])
190
+ end
191
+
192
+ # Formats a TodoWrite tool-result line.
193
+ #
194
+ # Input: :todos – the todo list; each item is a hash with :status
195
+ # ("pending"/"in_progress"/"completed"), :content, and :activeForm.
196
+ #
197
+ # Output: "TODOWRITE OK <done>/<total> done · <active>" – the
198
+ # completed count over the total, then the in-progress item's
199
+ # :activeForm (or :content), truncated. The " · <active>" part is
200
+ # omitted when nothing is in progress; an empty list yields a bare
201
+ # "TODOWRITE OK".
202
+ #
203
+ # Examples:
204
+ # TODOWRITE OK 3/8 done · Implementing the parser
205
+ # TODOWRITE OK 8/8 done
206
+ # TODOWRITE OK
207
+ #
208
+ #: () -> String
209
+ def format_todowrite
210
+ todos = tool_use_input[:todos] || []
211
+ done = todos.count { |todo| todo[:status] == "completed" }
212
+ active = todos.find { |todo| todo[:status] == "in_progress" }
213
+ progress = "#{done}/#{todos.length} done" if todos.any?
214
+ active_label = truncate(active[:activeForm] || active[:content]) if active
215
+ ok_line(progress, active_label)
216
+ end
217
+
218
+ # Formats a TaskUpdate tool-result line.
219
+ #
220
+ # Content: the text the tool returned.
221
+ #
222
+ # Output: "TASKUPDATE OK <preview>" – the first line of content,
223
+ # stripped and truncated to TRUNCATE_LIMIT chars. The preview is
224
+ # omitted when there is no content.
225
+ #
226
+ # Examples:
227
+ # TASKUPDATE OK Task updated successfully
228
+ # TASKUPDATE OK
229
+ #
230
+ #: () -> String
231
+ def format_taskupdate
232
+ preview = truncate(content.to_s.lines.first.to_s.strip)
233
+ ok_line(preview)
234
+ end
235
+
236
+ # Formats a TaskCreate tool-result line.
237
+ #
238
+ # Content: the text the tool returned.
239
+ #
240
+ # Output: "TASKCREATE OK <preview>" – the first line of content,
241
+ # stripped and truncated to TRUNCATE_LIMIT chars. The preview is
242
+ # omitted when there is no content.
243
+ #
244
+ # Examples:
245
+ # TASKCREATE OK Task created successfully
246
+ # TASKCREATE OK
247
+ #
248
+ #: () -> String
249
+ def format_taskcreate
250
+ preview = truncate(content.to_s.lines.first.to_s.strip)
251
+ ok_line(preview)
252
+ end
253
+
254
+ # Formats an Agent tool-result line.
255
+ #
256
+ # Content: the subagent's final message, delivered as a list of
257
+ # content blocks and joined into text.
258
+ #
259
+ # Output: "AGENT OK <preview>" – the first line of that text,
260
+ # stripped and truncated to TRUNCATE_LIMIT chars. The preview is
261
+ # omitted when there is no content.
262
+ #
263
+ # Examples:
264
+ # AGENT OK Refactored the parser; all tests pass
265
+ # AGENT OK Migrated the user table and backfilled all...
266
+ # AGENT OK
267
+ #
268
+ #: () -> String
269
+ def format_agent
270
+ preview = truncate(normalize_content(content).lines.first.to_s.strip)
271
+ ok_line(preview)
272
+ end
273
+
274
+ # Formats a Task tool-result line.
275
+ #
276
+ # Content: the dispatched subagent's reply, or a launch notice
277
+ # when the run is backgrounded – delivered as a list of content
278
+ # blocks and joined into text.
279
+ #
280
+ # Output: "TASK OK <preview>" – the first line of that text,
281
+ # stripped and truncated to TRUNCATE_LIMIT chars. The preview is
282
+ # omitted when there is no content.
283
+ #
284
+ # Examples:
285
+ # TASK OK Async agent launched successfully
286
+ # TASK OK Migrated the user table and backfilled all...
287
+ # TASK OK
288
+ #
289
+ #: () -> String
290
+ def format_task
291
+ preview = truncate(normalize_content(content).lines.first.to_s.strip)
292
+ ok_line(preview)
293
+ end
294
+
295
+ # Formats a TaskOutput tool-result line.
296
+ #
297
+ # Content: the polled task's payload – an XML-ish string carrying a
298
+ # <status> (or <retrieval_status>) tag and an <output> body.
299
+ #
300
+ # Output: "TASKOUTPUT OK <status>" – the text inside <status>, or
301
+ # inside <retrieval_status> when <status> is absent. The status is
302
+ # omitted when neither tag is present, leaving a bare "TASKOUTPUT OK".
303
+ #
304
+ # Examples:
305
+ # TASKOUTPUT OK completed
306
+ # TASKOUTPUT OK pending
307
+ # TASKOUTPUT OK
308
+ #
309
+ #: () -> String
310
+ def format_taskoutput
311
+ ok_line(tag_text("status") || tag_text("retrieval_status"))
312
+ end
313
+
40
314
  #: () -> String
41
315
  def format_unknown
42
- "UNKNOWN [#{tool_name}] #{is_error ? " ERROR" : "OK"} #{tool_use_description || ""}\n#{content}"
316
+ "UNKNOWN [#{tool_name}] OK #{tool_use_description}\n#{content}"
317
+ end
318
+
319
+ # Renders "<TOOL> OK[ <part> · <part> · ...]"; the success-side twin of
320
+ # #error_line. Blank/nil parts are dropped and the rest joined with " · ",
321
+ # so callers pass each piece of the summary without minding separators.
322
+ #
323
+ #: (*String?) -> String
324
+ def ok_line(*parts)
325
+ summary = parts.select(&:present?).join(" · ")
326
+ prefix = "#{tool_name.to_s.upcase} OK"
327
+ summary.present? ? "#{prefix} #{summary}" : prefix
328
+ end
329
+
330
+ # Renders "<TOOL> ERROR <message>" – the text inside the
331
+ # <tool_use_error> wrapper, or the whole content when it is unwrapped.
332
+ #
333
+ # Reads the instance's `content` and `tool_name` to produce a single-line
334
+ # error summary. Error messages are intentionally NOT truncated so the full
335
+ # diagnostic is preserved for debugging.
336
+ #
337
+ # Examples:
338
+ # BASH ERROR File has not been read yet.
339
+ # UNKNOWN ERROR command not found
340
+ #
341
+ #: () -> String
342
+ def error_line
343
+ message = tag_text("tool_use_error") || content.to_s.strip
344
+ "#{tool_name.to_s.upcase} ERROR #{message}".strip
345
+ end
346
+
347
+ # Returns the stripped text inside the first <tag>…</tag> pair in the
348
+ # result content, or nil when the tag is absent. The body is captured
349
+ # verbatim by a non-greedy match, so — unlike an XML parser — a body
350
+ # that itself contains bare angle brackets (such as an error message)
351
+ # is extracted intact.
352
+ #
353
+ # Matches a single, flat element: the lazy capture stops at the first
354
+ # closing </tag>, and nested same-name tags are not handled.
355
+ #
356
+ #: (String) -> String?
357
+ def tag_text(tag)
358
+ content.to_s[%r{<#{Regexp.escape(tag)}>(.*?)</#{Regexp.escape(tag)}>}m, 1]&.strip
359
+ end
360
+
361
+ # Truncates to TRUNCATE_LIMIT chars, appending "..." when cut. nil -> "".
362
+ #
363
+ #: (String?) -> String
364
+ def truncate(str)
365
+ s = str.to_s
366
+ s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
367
+ end
368
+
369
+ # The result's text. Agent and Task deliver a list of content
370
+ # blocks, which is joined into a string; every other shape is
371
+ # coerced with to_s.
372
+ #
373
+ #: ((String | Array[Hash[Symbol, untyped]])?) -> String
374
+ def normalize_content(value)
375
+ case value
376
+ when String
377
+ value
378
+ when Array
379
+ value.filter_map { |b| b[:text] }.join("\n")
380
+ else
381
+ value.to_s
382
+ end
43
383
  end
44
384
  end
45
385
  end