samagotchi 0.68.0 → 0.70.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +31 -8
  6. data/docs/context.md +15 -6
  7. data/docs/guardrails.md +23 -1
  8. data/docs/hooks.md +5 -1
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +25 -13
  12. data/docs/internals/prompt-caching.md +9 -2
  13. data/docs/internals/tool-guardrails.md +61 -1
  14. data/docs/plugins.md +66 -38
  15. data/docs/releasing.md +14 -6
  16. data/docs/sessions.md +5 -5
  17. data/docs/sub-agent.md +2 -1
  18. data/docs/testing.md +8 -1
  19. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
  26. data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
  27. data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
  28. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
  30. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  33. data/lib/samagotchi/config.rb +6 -21
  34. data/lib/samagotchi/context_absorber.rb +35 -21
  35. data/lib/samagotchi/engine.rb +7 -2
  36. data/lib/samagotchi/guardrails/targets.rb +9 -2
  37. data/lib/samagotchi/host_config.rb +1 -29
  38. data/lib/samagotchi/host_model.rb +0 -7
  39. data/lib/samagotchi/kernel_loop.rb +34 -7
  40. data/lib/samagotchi/llm/errors.rb +47 -1
  41. data/lib/samagotchi/llm/http.rb +20 -2
  42. data/lib/samagotchi/llm_context_stale.rb +8 -2
  43. data/lib/samagotchi/log_subscriber.rb +1 -0
  44. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  45. data/lib/samagotchi/model_price.rb +2 -4
  46. data/lib/samagotchi/peer_sources.rb +1 -1
  47. data/lib/samagotchi/plugin/api.rb +3 -2
  48. data/lib/samagotchi/plugin/peer.rb +35 -16
  49. data/lib/samagotchi/session_metrics.rb +4 -1
  50. data/lib/samagotchi/system_prompt.rb +19 -3
  51. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  52. data/lib/samagotchi/tool_activity.rb +3 -0
  53. data/lib/samagotchi/tool_output_cut.rb +312 -0
  54. data/lib/samagotchi/tool_output_files.rb +73 -0
  55. data/lib/samagotchi/tool_row_fields.rb +4 -2
  56. data/lib/samagotchi/tool_runner.rb +70 -15
  57. data/lib/samagotchi/toolkit.rb +27 -14
  58. data/lib/samagotchi/toolkit_binding.rb +8 -2
  59. data/lib/samagotchi/toolkit_command.rb +20 -4
  60. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  61. data/lib/samagotchi/tools/delegate.rb +4 -1
  62. data/lib/samagotchi/tools/delegate_wait.rb +12 -1
  63. data/lib/samagotchi/tools/ref_text.rb +27 -0
  64. data/lib/samagotchi/tools/registry.rb +95 -8
  65. data/lib/samagotchi/tools/task_wait.rb +4 -1
  66. data/lib/samagotchi/tools/tool_index.rb +223 -72
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +14 -9
  69. data/lib/samagotchi/web/message_parts.rb +9 -4
  70. data/lib/samagotchi/web/public/activity.js +3 -1
  71. data/lib/samagotchi/web/public/app.js +123 -22
  72. data/lib/samagotchi/web/public/chains.js +19 -2
  73. data/lib/samagotchi/web/public/families_view.js +15 -0
  74. data/lib/samagotchi/web/public/index.html +7 -5
  75. data/lib/samagotchi/web/public/model_picker.js +21 -6
  76. data/lib/samagotchi/web/public/session_state.js +5 -0
  77. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  78. data/lib/samagotchi/web/public/timing.js +3 -1
  79. data/lib/samagotchi/web/public/toast_place.js +16 -0
  80. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  81. data/lib/samagotchi/web/public/turn_html.js +4 -2
  82. data/lib/samagotchi/worker.rb +2 -2
  83. data/lib/samagotchi/worker_inbound.rb +3 -0
  84. metadata +5 -1
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_output_files"
4
+
5
+ module Samagotchi
6
+ # How ToolRunner cuts a tool's output that is longer than
7
+ # max_tool_output_chars, by the tool's policy (POLICIES, by its built-in
8
+ # name; any other tool gets :head).
9
+ #
10
+ # :head keeps the first chars and ends with head_line ("[cut: N of M
11
+ # chars; read it in parts]"): read (which has line ranges) and every tool
12
+ # with no policy of its own.
13
+ #
14
+ # :head_tail_file (execute) keeps a short head and a long tail, whole
15
+ # lines, with one gap line between them, and saves the whole output
16
+ # (ToolOutputFiles, through the caller's +save+): a test run's verdict and
17
+ # an error are at the end. The gap line names the saved file and the lines
18
+ # left out of it, so the model reads or rg's it instead of running the
19
+ # command again. execute's stdout and stderr are cut each on its own
20
+ # (stdout gets about two thirds of the room), its exit line always kept.
21
+ # The cut text still fits the cap, and comes from the content alone (no
22
+ # clock): the same output gives the same text.
23
+ #
24
+ # :head_tail_ref (task_wait, delegate, delegate_result) cuts the same
25
+ # way, as one block, and saves nothing: the whole is already in a file (a
26
+ # task's log, the child's reply in its output/ folder), which the tool
27
+ # gave with its text (Tools::RefText) and the gap line names.
28
+ module ToolOutputCut
29
+ POLICIES = { "execute" => :head_tail_file, "task_wait" => :head_tail_ref, "delegate" => :head_tail_ref,
30
+ "delegate_result" => :head_tail_ref }.freeze
31
+
32
+ # What a :head_tail_ref tool's file holds, for its gap line.
33
+ REF_WHAT = { "task_wait" => "the task's whole output", "delegate" => "the child's whole reply",
34
+ "delegate_result" => "the child's whole reply" }.freeze
35
+
36
+ # A head cut's last line (head_line), as LLMContextStale recognizes a
37
+ # partial read by it. Kept in sync with head_line by its spec.
38
+ HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
39
+
40
+ # The head's chars at most (execute.cut_head_chars): never more than a
41
+ # quarter of the room; the tail gets the rest.
42
+ HEAD_CHARS = 1000
43
+ # A tail shorter than this isn't worth it: the cap is too small, and
44
+ # the output gets the head cut.
45
+ MIN_TAIL = 200
46
+
47
+ # An execute that didn't finish: stopped, killed at the limit or
48
+ # handed over (its result starts with an "Error:" or a "Still running"
49
+ # line after the lead), or with no exit line last.
50
+ UNFINISHED = /\A(?:ran as: [^\n]*\n)?\[execute\]\n(?:Error: |Still running at the )/
51
+
52
+ # The marks of a text that is already a head-and-tail preview (execute's
53
+ # own above execute.truncate_at_bytes): its saved copy isn't the whole
54
+ # output.
55
+ PREVIEW_MARK = "[TRUNCATED_PREVIEW_HEAD]"
56
+ # execute's blocks (Tools::Execute#call): stdout, stderr, then the exit
57
+ # line last (a hint in brackets may follow it). A killed or handed-over
58
+ # command has none: its output isn't all there is.
59
+ STDOUT_LABEL = "\nstdout:\n"
60
+ STDERR_LABEL = "\nstderr:\n"
61
+ EXIT_LINE = /\nexit: -?\d+[^\n]*(?:\n\([^\n]*\))?\z/
62
+
63
+ # The share of the room stdout gets when both blocks are cut.
64
+ STDOUT_SHARE = Rational(2, 3)
65
+
66
+ # A cut part: [from, length] of the output.
67
+ Block = Data.define(:from, :length) do
68
+ def to = from + length
69
+ end
70
+
71
+ module_function
72
+
73
+ # @param tool [String, nil] the call's tool name
74
+ # @return [Symbol] its policy
75
+ def policy(tool) = POLICIES.fetch(tool.to_s, :head)
76
+
77
+ # +output+ cut to +cap+ chars by +tool+'s policy.
78
+ # @param save [#call, nil] (text, dry:) → ToolOutputFiles::Saved, or a
79
+ # String saying why it wasn't saved ("no session", "Errno::ENOSPC");
80
+ # dry: true only says where it would go. It writes only once the
81
+ # head-and-tail cut is known to fit the cap with that file's gap
82
+ # line: a cut that falls back to the head cut saves nothing.
83
+ # @param head_chars [Integer] execute.cut_head_chars
84
+ # @return [Array(String, Object)] the text the model gets, and what
85
+ # +save+ returned (nil when it wasn't called)
86
+ # @param ref [String, nil] :head_tail_ref: the file the whole text is in
87
+ # @param ref_stderr [String, nil] :head_tail_ref: its stderr's file, if apart
88
+ def apply(output, cap:, tool:, save: nil, ref: nil, ref_stderr: nil, head_chars: HEAD_CHARS)
89
+ kind = policy(tool)
90
+ return [head(output, cap), nil] if kind == :head || cap < MIN_TAIL * 3
91
+
92
+ if kind == :head_tail_ref
93
+ text = head_tail(output, cap: cap, blocks: [Block.new(from: 0, length: output.length)], head_chars: head_chars,
94
+ gap: RefGap.new(output, ref, REF_WHAT.fetch(tool.to_s, "the whole output"),
95
+ stderr: ref_stderr))
96
+ return [text || head(output, cap), nil]
97
+ end
98
+
99
+ blocks = execute_blocks(output)
100
+ planned = save ? save.call(output, dry: true) : "no session"
101
+ text = head_tail(output, cap: cap, blocks: blocks, head_chars: head_chars, gap: FileGap.new(output, planned))
102
+ return [head(output, cap), nil] unless text
103
+ return [text, planned] unless planned.is_a?(ToolOutputFiles::Saved)
104
+
105
+ saved = save.call(output)
106
+ return [text, saved] if saved.is_a?(ToolOutputFiles::Saved)
107
+
108
+ # The write failed: the line says so instead, if that still fits.
109
+ text = head_tail(output, cap: cap, blocks: blocks, head_chars: head_chars, gap: FileGap.new(output, saved))
110
+ [text || head(output, cap), text ? saved : nil]
111
+ end
112
+
113
+ def unfinished?(output) = output.match?(UNFINISHED) || !output.match?(EXIT_LINE)
114
+
115
+ # +output+ as a web row keeps it, at most +cap+ chars (a reload's
116
+ # MessageParts, the Bridge's snapshot): a read's head (its view numbers
117
+ # the lines from the first), any other tool's head and tail with a gap
118
+ # line, as the model's cut keeps a test run's verdict and exit line.
119
+ def preview(output, cap:, tool:)
120
+ return output if output.length <= cap
121
+ return output[0, cap] if tool.to_s == "read"
122
+
123
+ head_tail(output, cap: cap, blocks: [Block.new(from: 0, length: output.length)], head_chars: HEAD_CHARS,
124
+ gap: RefGap.new(output, nil, nil)) || output[0, cap]
125
+ end
126
+
127
+ # +output+ cut to +cap+ chars, the cut line included: the model knows
128
+ # what it didn't get. A cap smaller than the line keeps +cap+ chars and
129
+ # the line goes past it.
130
+ def head(output, cap)
131
+ keep = cap - head_line(cap, output.length).length
132
+ keep = cap unless keep.positive?
133
+ "#{output[0, keep]}#{head_line(keep, output.length)}"
134
+ end
135
+
136
+ def head_line(kept, total)
137
+ "\n[cut: #{kept} of #{total} chars; read it in parts]"
138
+ end
139
+
140
+ # +output+ with each of +blocks+ that doesn't fit its share of the room
141
+ # cut to its head and tail, a gap line between them (the first cut's
142
+ # line +gap+.line, the next ones' +gap+.short). nil when the cap leaves
143
+ # too little room.
144
+ def head_tail(output, cap:, blocks:, head_chars:, gap:)
145
+ fixed = output.length - blocks.sum(&:length)
146
+ # Room for the gap lines (their longest numbers) and a newline each.
147
+ room = cap - fixed - (gap.line_max.length + 2) - (blocks.size > 1 ? gap.short_max.length + 2 : 0)
148
+ shares = shares(blocks.map(&:length), room)
149
+ return nil unless shares
150
+
151
+ text = +""
152
+ at = 0
153
+ first = true
154
+ blocks.zip(shares).each do |block, share|
155
+ text << output[at...block.from]
156
+ at = block.to
157
+ body = output[block.from, block.length]
158
+ if body.length <= share
159
+ text << body
160
+ next
161
+ end
162
+
163
+ shown = cut_block(body, share, head_chars)
164
+ return nil unless shown
165
+
166
+ head_part, tail_part = shown
167
+ left_out = Block.new(from: block.from + head_part.length, length: body.length - head_part.length - tail_part.length)
168
+ text << head_part
169
+ text << "\n" unless head_part.end_with?("\n") || head_part.empty?
170
+ text << (first ? gap.line(left_out) : gap.short(left_out)) << "\n" << tail_part
171
+ first = false
172
+ end
173
+ text << output[at..]
174
+ text.length <= cap ? text : nil
175
+ end
176
+
177
+ # The room each block gets: stdout STDOUT_SHARE when both need cutting,
178
+ # a block that needs less gives the rest to the other. nil when there
179
+ # isn't room for a useful tail.
180
+ def shares(lengths, room)
181
+ return nil if room < MIN_TAIL
182
+ return [room] if lengths.size == 1
183
+
184
+ first = (room * STDOUT_SHARE).floor
185
+ second = room - first
186
+ if lengths[1] < second
187
+ [room - lengths[1], lengths[1]]
188
+ elsif lengths[0] < first
189
+ [lengths[0], room - lengths[0]]
190
+ else
191
+ [first, second]
192
+ end
193
+ end
194
+
195
+ # +body+'s head and tail within +share+ chars, each snapped to whole
196
+ # lines when that keeps at least half of it. nil when the tail would
197
+ # be shorter than MIN_TAIL.
198
+ def cut_block(body, share, head_chars)
199
+ head_size = [head_chars, share / 4].min
200
+ tail_size = share - head_size
201
+ return nil if tail_size < MIN_TAIL
202
+
203
+ head_part = body[0, head_size]
204
+ newline = head_part.rindex("\n")
205
+ head_part = head_part[0, newline + 1] if newline && newline + 1 >= head_size / 2
206
+ tail_part = body[-tail_size..]
207
+ newline = tail_part.index("\n")
208
+ tail_part = tail_part[(newline + 1)..] if newline && tail_part.length - newline - 1 >= tail_size / 2
209
+ [head_part, tail_part]
210
+ end
211
+
212
+ # execute's stdout and stderr bodies, each cut on its own; any other
213
+ # text (no "stdout:"/"stderr:" block: an error, a hand-over) is one.
214
+ def execute_blocks(output)
215
+ ends = output.match(EXIT_LINE)&.begin(0) || output.length
216
+ stdout_at = output.index(STDOUT_LABEL)
217
+ stderr_at = output.rindex(STDERR_LABEL, ends - STDERR_LABEL.length)
218
+ stderr_at = nil if stderr_at && stdout_at && stderr_at < stdout_at
219
+ blocks = []
220
+ if stdout_at
221
+ from = stdout_at + STDOUT_LABEL.length
222
+ blocks << Block.new(from: from, length: [(stderr_at || ends) - from, 0].max)
223
+ end
224
+ blocks << Block.new(from: stderr_at + STDERR_LABEL.length, length: ends - stderr_at - STDERR_LABEL.length) if stderr_at
225
+ blocks.empty? ? [Block.new(from: 0, length: output.length)] : blocks
226
+ end
227
+
228
+ # The gap line of a :head_tail_ref cut: the file the whole text is in.
229
+ class RefGap
230
+ def initialize(output, ref, what, stderr: nil)
231
+ @output = output
232
+ @ref = ref.to_s
233
+ @what = what
234
+ @stderr = stderr.to_s
235
+ end
236
+
237
+ def line(left_out)
238
+ also = @stderr.empty? ? "" : " (its stderr in #{@stderr})"
239
+ where = @ref.empty? ? "" : "; #{@what} is in #{@ref}#{also}: read it with start_line/end_line or rg it"
240
+ "[cut: #{left_out.length} of #{@output.length} chars not shown#{where}]"
241
+ end
242
+
243
+ def short(left_out) = "[cut: #{left_out.length} chars not shown]"
244
+ def line_max = line(Block.new(from: 0, length: @output.length))
245
+ def short_max = short(Block.new(from: 0, length: @output.length))
246
+ end
247
+
248
+ # The gap lines of a :head_tail_file cut: where the whole output is
249
+ # saved, and which of its lines the cut left out.
250
+ class FileGap
251
+ # @param saved [ToolOutputFiles::Saved, String] the saved file, or
252
+ # why there is none
253
+ def initialize(output, saved)
254
+ @output = output
255
+ @saved = saved.is_a?(String) ? nil : saved
256
+ @reason = saved.is_a?(String) ? saved : nil
257
+ end
258
+
259
+ # The first cut's line.
260
+ def line(left_out)
261
+ return "[cut: #{left_out.length} of #{@output.length} chars not shown; #{unsaved_note}]" unless @saved
262
+
263
+ "[cut: #{lines_text(left_out)} of the saved file not shown (#{left_out.length} of #{@output.length} chars); " \
264
+ "#{saved_note}]"
265
+ end
266
+
267
+ # A further cut's line, the file named by the first.
268
+ def short(left_out)
269
+ return "[cut: #{left_out.length} chars not shown]" unless @saved
270
+
271
+ "[cut: #{lines_text(left_out)} of the saved file not shown (#{left_out.length} chars)]"
272
+ end
273
+
274
+ # The longest line and short line it can write (budgeting).
275
+ def line_max = line(widest)
276
+ def short_max = short(widest)
277
+
278
+ private
279
+
280
+ def widest = Block.new(from: 0, length: @output.length)
281
+
282
+ def lines_text(left_out)
283
+ return "lines #{lines_total}-#{lines_total}" if left_out.from.zero? && left_out.length == @output.length
284
+
285
+ first = @output[0, left_out.from].count("\n") + 1
286
+ last = @output[0, left_out.to].count("\n")
287
+ last += 1 unless left_out.to.zero? || @output[left_out.to - 1] == "\n"
288
+ "lines #{first}-#{[last, first].max}"
289
+ end
290
+
291
+ def lines_total = @saved.lines
292
+
293
+ def where = "#{@saved.path} (#{@saved.bytes} bytes, #{@saved.lines} lines)"
294
+
295
+ def saved_note
296
+ if @output.include?(PREVIEW_MARK)
297
+ "this preview is saved in #{where}; the full output wasn't kept: read the preview with " \
298
+ "start_line/end_line or rg it"
299
+ elsif ToolOutputCut.unfinished?(@output)
300
+ "the output it printed is saved in #{where}: read it with start_line/end_line or rg it"
301
+ else
302
+ "the whole output is saved in #{where}: read it with start_line/end_line or rg it; don't rerun the command"
303
+ end
304
+ end
305
+
306
+ def unsaved_note
307
+ "the whole output wasn't saved (#{@reason}); to look past this, rerun with the output sent to a file " \
308
+ "(cmd > /tmp/out.txt 2>&1) and read that"
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require_relative "atomic_file"
6
+
7
+ module Samagotchi
8
+ # The whole output of a tool call ToolRunner cut (ToolOutputCut's
9
+ # :head_tail_file), saved in the session's folder, tool_output/
10
+ # (sessions/<id>/tool_output/; not output/, the session's reply outbox),
11
+ # so the model reads or rg's it instead of running the command again.
12
+ #
13
+ # The name comes from the content (<tool>-<sha256[0,12]>.txt): the same
14
+ # output gives the same path, so the cut text is the same each time
15
+ # (loop-guard's repeat check, the prompt cache). The files go with the
16
+ # session folder; a session keeps its newest MAX_FILES.
17
+ module ToolOutputFiles
18
+ DIR = "tool_output"
19
+ MAX_FILES = 50
20
+
21
+ # Where a saved output is, and its size.
22
+ Saved = Data.define(:path, :bytes, :lines)
23
+
24
+ module_function
25
+
26
+ # @param session_dir [String] the session's folder (Session.session_dir)
27
+ # @param text [String] the output, saved byte for byte
28
+ # @param tool [String] the tool's name, the file name's start
29
+ # @return [Saved]
30
+ # @raise [SystemCallError] when it can't be written
31
+ def save(session_dir, text, tool:)
32
+ path = planned(session_dir, text, tool: tool).path
33
+ dir = File.dirname(path)
34
+ FileUtils.mkdir_p(dir)
35
+ # Already there (the same output again): newest now, not rewritten.
36
+ # An empty one (cut short) is written again.
37
+ if File.size?(path)
38
+ FileUtils.touch(path)
39
+ else
40
+ AtomicFile.write(path, text)
41
+ end
42
+ prune(dir)
43
+ planned(session_dir, text, tool: tool)
44
+ end
45
+
46
+ # Where #save would put +text+, and its size, with nothing written: the
47
+ # cut checks its gap line fits before anything is saved.
48
+ # @return [Saved]
49
+ def planned(session_dir, text, tool:)
50
+ path = File.join(session_dir, DIR, "#{file_tool(tool)}-#{Digest::SHA256.hexdigest(text)[0, 12]}.txt")
51
+ Saved.new(path: path, bytes: text.bytesize, lines: line_count(text))
52
+ end
53
+
54
+ # The lines a text has, a last line with no newline counted.
55
+ def line_count(text)
56
+ text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1)
57
+ end
58
+
59
+ # A tool name safe in a file name (a plugin's or an MCP tool's).
60
+ def file_tool(tool)
61
+ name = tool.to_s.downcase.gsub(/[^a-z0-9_-]/, "_")
62
+ name.empty? ? "tool" : name
63
+ end
64
+
65
+ # Keeps the newest MAX_FILES files of +dir+, by mtime.
66
+ def prune(dir)
67
+ files = Dir.glob(File.join(dir, "*.txt"))
68
+ return if files.size <= MAX_FILES
69
+
70
+ files.sort_by { |f| [-File.mtime(f).to_f, f] }.drop(MAX_FILES).each { |f| FileUtils.rm_f(f) }
71
+ end
72
+ end
73
+ end
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # params and label stay where they are: their sources differ per path
24
24
  # (the registry's preview, a plugin's label, the saved shown params).
25
25
  module ToolRowFields
26
- ACTIVITY_KEYS = %i[title called_as].freeze
26
+ ACTIVITY_KEYS = %i[title called_as via].freeze
27
27
  EVENT_KEYS = %i[view ref refs].freeze
28
28
  KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
29
29
 
@@ -33,13 +33,15 @@ module Samagotchi
33
33
  # relative to it when inside it)
34
34
  # @return [Hash] title (a few words: a project-relative path, a command
35
35
  # without its "cd … &&"), called_as (the name the model called it by
36
- # when that was an alias's: bash), view (ToolView: the full command)
36
+ # when that was an alias's: bash), via (the tool it was reached
37
+ # through: call_tool), view (ToolView: the full command)
37
38
  # and ref (FileRef: the file a file tool touched), each only when
38
39
  # there is one
39
40
  def for(tool_name, call, cwd:)
40
41
  {
41
42
  title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
42
43
  called_as: call[:called_as],
44
+ via: call[:via],
43
45
  view: ToolView.for(tool_name, call)&.to_h,
44
46
  ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
45
47
  }.compact
@@ -7,6 +7,9 @@ require_relative "guardrails"
7
7
  require_relative "vision_context"
8
8
  require_relative "log"
9
9
  require_relative "edit_preview"
10
+ require_relative "tool_output_cut"
11
+ require_relative "tool_output_files"
12
+ require_relative "tools/output_guardrails"
10
13
  require_relative "tools/tool_path"
11
14
  require_relative "memory_bundle/index_sync"
12
15
  require_relative "memory_bundle/index_size"
@@ -16,8 +19,8 @@ module Samagotchi
16
19
  # tool_call_completed events, the guardrail gate (before_tool_call hooks
17
20
  # and their veto), dispatch through KernelLoop, the after_tool_call hook
18
21
  # and the output cap. Both loops feed the model `capped_output:` (cut to
19
- # the cap with a "[cut: N of M chars …]" line), the text the event and
20
- # the hook get too.
22
+ # the cap by the tool's ToolOutputCut policy, with a "[cut: …]" line),
23
+ # the text the event and the hook get too.
21
24
  class ToolRunner
22
25
  # More images in one result are left out, each with a line (a tool
23
26
  # that returns many screenshots can't flood the context).
@@ -38,6 +41,7 @@ module Samagotchi
38
41
 
39
42
  # @param call_index [Integer] 1-based position of the call in its batch
40
43
  # @return [Hash] output:, capped_output:, truncated:, activity:,
44
+ # output_file: (the saved whole output of a cut execute result),
41
45
  # images: (refs) when the tool returned images the model gets to see, and
42
46
  # shown_params: the params line the live row showed, and shown_label:
43
47
  # its label ("chrome: screenshot"), only for a tool that isn't built in
@@ -47,6 +51,10 @@ module Samagotchi
47
51
  # file (EditPreview.change; also on tool_call_completed, never in the
48
52
  # model's output)
49
53
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
54
+ # A call_tool of one of chi's own tools runs as that tool, from here
55
+ # on (the gate, the row, the result): rules, approvals and hooks see
56
+ # the real call, with via: (Tools::ToolIndex#unwrap).
57
+ call = unwrapped(call)
50
58
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
51
59
  # The gate runs first, so tool_call_started shows the call that runs.
52
60
  # A tool the session's toolkit leaves out skips it: no before_tool_call
@@ -60,7 +68,7 @@ module Samagotchi
60
68
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
61
69
  tool: call[:name], call: call.dup, params: params }
62
70
  started[:label] = label if label
63
- # Its row fields (ToolRowFields: title, called_as, view, ref, and a
71
+ # Its row fields (ToolRowFields: title, called_as, via, view, ref, and a
64
72
  # plugin tool's refs); the worker runs in its session's working
65
73
  # directory.
66
74
  fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
@@ -105,7 +113,8 @@ module Samagotchi
105
113
  output = "#{changed}\n#{output}"
106
114
  end
107
115
  truncated = !max_tool_output_chars.nil? && output.length > max_tool_output_chars
108
- capped = truncated ? cut(output, max_tool_output_chars) : output
116
+ capped, saved = truncated ? cut(call, output, max_tool_output_chars, result) : output
117
+ output_file = saved.path if saved.is_a?(ToolOutputFiles::Saved)
109
118
 
110
119
  # The call's outcome as its activity line has it (worked out from the
111
120
  # full output: ok, error, blocked, stopped); a dispatcher that raised
@@ -116,6 +125,7 @@ module Samagotchi
116
125
  completed[:images] = images if images&.any?
117
126
  completed[:diff] = diff if diff
118
127
  completed[:waited_ms] = waited_ms if waited_ms
128
+ completed[:output_file] = output_file if output_file
119
129
  # Also here: a UI that missed the start (a replay gap) builds its row
120
130
  # from this event (its title is in the activity).
121
131
  completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
@@ -131,22 +141,50 @@ module Samagotchi
131
141
  run[:shown_label] = label if label
132
142
  run[:refs] = refs unless refs.empty?
133
143
  run[:diff] = diff if diff
144
+ run[:output_file] = output_file if output_file
134
145
  run
135
146
  end
136
147
 
137
148
  private
138
149
 
139
- # +output+ cut to +cap+ chars, the cut line included: the model knows
140
- # what it didn't get. A cap smaller than the line keeps +cap+ chars and
141
- # the line goes past it.
142
- def cut(output, cap)
143
- keep = cap - cut_line(cap, output.length).length
144
- keep = cap unless keep.positive?
145
- "#{output[0, keep]}#{cut_line(keep, output.length)}"
150
+ # +output+ cut to +cap+ by its tool's policy (ToolOutputCut); an
151
+ # execute result keeps its tail too, its whole output saved in the
152
+ # session's folder.
153
+ # @param result [Hash] the dispatch's: its ref_path is the file the
154
+ # whole text is in (a task's log, a child's reply: Tools::RefText),
155
+ # for task_wait and delegate; ref_stderr_path, a task's stderr log
156
+ # @return [Array(String, Object)] the text, and the saved file (or why
157
+ # there is none) when it was cut that way
158
+ def cut(call, output, cap, result)
159
+ ToolOutputCut.apply(output, cap: cap, tool: call[:name], save: ->(text, dry: false) { save_output(call, text, dry: dry) },
160
+ ref: result[:ref_path], ref_stderr: result[:ref_stderr_path],
161
+ head_chars: Tools::OutputGuardrails.config_positive_int("execute.cut_head_chars",
162
+ ToolOutputCut::HEAD_CHARS))
146
163
  end
147
164
 
148
- def cut_line(kept, total)
149
- "\n[cut: #{kept} of #{total} chars; read it in parts]"
165
+ # The whole output in the session's tool_output folder
166
+ # (ToolOutputFiles), or why it isn't saved: no session (a bare kernel)
167
+ # or the error's class. Never fails the call. +dry+: where it would go,
168
+ # nothing written (ToolOutputFiles.planned).
169
+ # @return [ToolOutputFiles::Saved, String]
170
+ def save_output(call, text, dry: false)
171
+ dir = session_dir
172
+ return "no session" if dir.to_s.empty?
173
+ return ToolOutputFiles.planned(dir, text, tool: call[:name]) if dry
174
+
175
+ ToolOutputFiles.save(dir, text, tool: call[:name])
176
+ rescue SystemCallError => e
177
+ Log.warn(:turn, "tool_output_save_failed", tool: call[:name], error: e.class.name)
178
+ e.class.name
179
+ end
180
+
181
+ # The session's folder (KernelLoop#session_dir), nil for a bare kernel
182
+ # or a lookup that failed.
183
+ def session_dir
184
+ @kernel.respond_to?(:session_dir) ? @kernel.session_dir&.call : nil
185
+ rescue StandardError => e
186
+ Log.warn(:turn, "tool_output_save_failed", error: e.class.name)
187
+ nil
150
188
  end
151
189
 
152
190
  # edit/write only: the file just before the call runs. The row diffs it
@@ -268,12 +306,29 @@ module Samagotchi
268
306
  # The kernel's tools, for the activity line of a tool that isn't built in.
269
307
  def tools = @kernel.tools
270
308
 
271
- # A real tool the session's toolkit leaves out of this turn.
309
+ def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
310
+
311
+ # The call its tool's entry unwraps it to (call_tool on a built-in),
312
+ # else the call as it is. One that fails runs as it is: call_tool then
313
+ # refuses a built-in (fail closed).
314
+ def unwrapped(call)
315
+ registry = tools
316
+ return call unless registry.respond_to?(:offered) && !call[:name].nil?
317
+
318
+ registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
319
+ rescue StandardError => e
320
+ Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
321
+ call
322
+ end
323
+
324
+ # A call that can't run in this turn as it is, by its name: a real tool
325
+ # the session's toolkit leaves out, or one reached through call_tool
326
+ # (via:) that the toolkit doesn't reach that way (a hook's rewrite).
272
327
  def left_out?(call)
273
328
  registry = tools
274
329
  return false unless registry.respond_to?(:left_out) && !call[:name].nil?
330
+ return registry.indexed(call[:name], layers: layers).nil? if call[:via]
275
331
 
276
- layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
277
332
  !registry.left_out(call[:name], layers: layers).nil?
278
333
  end
279
334
 
@@ -25,17 +25,19 @@ module Samagotchi
25
25
  # The plain form the snapshot and the web carry.
26
26
  def to_h = { name: name, add: add, remove: remove }
27
27
 
28
- # This toolkit with +tool+ declared (/toolkit add): a tool it removed
29
- # comes out of +remove+ (back to its kit's), any other goes into +add+.
28
+ # This toolkit with +tool+ declared (/toolkit add): out of +remove+
29
+ # and into +add+, whatever its kit says (an add after a remove
30
+ # declares it).
30
31
  def adding(tool)
31
32
  tool = tool.to_s
32
- remove.include?(tool) ? with(remove: remove - [tool]) : with(add: (add + [tool]).uniq)
33
+ with(remove: remove - [tool], add: (add + [tool]).uniq)
33
34
  end
34
35
 
35
- # This toolkit with +tool+ left out (/toolkit remove), the other way.
36
+ # This toolkit with +tool+ removed (/toolkit remove), the other way: a
37
+ # remove is hard (call_tool can't reach it either), also after an add.
36
38
  def removing(tool)
37
39
  tool = tool.to_s
38
- add.include?(tool) ? with(add: add - [tool]) : with(remove: (remove + [tool]).uniq)
40
+ with(add: add - [tool], remove: (remove + [tool]).uniq)
39
41
  end
40
42
 
41
43
  # How the UIs name it: "lean", "lean +web_fetch -memory_write".
@@ -66,21 +68,31 @@ module Samagotchi
66
68
 
67
69
  # A toolkit as a session's next turn would declare it (Engine
68
70
  # #toolkit_explained): +toolkit+ as saved, +resolved+, the Registry
69
- # +declared+ entries and the names it leaves out (+left_out+), and
70
- # whether that isn't what the Registry declares now (+pending+: it
71
+ # +declared+ entries and the names it leaves out (+left_out+): the ones
72
+ # call_tool reaches (+reachable+) and the ones it removes (+removed+);
73
+ # and whether that isn't what the Registry declares now (+pending+: it
71
74
  # applies at the next turn's start).
72
- Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :pending) do
73
- # What the prompt carries of it: the declared names and, when it
74
- # leaves tools out, the kit name the not-loaded line gives. Two equal
75
+ Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :reachable, :removed, :pending) do
76
+ def initialize(toolkit:, resolved:, declared:, left_out:, pending:, reachable: [], removed: [])
77
+ super
78
+ end
79
+
80
+ # What the prompt carries of it: the declared names, the reachable
81
+ # and removed ones (the not-loaded line, find_tools' chi line) and,
82
+ # when either list has any, the kit name the line gives. Two equal
75
83
  # keys build the same prompt (/toolkit's "no change", its cost).
76
- def prompt_key = [declared.map(&:name), left_out.empty? ? nil : resolved.name]
84
+ def prompt_key
85
+ [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
86
+ end
77
87
 
78
88
  # What the UIs show (the snapshot, command_ran): nil when it declares
79
89
  # every tool on full (full, full +delegate), which has no chip.
90
+ # +left_out+ is +reachable+ and +removed+ together.
80
91
  def summary
81
92
  return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
82
93
 
83
- { name: toolkit.name, label: toolkit.label, left_out: left_out, fallback: resolved.fallback }
94
+ { name: toolkit.name, label: toolkit.label, left_out: left_out, reachable: reachable, removed: removed,
95
+ fallback: resolved.fallback }
84
96
  end
85
97
  end
86
98
 
@@ -111,8 +123,9 @@ module Samagotchi
111
123
  Kit.new(name: FULL, tools: :all),
112
124
  Kit.new(name: LEAN, tools: LEAN_TOOLS),
113
125
  # A delegate child: it reports to its parent (send_note; the
114
- # delegated-by line names it) and leaves memory writes to it.
115
- Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write])
126
+ # delegated-by line names it) and leaves memory writes to it, and
127
+ # starts no children of its own (removed: not even through call_tool).
128
+ Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write delegate delegate_result])
116
129
  ].to_h { |kit| [kit.name, kit] }.freeze
117
130
 
118
131
  # What a toolkit declares, the Registry's filter: every built-in