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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +6 -1
- data/docs/configuration.md +31 -8
- data/docs/context.md +15 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +5 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +25 -13
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/internals/tool-guardrails.md +61 -1
- data/docs/plugins.md +66 -38
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +5 -5
- data/docs/sub-agent.md +2 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +6 -21
- data/lib/samagotchi/context_absorber.rb +35 -21
- data/lib/samagotchi/engine.rb +7 -2
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +34 -7
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +8 -2
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/peer_sources.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +35 -16
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_output_cut.rb +312 -0
- data/lib/samagotchi/tool_output_files.rb +73 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +70 -15
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +12 -1
- data/lib/samagotchi/tools/ref_text.rb +27 -0
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/task_wait.rb +4 -1
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -9
- data/lib/samagotchi/web/message_parts.rb +9 -4
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +123 -22
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/model_picker.js +21 -6
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/worker.rb +2 -2
- data/lib/samagotchi/worker_inbound.rb +3 -0
- 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),
|
|
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:
|
|
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+
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -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):
|
|
29
|
-
#
|
|
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
|
-
|
|
33
|
+
with(remove: remove - [tool], add: (add + [tool]).uniq)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
# This toolkit with +tool+
|
|
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
|
-
|
|
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+)
|
|
70
|
-
#
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|