samagotchi 0.69.0 → 0.71.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 +73 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +4 -2
- data/docs/configuration.md +6 -5
- data/docs/context.md +15 -6
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/engine.md +10 -5
- data/docs/internals/tool-guardrails.md +61 -1
- data/docs/plugins.md +24 -19
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +2 -1
- data/docs/testing.md +3 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -2
- 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 +1 -1
- data/lib/samagotchi/config.rb +6 -2
- data/lib/samagotchi/context_absorber.rb +35 -21
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +10 -0
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +1 -0
- 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/sessions_command.rb +12 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -1
- data/lib/samagotchi/tool_activity.rb +19 -1
- data/lib/samagotchi/tool_output_cut.rb +321 -0
- data/lib/samagotchi/tool_output_files.rb +73 -0
- data/lib/samagotchi/tool_runner.rb +46 -12
- data/lib/samagotchi/toolkit.rb +9 -2
- 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 +13 -2
- data/lib/samagotchi/tools/task_wait.rb +8 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -7
- data/lib/samagotchi/web/message_parts.rb +5 -3
- data/lib/samagotchi/web/public/answer_refs.js +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/turn_html.js +55 -3
- data/lib/samagotchi/worker.rb +2 -2
- data/lib/samagotchi/worker_inbound.rb +3 -0
- metadata +4 -1
|
@@ -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
|
|
@@ -109,7 +113,8 @@ module Samagotchi
|
|
|
109
113
|
output = "#{changed}\n#{output}"
|
|
110
114
|
end
|
|
111
115
|
truncated = !max_tool_output_chars.nil? && output.length > max_tool_output_chars
|
|
112
|
-
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)
|
|
113
118
|
|
|
114
119
|
# The call's outcome as its activity line has it (worked out from the
|
|
115
120
|
# full output: ok, error, blocked, stopped); a dispatcher that raised
|
|
@@ -120,6 +125,7 @@ module Samagotchi
|
|
|
120
125
|
completed[:images] = images if images&.any?
|
|
121
126
|
completed[:diff] = diff if diff
|
|
122
127
|
completed[:waited_ms] = waited_ms if waited_ms
|
|
128
|
+
completed[:output_file] = output_file if output_file
|
|
123
129
|
# Also here: a UI that missed the start (a replay gap) builds its row
|
|
124
130
|
# from this event (its title is in the activity).
|
|
125
131
|
completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
|
|
@@ -135,22 +141,50 @@ module Samagotchi
|
|
|
135
141
|
run[:shown_label] = label if label
|
|
136
142
|
run[:refs] = refs unless refs.empty?
|
|
137
143
|
run[:diff] = diff if diff
|
|
144
|
+
run[:output_file] = output_file if output_file
|
|
138
145
|
run
|
|
139
146
|
end
|
|
140
147
|
|
|
141
148
|
private
|
|
142
149
|
|
|
143
|
-
# +output+ cut to +cap+
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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))
|
|
150
163
|
end
|
|
151
164
|
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
154
188
|
end
|
|
155
189
|
|
|
156
190
|
# edit/write only: the file just before the call runs. The row diffs it
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -230,13 +230,17 @@ module Samagotchi
|
|
|
230
230
|
base.changed(add: kit.add, remove: kit.remove).with(name: name)
|
|
231
231
|
end
|
|
232
232
|
|
|
233
|
+
# The keys a config kit entry takes.
|
|
234
|
+
KIT_KEYS = %w[base add remove].freeze
|
|
235
|
+
|
|
233
236
|
# The kits known: the built-ins, then config's toolkit.kits, then the
|
|
234
237
|
# plugins' (chi.toolkit). A config kit with a built-in's name is left
|
|
235
238
|
# out (one it can't redefine) with a line in +errors+, and so is one
|
|
236
|
-
# that isn't a {base:, add:, remove:} map.
|
|
239
|
+
# that isn't a {base:, add:, remove:} map. One with another key (a
|
|
240
|
+
# typo, a tools: list) still loads, with a line naming the key.
|
|
237
241
|
# @param config [Hash, nil] toolkit.kits
|
|
238
242
|
# @param plugins [Array<Kit>]
|
|
239
|
-
# @param errors [Array<String>] gets a line per kit left out
|
|
243
|
+
# @param errors [Array<String>] gets a line per kit left out or unknown key
|
|
240
244
|
# @return [Hash{String => Kit}]
|
|
241
245
|
def self.kits(config: nil, plugins: [], errors: [])
|
|
242
246
|
kits = BUILTIN_KITS.dup
|
|
@@ -248,6 +252,9 @@ module Samagotchi
|
|
|
248
252
|
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
|
|
249
253
|
else
|
|
250
254
|
spec = spec.transform_keys(&:to_s)
|
|
255
|
+
(spec.keys - KIT_KEYS).each do |key|
|
|
256
|
+
errors << "toolkit.kits.#{name}: unknown key #{key} (a kit takes base:, add:, remove:)"
|
|
257
|
+
end
|
|
251
258
|
kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
|
|
252
259
|
source: "config")
|
|
253
260
|
end
|
|
@@ -66,7 +66,10 @@ module Samagotchi
|
|
|
66
66
|
return child_id if child_id.start_with?("Error:")
|
|
67
67
|
|
|
68
68
|
note = warning ? "Warning: #{warning}\n" : ""
|
|
69
|
-
|
|
69
|
+
if wait
|
|
70
|
+
result = DelegateWait.call(child_id, peers: peers, timeout: timeout)
|
|
71
|
+
return RefText.wrap(note + result, result.respond_to?(:ref_path) ? result.ref_path : nil)
|
|
72
|
+
end
|
|
70
73
|
|
|
71
74
|
started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
|
|
72
75
|
"#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
|
|
@@ -7,6 +7,7 @@ require_relative "output_guardrails"
|
|
|
7
7
|
require_relative "peers"
|
|
8
8
|
require_relative "delegate_relay"
|
|
9
9
|
require_relative "delegate_cursor"
|
|
10
|
+
require_relative "ref_text"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# Loaded on first use: child_reports requires this file (a circular
|
|
@@ -78,7 +79,8 @@ module Samagotchi
|
|
|
78
79
|
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
79
80
|
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
80
81
|
advance(peers.session_id, child_id, wait, state_dir: sd)
|
|
81
|
-
|
|
82
|
+
text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
|
|
83
|
+
return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
|
|
82
84
|
end
|
|
83
85
|
|
|
84
86
|
outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
|
|
@@ -104,6 +106,15 @@ module Samagotchi
|
|
|
104
106
|
})
|
|
105
107
|
end
|
|
106
108
|
|
|
109
|
+
# The child's reply file a :done wait read (its output/ file), which
|
|
110
|
+
# ToolRunner's cut of a long reply names (ToolOutputCut's
|
|
111
|
+
# :head_tail_ref); nil for any other outcome.
|
|
112
|
+
def reply_path(child_id, wait, state_dir:)
|
|
113
|
+
return nil unless wait.status == :done && wait.file
|
|
114
|
+
|
|
115
|
+
File.join(ReplyWait.reply_dir(child_id, state_dir: state_dir), wait.file)
|
|
116
|
+
end
|
|
117
|
+
|
|
107
118
|
# The relayed approvals' outcome lines, after the status line.
|
|
108
119
|
def with_outcomes(text, outcomes)
|
|
109
120
|
return text if outcomes.empty?
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# A tool's text that shows part of a file kept elsewhere in whole: a
|
|
6
|
+
# task's log (task_wait's output tail), a child's reply file (delegate,
|
|
7
|
+
# delegate_result). It is the text itself (a String), like
|
|
8
|
+
# Plugin::ToolResult; #ref_path is the file, which ToolRunner's cut
|
|
9
|
+
# names when the text is longer than max_tool_output_chars
|
|
10
|
+
# (ToolOutputCut's :head_tail_ref), instead of saving a copy;
|
|
11
|
+
# #stderr_path, a handed-over execute's stderr log, the cut names too.
|
|
12
|
+
class RefText < String
|
|
13
|
+
attr_reader :ref_path, :stderr_path
|
|
14
|
+
|
|
15
|
+
def initialize(text, ref_path, stderr_path: nil)
|
|
16
|
+
@ref_path = ref_path
|
|
17
|
+
@stderr_path = stderr_path.to_s.empty? ? nil : stderr_path
|
|
18
|
+
super(text.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# +text+ as a RefText of +ref_path+; +text+ as it is with no path.
|
|
22
|
+
def self.wrap(text, ref_path, stderr_path: nil)
|
|
23
|
+
ref_path.to_s.empty? ? text : new(text, ref_path, stderr_path: stderr_path)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -179,13 +179,24 @@ module Samagotchi
|
|
|
179
179
|
return true if @index.sourced
|
|
180
180
|
return false if toolkit&.removed&.include?(@index.call_name)
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
index_reaches?(toolkit)
|
|
183
183
|
end
|
|
184
184
|
|
|
185
|
+
# Whether the index's tools would reach something under +toolkit+,
|
|
186
|
+
# removed or not: a source registered at load, or a built-in the
|
|
187
|
+
# toolkit leaves out without removing it.
|
|
188
|
+
def index_reaches?(toolkit)
|
|
189
|
+
@index.sourced || @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Not declared by +toolkit+ under +layers+. An index tool counts only
|
|
193
|
+
# when the toolkit removes it and it would reach something (full with
|
|
194
|
+
# --no-tool call_tool and no MCP server leaves nothing out).
|
|
185
195
|
def left_out?(entry, layers, toolkit)
|
|
186
196
|
return false unless entry.offered?(layers) && !declared?(entry, toolkit)
|
|
197
|
+
return true unless index_entry?(entry)
|
|
187
198
|
|
|
188
|
-
|
|
199
|
+
toolkit&.removed&.include?(entry.name) == true && index_reaches?(toolkit)
|
|
189
200
|
end
|
|
190
201
|
|
|
191
202
|
# A built-in +toolkit+ leaves out of a turn under +layers+ without
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "task_runtime"
|
|
4
|
+
require_relative "ref_text"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
module Tools
|
|
@@ -79,13 +80,11 @@ module Samagotchi
|
|
|
79
80
|
end
|
|
80
81
|
private_class_method :cancelled_response
|
|
81
82
|
|
|
82
|
-
#
|
|
83
|
-
# the
|
|
83
|
+
# Always give the log's last lines: a test run's summary, an error. A
|
|
84
|
+
# task the user stopped also says so.
|
|
84
85
|
def self.finished_response(record, tail_lines)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
|
|
88
|
-
extra: [TaskRuntime::USER_STOP_NOTE])
|
|
86
|
+
extra = record.stopped_by_user? ? [TaskRuntime::USER_STOP_NOTE] : []
|
|
87
|
+
format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines, extra: extra)
|
|
89
88
|
end
|
|
90
89
|
private_class_method :finished_response
|
|
91
90
|
|
|
@@ -121,7 +120,9 @@ module Samagotchi
|
|
|
121
120
|
lines << output_tail.rstrip unless output_tail.empty?
|
|
122
121
|
stderr_tail = stderr_tail(record, tail_lines)
|
|
123
122
|
lines.concat(["stderr_tail:", stderr_tail.rstrip]) unless stderr_tail.empty?
|
|
124
|
-
|
|
123
|
+
# The whole log is at output_path (stderr at stderr_path): a cut of
|
|
124
|
+
# this text names them.
|
|
125
|
+
RefText.wrap(lines.join("\n"), record.output_path, stderr_path: record.stderr_path)
|
|
125
126
|
end
|
|
126
127
|
private_class_method :format_response
|
|
127
128
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -896,9 +896,9 @@ module Samagotchi
|
|
|
896
896
|
# session part is what the page reads of it (id, status, used memory
|
|
897
897
|
# names, the model notes its prompt carried: the first turn's build or
|
|
898
898
|
# a /model records them). ?turn_id=: that turn's answer (a queued
|
|
899
|
-
# turn's page re-reads after the next one started).
|
|
900
|
-
#
|
|
901
|
-
#
|
|
899
|
+
# turn's page re-reads after the next one started). The timing's records
|
|
900
|
+
# are the newest turn's (#recent_timing), which the page merges by id
|
|
901
|
+
# (the page still sends ?recent=1, which is accepted and not needed).
|
|
902
902
|
def handle_tail_read(req, id)
|
|
903
903
|
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
904
904
|
|
|
@@ -919,9 +919,7 @@ module Samagotchi
|
|
|
919
919
|
end
|
|
920
920
|
session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
|
|
921
921
|
live_metrics = state && state["metrics"]
|
|
922
|
-
|
|
923
|
-
# timing with the reply's). Remove this branch after the next release.
|
|
924
|
-
timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
|
|
922
|
+
timing = recent_timing(id, live_metrics)
|
|
925
923
|
json_response(200, {
|
|
926
924
|
tail: true,
|
|
927
925
|
session: session_json,
|
|
@@ -1703,7 +1701,7 @@ module Samagotchi
|
|
|
1703
1701
|
turn_count: (live && turn_count(live_metrics)) || turns.size)
|
|
1704
1702
|
end
|
|
1705
1703
|
|
|
1706
|
-
# The timing a ?tail=1
|
|
1704
|
+
# The timing a ?tail=1 read sends: a live worker's recent
|
|
1707
1705
|
# records as its metrics hold them (the newest finished turn's and
|
|
1708
1706
|
# the unsaved ones, the running turn's finished calls), no
|
|
1709
1707
|
# analytics.json read; with no worker the disk's, trimmed to the
|
|
@@ -10,6 +10,7 @@ require_relative "../image_store"
|
|
|
10
10
|
require_relative "../llm_context_edit"
|
|
11
11
|
require_relative "../token_usage"
|
|
12
12
|
require_relative "../row_refs"
|
|
13
|
+
require_relative "../tool_output_cut"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
module Web
|
|
@@ -50,8 +51,9 @@ module Samagotchi
|
|
|
50
51
|
# so an output that holds the joiner itself stays whole.
|
|
51
52
|
JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
|
|
52
53
|
# The row shows 300 chars and the rest on hover; a read of a big file
|
|
53
|
-
# would otherwise ride along with every reload.
|
|
54
|
-
|
|
54
|
+
# would otherwise ride along with every reload. A longer one keeps its
|
|
55
|
+
# head and tail (ToolOutputCut.preview).
|
|
56
|
+
OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
|
|
55
57
|
|
|
56
58
|
QWEN_THINK = %r{<think>(.*?)</think>}m
|
|
57
59
|
QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
|
|
@@ -246,7 +248,7 @@ module Samagotchi
|
|
|
246
248
|
part.merge!(ToolRowFields.for(name, call, cwd: cwd))
|
|
247
249
|
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
248
250
|
unless output.nil?
|
|
249
|
-
part[:output] = output
|
|
251
|
+
part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
|
|
250
252
|
part[:output_truncated] = true if output.length > OUTPUT_MAX
|
|
251
253
|
end
|
|
252
254
|
shots = Array(images).select { |image| image.is_a?(Hash) }
|
|
@@ -67,6 +67,7 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
|
|
|
67
67
|
if (typeof message.html === "string") {
|
|
68
68
|
bubble.classList.add("markdown");
|
|
69
69
|
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
70
|
+
// The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
|
|
70
71
|
bubble.innerHTML = message.html;
|
|
71
72
|
}
|
|
72
73
|
setAnswerRefs(bubble, message.display_refs);
|
|
@@ -162,6 +162,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
162
162
|
.activity-alias{color:var(--text-faint);white-space:nowrap}
|
|
163
163
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
164
164
|
.activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
|
|
165
|
+
.activity-output.tail{display:flex;flex-direction:column-reverse}
|
|
165
166
|
/* A row's status and tool name never wrap (a phone broke them letter by
|
|
166
167
|
letter); the params take what is left. The output goes on its own line
|
|
167
168
|
under the call instead of a column beside it: on a desktop it lines up
|
|
@@ -11,6 +11,7 @@ import { activityDiffTemplate } from "./diff_view.js";
|
|
|
11
11
|
import { clip, steerSender } from "./format.js";
|
|
12
12
|
import { rowTitleRef } from "./refs.js";
|
|
13
13
|
import { readLinesTemplate } from "./read_view.js";
|
|
14
|
+
import { handedOverId } from "./task_stop.js";
|
|
14
15
|
import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
|
|
15
16
|
import { emptyAnswerLine } from "./turn_events.js";
|
|
16
17
|
|
|
@@ -73,12 +74,63 @@ function llmEditTemplate(row) {
|
|
|
73
74
|
return html`<div class="activity-edit" title=${hover}>${line}</div>`;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// What a done row shows of its output inline: its first INLINE_CHARS, else
|
|
78
|
+
// for an execute's result and a task_wait with its log's last lines its
|
|
79
|
+
// last ones (a test run's summary, a failing exit line). An execute drops a
|
|
80
|
+
// bare success "exit: 0" (the mark says it) and, with no stderr block, the
|
|
81
|
+
// lone "stdout:" label. A guardrail deny, an approval refused or an
|
|
82
|
+
// execute's error has its reason first, a hand-over its task_id: the head.
|
|
83
|
+
// A gate-corrected call's `ran as:` line shows on hover only.
|
|
84
|
+
const INLINE_CHARS = 300;
|
|
85
|
+
// The JS twin of ToolOutputCut::EXIT_LINE (tool_output_cut.rb): an exit line, then maybe a hint; a
|
|
86
|
+
// signal-killed command's has no number ("exit: ").
|
|
87
|
+
const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
|
|
88
|
+
// A bare success exit line; group 1 is the hint line after it, if any.
|
|
89
|
+
const SUCCESS_EXIT_LINE = /\nexit: 0(\n\([^\n]*\))?$/;
|
|
90
|
+
const EXECUTE_BLOCK = /(?:^|\n)(?:stdout|stderr):\n/;
|
|
91
|
+
const OUTPUT_TAIL = /^output_tail:$/m;
|
|
92
|
+
// A gate-corrected call's first line (ToolRunner#changed_call_line) and the "[tool]" lead after it, which
|
|
93
|
+
// the row's lead strip misses (it isn't first). The inline text drops both; the hover keeps them.
|
|
94
|
+
const RAN_AS_LEAD = /^ran as: [^\n]*\n(?:\[[^\]\s]+\](?:\n| ))?/;
|
|
95
|
+
|
|
96
|
+
export function inlineOutput(tool, full) {
|
|
97
|
+
const text = String(full ?? "").replace(RAN_AS_LEAD, "");
|
|
98
|
+
if (!tailMode(tool, text)) return clip(text, INLINE_CHARS + 1);
|
|
99
|
+
let shown = text;
|
|
100
|
+
if (tool === "execute") {
|
|
101
|
+
shown = shown.replace(SUCCESS_EXIT_LINE, (_, hint) => hint || "");
|
|
102
|
+
if (!text.includes("\nstderr:\n") && shown.startsWith("stdout:\n")) shown = shown.slice("stdout:\n".length);
|
|
103
|
+
}
|
|
104
|
+
shown = shown.replace(/\n+$/, "");
|
|
105
|
+
return tailClip(shown.trim() ? shown : text);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Whether a row's output shows its end (its div opens scrolled to the end).
|
|
109
|
+
export function tailMode(tool, text) {
|
|
110
|
+
if (tool === "task_wait") return OUTPUT_TAIL.test(text);
|
|
111
|
+
if (tool !== "execute" || handedOverId(text)) return false;
|
|
112
|
+
return EXECUTE_BLOCK.test(text) || EXIT_LINE.test(text);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The text's end, as clip keeps its start: whole when it fits, else "…" and
|
|
116
|
+
// its last INLINE_CHARS, never from half an emoji, from a line's start when
|
|
117
|
+
// that keeps at least half of them (ToolOutputCut.cut_block's tail rule).
|
|
118
|
+
function tailClip(text) {
|
|
119
|
+
if (text.length <= INLINE_CHARS + 1) return text;
|
|
120
|
+
let kept = text.slice(-INLINE_CHARS);
|
|
121
|
+
const first = kept.charCodeAt(0);
|
|
122
|
+
if (first >= 0xdc00 && first <= 0xdfff) kept = kept.slice(1);
|
|
123
|
+
const nl = kept.indexOf("\n");
|
|
124
|
+
if (nl >= 0 && kept.length - nl - 1 >= INLINE_CHARS / 2) kept = kept.slice(nl + 1);
|
|
125
|
+
return `\u2026${kept}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
76
128
|
// A tool row: its status mark, tool, title (else params; the full command,
|
|
77
129
|
// else the full params line, on hover), the word an exception shows, the
|
|
78
130
|
// call's duration, the full command (an execute/task_create's view, also
|
|
79
131
|
// while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
|
|
80
|
-
// the rest on hover; a read
|
|
81
|
-
// closed: read_view.js), its images (+thumbs+(images) draws them) and an
|
|
132
|
+
// an execute/task_wait's last ones (inlineOutput), the rest on hover; a read
|
|
133
|
+
// of the file as-is also its numbered lines, closed: read_view.js), its images (+thumbs+(images) draws them) and an
|
|
82
134
|
// edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
|
|
83
135
|
// part with nothing to say is left out. A reload renders toolRowTemplate;
|
|
84
136
|
// the live view renders toolRowInnerTemplate into its own row element
|
|
@@ -107,7 +159,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
|
|
|
107
159
|
const duration = formatDuration(row.duration_ms);
|
|
108
160
|
const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
109
161
|
const output = full
|
|
110
|
-
? html`<div class
|
|
162
|
+
? html`<div class=${tailMode(row.tool, full) ? "activity-output tail" : "activity-output"} title=${full}>${inlineOutput(row.tool, full)}</div>`
|
|
111
163
|
: nothing;
|
|
112
164
|
const status = statusParts(row.status, { noMatch: !!row.no_match });
|
|
113
165
|
return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -496,13 +496,13 @@ module Samagotchi
|
|
|
496
496
|
# turn from context:<name> whose first message is the wake note already
|
|
497
497
|
# in the conversation (named by +turn_id+), on the same rules as a
|
|
498
498
|
# delegate report's wake turn: it counts toward session.max_wakes (a
|
|
499
|
-
# peer's answer toward context.peer_turns too), and a failure goes back
|
|
499
|
+
# peer's answer toward context.peer_turns too, unless it agrees), and a failure goes back
|
|
500
500
|
# to before the turn (the note stays, as a plain one:
|
|
501
501
|
# WorkerInbound#unmark_wake) and pauses wakes until a human's input.
|
|
502
502
|
# +context_wake+: a WorkerInbound::ContextWake.
|
|
503
503
|
def run_context_wake_turn(context_wake)
|
|
504
504
|
name = context_wake.name
|
|
505
|
-
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.
|
|
505
|
+
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer_turn?))
|
|
506
506
|
@turn_flow.before_prompt_turn
|
|
507
507
|
run_engine_turn(nil, continue: true, origin: context_wake.origin,
|
|
508
508
|
id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
|
|
@@ -47,6 +47,9 @@ module Samagotchi
|
|
|
47
47
|
def name = delivery.name
|
|
48
48
|
# For a peer agent's answer (its own budget, WorkerWakes#peer_open?).
|
|
49
49
|
def peer? = !delivery.peer.nil?
|
|
50
|
+
# Whether the wake counts toward context.peer_turns: a peer's answer
|
|
51
|
+
# that agrees closes the round and counts toward session.max_wakes only.
|
|
52
|
+
def peer_turn? = peer? && !delivery.agreed
|
|
50
53
|
def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
|
|
51
54
|
end
|
|
52
55
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.71.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -504,6 +504,8 @@ files:
|
|
|
504
504
|
- lib/samagotchi/tool_call_parser.rb
|
|
505
505
|
- lib/samagotchi/tool_declarations.rb
|
|
506
506
|
- lib/samagotchi/tool_ids.rb
|
|
507
|
+
- lib/samagotchi/tool_output_cut.rb
|
|
508
|
+
- lib/samagotchi/tool_output_files.rb
|
|
507
509
|
- lib/samagotchi/tool_response.rb
|
|
508
510
|
- lib/samagotchi/tool_row_fields.rb
|
|
509
511
|
- lib/samagotchi/tool_runner.rb
|
|
@@ -531,6 +533,7 @@ files:
|
|
|
531
533
|
- lib/samagotchi/tools/output_guardrails.rb
|
|
532
534
|
- lib/samagotchi/tools/peers.rb
|
|
533
535
|
- lib/samagotchi/tools/read.rb
|
|
536
|
+
- lib/samagotchi/tools/ref_text.rb
|
|
534
537
|
- lib/samagotchi/tools/register_reminder.rb
|
|
535
538
|
- lib/samagotchi/tools/registry.rb
|
|
536
539
|
- lib/samagotchi/tools/send_note.rb
|