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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CONTRIBUTING.md +0 -2
  3. data/Gemfile.lock +8 -8
  4. data/README.md +26 -14
  5. data/examples/demo/Gemfile.lock +2 -2
  6. data/examples/plugin-gem-example/Gemfile.lock +4 -4
  7. data/examples/simple_agent.rb +1 -1
  8. data/examples/targets_and_params.rb +12 -0
  9. data/internal/documentation/comments/doc-comments-external.md +5 -3
  10. data/internal/documentation/comments/doc-comments.md +2 -1
  11. data/lib/roast/cog_input_manager.rb +2 -44
  12. data/lib/roast/cogs/agent/config.rb +103 -4
  13. data/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +5 -1
  14. data/lib/roast/cogs/agent/providers/claude/message.rb +4 -2
  15. data/lib/roast/cogs/agent/providers/pi/messages/tool_call_message.rb +218 -18
  16. data/lib/roast/cogs/agent/providers/pi/messages/tool_result_message.rb +208 -13
  17. data/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +2 -0
  18. data/lib/roast/cogs/agent/stats.rb +15 -0
  19. data/lib/roast/cogs/agent/usage.rb +7 -0
  20. data/lib/roast/cogs/agent.rb +57 -4
  21. data/lib/roast/cogs/chat/config.rb +53 -2
  22. data/lib/roast/cogs/chat.rb +13 -5
  23. data/lib/roast/cogs/cmd.rb +35 -0
  24. data/lib/roast/command_runner.rb +3 -1
  25. data/lib/roast/config_manager.rb +29 -2
  26. data/lib/roast/version.rb +1 -1
  27. data/lib/roast/workflow.rb +10 -7
  28. data/lib/roast/workflow_param_accessors.rb +53 -0
  29. data/sorbet/rbi/shims/lib/roast/cog/config.rbi +251 -0
  30. data/sorbet/rbi/shims/lib/roast/config_context.rbi +13 -1
  31. data/tutorial/01_your_first_workflow/README.md +3 -2
  32. data/tutorial/01_your_first_workflow/configured_chat.rb +1 -1
  33. data/tutorial/02_chaining_cogs/README.md +23 -2
  34. data/tutorial/02_chaining_cogs/code_review.rb +2 -2
  35. data/tutorial/02_chaining_cogs/session_resumption.rb +1 -0
  36. data/tutorial/03_targets_and_params/README.md +10 -1
  37. data/tutorial/04_configuration_options/README.md +19 -0
  38. data/tutorial/README.md +1 -1
  39. metadata +4 -2
@@ -25,31 +25,231 @@ module Roast
25
25
  def initialize(id:, name:, arguments:)
26
26
  @id = id
27
27
  @name = name
28
- @arguments = arguments
28
+ @arguments = arguments.is_a?(Hash) ? arguments : {}
29
29
  end
30
30
 
31
31
  #: () -> String?
32
32
  def format
33
33
  return unless name
34
34
 
35
- case name.to_s.downcase
36
- when "bash"
37
- "BASH #{arguments[:command]}"
38
- when "read"
39
- "READ #{arguments[:path]}"
40
- when "edit"
41
- "EDIT #{arguments[:path]}"
42
- when "write"
43
- "WRITE #{arguments[:path]}"
44
- when "grep"
45
- "GREP #{arguments[:pattern]} #{arguments[:path]}"
46
- when "find"
47
- "FIND #{arguments[:pattern]} #{arguments[:path]}"
48
- when "ls"
49
- "LS #{arguments[:path]}"
50
- else
51
- "TOOL [#{name}] #{arguments.inspect}"
35
+ format_method_name = "format_#{name.to_s.downcase}".to_sym
36
+ return send(format_method_name) if respond_to?(format_method_name, true)
37
+
38
+ format_unknown
39
+ rescue StandardError
40
+ format_unknown
41
+ end
42
+
43
+ # Truncates each value in a formatted tool-call string to keep terminal output concise.
44
+ TRUNCATE_LIMIT = 45
45
+
46
+ private
47
+
48
+ # Formats a bash tool call.
49
+ #
50
+ # Input fields:
51
+ # :command (String) – shell command to execute [required]
52
+ #
53
+ # Output: "BASH <command>", with :command truncated to TRUNCATE_LIMIT
54
+ # chars. A missing command renders the bare "BASH".
55
+ #
56
+ # Examples:
57
+ # BASH ls -la
58
+ # BASH
59
+ #
60
+ #: () -> String
61
+ def format_bash
62
+ command = truncate(arguments[:command])
63
+ command.empty? ? "BASH" : "BASH #{command}"
64
+ end
65
+
66
+ # Formats a read tool call.
67
+ #
68
+ # Input fields:
69
+ # :path (String) – file to read [required]
70
+ # :offset (Integer) – 1-indexed first line [optional]
71
+ # :limit (Integer) – maximum number of lines [optional]
72
+ #
73
+ # Output: "READ <path>", with a line range appended when :offset and/or
74
+ # :limit is given:
75
+ # :limit >= 1 → " (lines <start>–<end>)", start = :offset (default 1),
76
+ # end = start + :limit - 1
77
+ # :offset only → " (from line <offset>)" (reads to end of file); also the
78
+ # fallback when :limit <= 0, which has no sensible range
79
+ # With neither, the bare "READ <path>". A missing path renders "READ".
80
+ #
81
+ # Examples:
82
+ # READ lib/roast.rb (lines 30–80)
83
+ # READ lib/roast.rb (from line 30)
84
+ # READ lib/roast.rb
85
+ #
86
+ #: () -> String
87
+ def format_read
88
+ path, offset, limit = arguments.values_at(:path, :offset, :limit)
89
+ path = path.to_s
90
+ details = if limit&.positive?
91
+ offset ||= 1
92
+ "lines #{offset}–#{offset + limit - 1}"
93
+ elsif offset
94
+ "from line #{offset}"
52
95
  end
96
+ label = path.empty? ? "READ" : "READ #{path}"
97
+ details ? "#{label} (#{details})" : label
98
+ end
99
+
100
+ # Formats a write tool call.
101
+ #
102
+ # Input fields:
103
+ # :path (String) – file to write [required]
104
+ # :content (String) – file contents [required]
105
+ #
106
+ # Output: 'WRITE <path> "<preview>" (+<n> <line|lines>)' – <preview> is the
107
+ # first line of :content (stripped, truncated to TRUNCATE_LIMIT chars) and
108
+ # <n> the number of lines written. The content summary is appended only
109
+ # when :content is present; a missing path renders the bare "WRITE".
110
+ #
111
+ # Examples:
112
+ # WRITE lib/roast.rb "class Roast" (+10 lines)
113
+ # WRITE config/app.yml "enabled: true" (+1 line)
114
+ # WRITE lib/roast.rb
115
+ # WRITE
116
+ #
117
+ #: () -> String
118
+ def format_write
119
+ path, content = arguments.values_at(:path, :content)
120
+ path = path.to_s
121
+ label = path.empty? ? "WRITE" : "WRITE #{path}"
122
+ return label if content.nil?
123
+
124
+ lines = content.to_s.lines
125
+ preview = truncate(lines.first.to_s.strip)
126
+ count = lines.length
127
+ "#{label} \"#{preview}\" (+#{count} #{"line".pluralize(count)})"
128
+ end
129
+
130
+ # Formats an edit tool call.
131
+ #
132
+ # Input fields:
133
+ # :path (String) – file to edit [required]
134
+ # :edits (Array) – edit blocks, each {oldText, newText} [required]
135
+ #
136
+ # Output: "EDIT <path> (<n> <edit|edits>)" – <n> is the number of edit
137
+ # blocks applied in the call. The count is always shown.
138
+ #
139
+ # Examples:
140
+ # EDIT lib/roast.rb (1 edit)
141
+ # EDIT config/app.yml (3 edits)
142
+ #
143
+ #: () -> String
144
+ def format_edit
145
+ path = arguments[:path]
146
+ edits = arguments[:edits] || []
147
+ count = edits.length
148
+ "EDIT #{path} (#{count} #{"edit".pluralize(count)})"
149
+ end
150
+
151
+ # Formats a grep tool call.
152
+ #
153
+ # Input fields:
154
+ # :pattern (String) – regex/text to search for [required]
155
+ # :path (String) – file or directory to search [optional]
156
+ # :glob (String) – glob filter for matched files [optional]
157
+ # :limit (Integer) – max number of matches [optional]
158
+ #
159
+ # Output: 'GREP "<pattern>" <path> (glob: <glob>, limit: <limit>)' –
160
+ # <pattern> is truncated to TRUNCATE_LIMIT chars; <path> is shown only
161
+ # when present. The "glob:" and "limit:" details are each included only
162
+ # when present and joined with ", " inside a single trailing "(...)".
163
+ # `path`, `glob` and `limit` are left untruncated as they are usually short and
164
+ # contain important information that we don't want to truncate.
165
+ #
166
+ # Examples:
167
+ # GREP "def format" lib (glob: *.rb, limit: 50)
168
+ # GREP "TODO" (limit: 50)
169
+ # GREP "TODO"
170
+ #
171
+ #: () -> String
172
+ def format_grep
173
+ pattern, path, glob, limit = arguments.values_at(:pattern, :path, :glob, :limit)
174
+ base = "GREP \"#{truncate(pattern)}\""
175
+ base = "#{base} #{path}" if path.present?
176
+ details = [
177
+ ("glob: #{glob}" if glob.present?),
178
+ ("limit: #{limit}" if limit.present?),
179
+ ].compact
180
+ details.any? ? "#{base} (#{details.join(", ")})" : base
181
+ end
182
+
183
+ # Formats a find tool call.
184
+ #
185
+ # Input fields:
186
+ # :pattern (String) – filename glob to match [required]
187
+ # :path (String) – directory to search in [optional]
188
+ # :limit (Integer) – max number of results [optional]
189
+ #
190
+ # Output: "FIND <pattern> (path: <path>, limit: <limit>)" – the "path:"
191
+ # and "limit:" details are each included only when present and joined
192
+ # with ", " inside a single trailing "(...)".
193
+ #
194
+ # Examples:
195
+ # FIND *.rb (path: lib, limit: 50)
196
+ # FIND *.rb (limit: 50)
197
+ # FIND *.rb
198
+ #
199
+ #: () -> String
200
+ def format_find
201
+ pattern, path, limit = arguments.values_at(:pattern, :path, :limit)
202
+ details = [
203
+ ("path: #{path}" if path.present?),
204
+ ("limit: #{limit}" if limit.present?),
205
+ ].compact
206
+ details.any? ? "FIND #{pattern} (#{details.join(", ")})" : "FIND #{pattern}"
207
+ end
208
+
209
+ # Formats an ls tool call.
210
+ #
211
+ # Input fields:
212
+ # :path (String) – directory to list [optional]
213
+ #
214
+ # Output: "LS <path>" – a missing path renders the bare "LS".
215
+ #
216
+ # Examples:
217
+ # LS lib/roast
218
+ # LS
219
+ #
220
+ #: () -> String
221
+ def format_ls
222
+ path = arguments[:path]
223
+ path.to_s.empty? ? "LS" : "LS #{path}"
224
+ end
225
+
226
+ # Formats a tool call for which Roast has no dedicated formatter.
227
+ #
228
+ # Output: "<NAME> <key>: <value>, ..." – the upcased tool name, then each
229
+ # argument as "<key>: <inspected value>", ordered shortest pair first so the
230
+ # most compact arguments stay visible, joined with ", ". Every value is
231
+ # truncated to TRUNCATE_LIMIT chars so one large argument can't flood the
232
+ # line; keys are always shown. No arguments renders the bare "<NAME>".
233
+ #
234
+ # Examples:
235
+ # WEB_SEARCH max_results: 5, query: "ruby pluralize"
236
+ # DEPLOY
237
+ #
238
+ #: () -> String
239
+ def format_unknown
240
+ label = name.to_s.upcase
241
+ return label if arguments.empty?
242
+
243
+ details = arguments.map { |key, value| "#{key}: #{truncate(value.inspect)}" }.sort_by { |s| [s.length, s] }.join(", ")
244
+ "#{label} #{details}"
245
+ end
246
+
247
+ # Truncates to TRUNCATE_LIMIT chars, appending "..." when cut. nil -> "".
248
+ #
249
+ #: (String?) -> String
250
+ def truncate(str)
251
+ s = str.to_s
252
+ s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
53
253
  end
54
254
  end
55
255
  end
@@ -30,23 +30,218 @@ module Roast
30
30
  @tool_name = tool_name
31
31
  @content = content
32
32
  @is_error = is_error
33
+ @name = (tool_name || "unknown").to_s #: String
34
+ @input = {} #: Hash[Symbol, untyped]
33
35
  end
34
36
 
35
37
  #: (PiInvocation::Context) -> String?
36
38
  def format(context)
37
- tool_call = context.tool_call(tool_call_id)
38
- name = tool_name || tool_call&.name || "unknown"
39
- status = is_error ? "ERROR" : "OK"
40
-
41
- # Truncate long tool results for progress display
42
- c = content
43
- display_content = if c && c.length > 200
44
- "#{c[0..197]}..."
45
- else
46
- c
47
- end
48
-
49
- "#{name.upcase} #{status}#{display_content ? " #{display_content}" : ""}"
39
+ call = context.tool_call(tool_call_id)
40
+ @name = (tool_name || call&.name || "unknown").to_s
41
+ @input = call&.arguments || {}
42
+
43
+ return error_line if is_error
44
+
45
+ format_method_name = "format_#{@name.downcase}".to_sym
46
+ return send(format_method_name) if respond_to?(format_method_name, true)
47
+
48
+ format_unknown
49
+ rescue StandardError
50
+ format_unknown
51
+ end
52
+
53
+ # Truncates each element in a formatted tool-result string to keep terminal output concise.
54
+ TRUNCATE_LIMIT = 45
55
+
56
+ private
57
+
58
+ # Formats a bash tool result.
59
+ #
60
+ # Content: the command's output text.
61
+ #
62
+ # Output: "BASH OK <n> <line|lines> · <preview>" – <n> is the line count
63
+ # (pluralized) and <preview> is the first line, stripped and truncated to
64
+ # TRUNCATE_LIMIT chars. The preview is omitted when there is no output.
65
+ #
66
+ # Examples:
67
+ # BASH OK 12 lines · Cloning into 'roast'...
68
+ # BASH OK 1 line · hello world
69
+ # BASH OK 0 lines
70
+ #
71
+ #: () -> String
72
+ def format_bash
73
+ lines = content.to_s.lines
74
+ count = lines.length
75
+ preview = truncate(lines.first.to_s.strip)
76
+ ok_line("#{count} #{"line".pluralize(count)}", preview)
77
+ end
78
+
79
+ # Formats a read tool result.
80
+ #
81
+ # Content: the file's text.
82
+ #
83
+ # Output: "READ OK <n> <line|lines>" – <n> is the file's line count
84
+ # (pluralized). Blank lines are counted: this is the file's own length, so
85
+ # it deliberately differs from entry-listing tools (find/ls), which drop
86
+ # blanks because a blank isn't an entry.
87
+ #
88
+ # Examples:
89
+ # READ OK 42 lines
90
+ # READ OK 1 line
91
+ # READ OK 0 lines
92
+ #
93
+ #: () -> String
94
+ def format_read
95
+ count = content.to_s.lines.length
96
+ ok_line("#{count} #{"line".pluralize(count)}")
97
+ end
98
+
99
+ # Formats a write tool result.
100
+ #
101
+ # Input: :path – the path that was written, from the originating call.
102
+ #
103
+ # Output: "WRITE OK <path>" – the file path, omitted when the call
104
+ # had none.
105
+ #
106
+ # Examples:
107
+ # WRITE OK lib/roast/version.rb
108
+ # WRITE OK
109
+ #
110
+ #: () -> String
111
+ def format_write
112
+ ok_line(@input[:path])
113
+ end
114
+
115
+ # Formats an edit tool result.
116
+ #
117
+ # Input: :path – the path that was edited, from the originating call.
118
+ #
119
+ # Output: "EDIT OK <path>" – the file path, omitted when the call
120
+ # had none.
121
+ #
122
+ # Examples:
123
+ # EDIT OK lib/roast/version.rb
124
+ # EDIT OK
125
+ #
126
+ #: () -> String
127
+ def format_edit
128
+ ok_line(@input[:path])
129
+ end
130
+
131
+ # Formats a grep tool result.
132
+ #
133
+ # Content: matching lines, and possibly informational notes, one per
134
+ # line.
135
+ #
136
+ # Output: "GREP OK <n> <match|matches>[ · NOTE <notes>]" – <n> counts
137
+ # lines that look like matches (a leading path or line-number prefix);
138
+ # any remaining lines are joined into a truncated NOTE, shown only when
139
+ # there are both matches and notes.
140
+ #
141
+ # Examples:
142
+ # GREP OK 3 matches
143
+ # GREP OK 1 match
144
+ # GREP OK 0 matches
145
+ #
146
+ #: () -> String
147
+ def format_grep
148
+ lines = content.to_s.lines.map(&:strip).reject(&:empty?)
149
+ matches, notes = lines.partition { |line| line.match?(%r{\A\S+/}) || line.match?(/\A(?:\S+:)?\d+:/) }
150
+ count = matches.length
151
+ note = "NOTE #{truncate(notes.join(" "))}" if matches.any? && notes.any?
152
+ ok_line("#{count} #{"match".pluralize(count)}", note)
153
+ end
154
+
155
+ # Formats a find tool result.
156
+ #
157
+ # Content: matching paths, one per line, plus an optional status line –
158
+ # either a bracketed notice ("[2 results limit reached. ...]") or the
159
+ # no-results prose ("No files found matching pattern").
160
+ #
161
+ # Output: "FIND OK <n> <path|paths>[ · NOTE <status>]" – <n> counts the
162
+ # path lines only, and the notice's brackets are dropped. As in #format_grep,
163
+ # the NOTE is shown only alongside results: "0 paths" already says what the
164
+ # no-results prose would.
165
+ #
166
+ # Examples:
167
+ # FIND OK 12 paths
168
+ # FIND OK 1 path
169
+ # FIND OK 2 paths · NOTE 2 results limit reached. Use limit=4 for m...
170
+ # FIND OK 0 paths
171
+ #
172
+ #: () -> String
173
+ def format_find
174
+ lines = content.to_s.lines.map(&:strip).reject(&:empty?)
175
+ notes, paths = lines.partition { |line| line.sub!(/\A\[(.*)\]\z/, '\1') || line.match?(/\ANo files found/) }
176
+ count = paths.length
177
+ note = "NOTE #{truncate(notes.join(" "))}" if paths.any? && notes.any?
178
+ ok_line("#{count} #{"path".pluralize(count)}", note)
179
+ end
180
+
181
+ # Formats an ls tool result.
182
+ #
183
+ # Content: directory entries, one per line.
184
+ #
185
+ # Output: "LS OK <n> <entry|entries>" – <n> is the number of non-blank
186
+ # lines (pluralized).
187
+ #
188
+ # Examples:
189
+ # LS OK 8 entries
190
+ # LS OK 1 entry
191
+ # LS OK 0 entries
192
+ #
193
+ #: () -> String
194
+ def format_ls
195
+ count = content.to_s.lines.map(&:strip).reject(&:empty?).length
196
+ ok_line("#{count} #{"entry".pluralize(count)}")
197
+ end
198
+
199
+ # Formats a result for which Roast has no dedicated formatter.
200
+ #
201
+ # Content: the tool's output text.
202
+ #
203
+ # Output: "<NAME> OK <preview>" – the first line of content, stripped and
204
+ # truncated to TRUNCATE_LIMIT chars. The preview is omitted when there is
205
+ # no content.
206
+ #
207
+ # Examples:
208
+ # WEB_SEARCH OK 3 results for "ruby pluralize"
209
+ # DEPLOY OK
210
+ #
211
+ #: () -> String
212
+ def format_unknown
213
+ preview = truncate(content.to_s.lines.first.to_s.strip)
214
+ ok_line(preview)
215
+ end
216
+
217
+ # Renders "<TOOL> OK[ <part> · <part> · ...]"; the success-side twin of
218
+ # #error_line. Blank/nil parts are dropped and the rest joined with " · ",
219
+ # so callers pass each piece of the summary without minding separators.
220
+ #
221
+ #: (*String?) -> String
222
+ def ok_line(*parts)
223
+ summary = parts.select(&:present?).join(" · ")
224
+ prefix = "#{@name.upcase} OK"
225
+ summary.present? ? "#{prefix} #{summary}" : prefix
226
+ end
227
+
228
+ # Renders "<TOOL> ERROR <message>". The content is shown as-is and intentionally not truncated,
229
+ # preserving the full diagnostic.
230
+ #
231
+ # Examples:
232
+ # READ ERROR ENOENT: no such file or directory
233
+ #
234
+ #: () -> String
235
+ def error_line
236
+ "#{@name.upcase} ERROR #{content.to_s.strip}".strip
237
+ end
238
+
239
+ # Truncates to TRUNCATE_LIMIT chars, appending "..." when cut. nil -> "".
240
+ #
241
+ #: (String?) -> String
242
+ def truncate(str)
243
+ s = str.to_s
244
+ s.length > TRUNCATE_LIMIT ? "#{s[0...TRUNCATE_LIMIT - 3]}..." : s
50
245
  end
51
246
  end
52
247
  end
@@ -60,6 +60,7 @@ module Roast
60
60
  @append_system_prompt = config.valid_append_system_prompt #: String?
61
61
  @replace_system_prompt = config.valid_replace_system_prompt #: String?
62
62
  @working_directory = config.valid_working_directory #: Pathname?
63
+ @timeout = config.valid_timeout #: Integer?
63
64
  @prompt = prompt #: String
64
65
  @session = session #: String?
65
66
  @context = Context.new #: Context
@@ -86,6 +87,7 @@ module Roast
86
87
  command_line,
87
88
  working_directory: @working_directory,
88
89
  stdin_content: @prompt,
90
+ timeout: @timeout,
89
91
  stdout_handler: lambda { |line| handle_stdout(line) },
90
92
  )
91
93
  @end_time_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i #: Integer?
@@ -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?
@@ -68,6 +68,13 @@ module Roast
68
68
  result
69
69
  end
70
70
 
71
+ # Get the token counts and cost as a hash
72
+ #
73
+ #: () -> Hash[Symbol, untyped]
74
+ def to_h
75
+ { input_tokens:, output_tokens:, cost_usd: }
76
+ end
77
+
71
78
  private
72
79
 
73
80
  #: (Numeric?, Numeric?) -> Numeric?
@@ -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
- output = provider.invoke(input)
51
- if config.show_stats?
52
- Event << { block: { header: "AGENT STATS", content: "#{output.stats}\nSession ID: #{output.session}" } }
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!