samagotchi 0.25.0 → 0.27.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 +47 -1
- data/docs/architecture.md +6 -0
- data/docs/configuration.md +1 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +33 -7
- data/docs/plugins.md +3 -2
- data/lib/samagotchi/bridge.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cancellation_controller.rb +8 -0
- data/lib/samagotchi/client.rb +53 -3
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/engine.rb +50 -7
- data/lib/samagotchi/kernel_loop.rb +39 -3
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/http.rb +5 -4
- data/lib/samagotchi/log.rb +19 -3
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +4 -3
- data/lib/samagotchi/tool_activity.rb +3 -2
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_view.rb +14 -3
- data/lib/samagotchi/tools/task_get.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +49 -12
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +22 -10
- data/lib/samagotchi/turn_note.rb +28 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +25 -0
- data/lib/samagotchi/web/markdown_renderer.rb +21 -6
- data/lib/samagotchi/web/public/app.js +4 -0
- data/lib/samagotchi/web/public/command_view.js +6 -3
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +39 -13
- data/lib/samagotchi/web/public/stage_model.js +8 -2
- data/lib/samagotchi/web/public/stage_view.js +14 -2
- data/lib/samagotchi/web/public/task_stop.js +101 -0
- data/lib/samagotchi/web/public/turn_view.js +24 -1
- data/lib/samagotchi/worker.rb +3 -0
- metadata +4 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# The turn-end warm-up (cache.warmup). After a turn, the next turn's prompt
|
|
8
|
+
# differs from the last request at its tail: the previous answer's
|
|
9
|
+
# thinking is stripped (Gemma's and Qwen's specs), so a hybrid model on
|
|
10
|
+
# llama.cpp prefills from a restore point well before the end, longer
|
|
11
|
+
# with every turn. The warm-up sends that prompt, up to where the new user
|
|
12
|
+
# turn starts, while the user reads, so the next turn prefills only its
|
|
13
|
+
# own message (measured on Ornith: 0.73 s -> 0.11 s; 417 -> 26 tokens).
|
|
14
|
+
#
|
|
15
|
+
# The slot rule (R4 spike, llama.cpp with its host-memory prompt cache):
|
|
16
|
+
# - the warm-up runs on the slot the turn's last request used, which still
|
|
17
|
+
# holds its state (Client#warm_up, `id_slot`);
|
|
18
|
+
# - the next request is pinned to that slot only while the warm-up still
|
|
19
|
+
# runs, so it queues behind it (0.6 s) instead of starting cold on
|
|
20
|
+
# another slot (12 s);
|
|
21
|
+
# - once the warm-up is done, nothing is pinned: the server finds the
|
|
22
|
+
# state itself, and pinning to a slot it has since cleared skips its
|
|
23
|
+
# prompt cache (12 s again). #take_pin forgets the slot either way.
|
|
24
|
+
class PromptWarmup
|
|
25
|
+
MODES = %w[auto off].freeze
|
|
26
|
+
|
|
27
|
+
# Whether cache.warmup allows a warm-up (auto, the default; off, false,
|
|
28
|
+
# no or 0 turn it off; anything else warns once and is auto).
|
|
29
|
+
def self.enabled?
|
|
30
|
+
value = Config.get("cache.warmup").to_s.strip.downcase
|
|
31
|
+
return false if %w[off false no 0].include?(value)
|
|
32
|
+
|
|
33
|
+
unless value.empty? || value == "auto" || @warned
|
|
34
|
+
@warned = true
|
|
35
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.warmup: #{value.inspect} (allowed: auto, off) — using auto",
|
|
36
|
+
key: "cache.warmup")
|
|
37
|
+
end
|
|
38
|
+
true
|
|
39
|
+
rescue StandardError
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
# The pin (#take_pin forgets it) and the last warm-up's thread.
|
|
46
|
+
@state = nil
|
|
47
|
+
@thread = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Start a warm-up of +prompt+ on +client+ in its own thread.
|
|
51
|
+
# @param slot [Integer, nil] the last request's slot (nil: unpinned, and
|
|
52
|
+
# nothing to pin the next request to)
|
|
53
|
+
# @return [Thread]
|
|
54
|
+
def start(client:, prompt:, model:, slot:, images: [])
|
|
55
|
+
thread = Thread.new do
|
|
56
|
+
Thread.current.report_on_exception = false
|
|
57
|
+
result = client.warm_up(prompt, model: model, slot: slot, images: images)
|
|
58
|
+
Log.info(:model, "warmup", slot: result&.slot, cached: result&.cache_n, prefilled: result&.prompt_n,
|
|
59
|
+
prefill_ms: result&.prompt_ms, chars: prompt.length)
|
|
60
|
+
result
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Log.warn(:model, "warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@thread = thread
|
|
67
|
+
@state = { thread: thread, client: client, slot: slot }
|
|
68
|
+
end
|
|
69
|
+
thread
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The slot the next request on +client+ must run on: the warm-up's,
|
|
73
|
+
# while it still runs there; nil otherwise (done, failed, unpinned, or
|
|
74
|
+
# another host). Called once per request; the first call forgets the
|
|
75
|
+
# warm-up, so no later request is ever pinned.
|
|
76
|
+
def take_pin(client)
|
|
77
|
+
state = @mutex.synchronize { @state.tap { @state = nil } }
|
|
78
|
+
return nil unless state && state[:slot].is_a?(Integer) && state[:client].equal?(client)
|
|
79
|
+
|
|
80
|
+
state[:thread].alive? ? state[:slot] : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether a warm-up is running (specs, measurements).
|
|
84
|
+
def running?
|
|
85
|
+
thread = @mutex.synchronize { @thread }
|
|
86
|
+
thread&.alive? || false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Wait up to +timeout+ seconds for the running warm-up (specs).
|
|
90
|
+
def wait(timeout = nil)
|
|
91
|
+
thread = @mutex.synchronize { @thread }
|
|
92
|
+
thread&.join(timeout)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -173,6 +173,8 @@ module Samagotchi
|
|
|
173
173
|
# -p without --non-interactive drops into the REPL, which can answer
|
|
174
174
|
# an approval; --non-interactive can't, so an approval there denies.
|
|
175
175
|
@engine.interface = @non_interactive ? :non_interactive : :repl
|
|
176
|
+
# A --non-interactive run exits after its turn: no turn-end warm-up.
|
|
177
|
+
@engine.next_turn_waiting = -> { true } if @non_interactive
|
|
176
178
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
177
179
|
# A recap written while a continue offer waits says the turn stopped
|
|
178
180
|
# unfinished. (Answering at the prompt is typing: activity already.)
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
class ThinkingTails
|
|
24
24
|
FILE = "thinking_tails.jsonl"
|
|
25
25
|
# The last chars of a generation's thinking kept: a loop's cycle and
|
|
26
|
-
# some of its lead-in.
|
|
26
|
+
# some of its lead-in. TurnNote cuts an empty step's thinking to it too.
|
|
27
27
|
TAIL_CHARS = 20_000
|
|
28
28
|
MAX_RECORDS = 10
|
|
29
29
|
|
|
@@ -55,8 +55,9 @@ module Samagotchi
|
|
|
55
55
|
when :generation_chunk then add(event[:thinking].to_s)
|
|
56
56
|
when :generation_completed then completed(event)
|
|
57
57
|
when :generation_cancelled
|
|
58
|
-
# A plugin's stop_turn mid-generation
|
|
59
|
-
|
|
58
|
+
# A plugin's stop_turn mid-generation (the bundle that stopped it,
|
|
59
|
+
# "hook" when unknown); a user's Stop is not kept.
|
|
60
|
+
keep(event, finish_reason: "cancelled", stopped_by: (event[:stopped_by] || "hook").to_s) if event[:reason].to_s == "hook"
|
|
60
61
|
end
|
|
61
62
|
rescue StandardError
|
|
62
63
|
nil
|
|
@@ -150,12 +150,13 @@ module Samagotchi
|
|
|
150
150
|
# "error" for an "Error:" result, and for an execute whose command
|
|
151
151
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
152
152
|
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
153
|
-
# task_wait the user's Stop ended
|
|
153
|
+
# task_wait the user's Stop ended (the task itself runs on), and for one
|
|
154
|
+
# whose task was stopped (the stop-task button, or the model's task_stop).
|
|
154
155
|
def tool_activity_status(result, tool_name = nil)
|
|
155
156
|
text = result.to_s
|
|
156
157
|
return "error" if text.start_with?("Error:")
|
|
157
158
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
158
|
-
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
|
|
159
|
+
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
159
160
|
|
|
160
161
|
"ok"
|
|
161
162
|
end
|
|
@@ -28,7 +28,7 @@ module Samagotchi
|
|
|
28
28
|
},
|
|
29
29
|
description: {
|
|
30
30
|
type: "string",
|
|
31
|
-
description: "
|
|
31
|
+
description: "What this command does, in a few words (what, not why). Shown to the user as the step's title, so include it on every call."
|
|
32
32
|
},
|
|
33
33
|
cwd: {
|
|
34
34
|
type: "string",
|
data/lib/samagotchi/tool_view.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "command_steps"
|
|
4
4
|
require_relative "tools/execute"
|
|
5
5
|
require_relative "tools/task_create"
|
|
6
|
+
require_relative "tools/task_wait"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# What a richer UI shows of a tool call beyond its one-line params: the
|
|
@@ -18,8 +19,9 @@ module Samagotchi
|
|
|
18
19
|
# can't (the fallback: the UI shows the command itself) or the command
|
|
19
20
|
# is cut. +description+: what the model said the command does (execute's
|
|
20
21
|
# optional description), whitespace collapsed, a trailing period gone;
|
|
21
|
-
# nil when it gave none.
|
|
22
|
-
|
|
22
|
+
# nil when it gave none. +task_id+: the task a task_wait waits on (its
|
|
23
|
+
# only field), for the web's stop-task button.
|
|
24
|
+
ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description, :task_id) do
|
|
23
25
|
def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -37,6 +39,7 @@ module Samagotchi
|
|
|
37
39
|
# @return [ToolView, nil] nil for a tool without a view and for an
|
|
38
40
|
# empty command
|
|
39
41
|
def self.for(tool_name, call)
|
|
42
|
+
return task_wait(call) if tool_name == Tools::TaskWait::NAME && call.is_a?(Hash)
|
|
40
43
|
return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
|
|
41
44
|
|
|
42
45
|
command = call[:content].to_s
|
|
@@ -47,9 +50,17 @@ module Samagotchi
|
|
|
47
50
|
parsed = truncated ? nil : CommandSteps.parse(command)
|
|
48
51
|
new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
|
|
49
52
|
truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
|
|
50
|
-
description: description(call))
|
|
53
|
+
description: description(call), task_id: nil)
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
def self.task_wait(call)
|
|
57
|
+
task_id = call[:content].to_s.strip
|
|
58
|
+
return nil if task_id.empty?
|
|
59
|
+
|
|
60
|
+
new(command: nil, cwd: nil, truncated: false, chars: nil, cd: nil, steps: nil, description: nil, task_id: task_id)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :task_wait
|
|
63
|
+
|
|
53
64
|
# The call's description as a view shows it, nil for none.
|
|
54
65
|
def self.description(call)
|
|
55
66
|
text = call[:description]
|
|
@@ -30,8 +30,9 @@ module Samagotchi
|
|
|
30
30
|
"finished_at: #{record["finished_at"]}",
|
|
31
31
|
"exit_code: #{record["exit_code"]}",
|
|
32
32
|
"stop_reason: #{record["stop_reason"]}",
|
|
33
|
-
"output_path: #{record.fetch("output_path")}"
|
|
34
|
-
|
|
33
|
+
"output_path: #{record.fetch("output_path")}",
|
|
34
|
+
(TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
|
|
35
|
+
].compact.join("\n")
|
|
35
36
|
end
|
|
36
37
|
private_class_method :format_record
|
|
37
38
|
end
|
|
@@ -22,6 +22,9 @@ module Samagotchi
|
|
|
22
22
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
23
23
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
24
24
|
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
25
|
+
# task_wait and task_get add it to a task the user stopped (the web's
|
|
26
|
+
# stop-task button), so the model doesn't rerun the job.
|
|
27
|
+
USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
|
|
25
28
|
|
|
26
29
|
module_function
|
|
27
30
|
|
|
@@ -107,19 +110,27 @@ module Samagotchi
|
|
|
107
110
|
# A native tool_response joins its calls' results with "---".
|
|
108
111
|
# @return [Array<Hash>] {id:, command:}, oldest first
|
|
109
112
|
def running_created_in(messages)
|
|
110
|
-
|
|
113
|
+
created_ids_in(messages).filter_map do |id|
|
|
114
|
+
record, _error = get_record(id)
|
|
115
|
+
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The ids of every task this conversation's task_create calls started,
|
|
120
|
+
# whatever their status now, oldest first.
|
|
121
|
+
# @return [Array<String>]
|
|
122
|
+
def created_ids_in(messages)
|
|
123
|
+
Array(messages).flat_map do |message|
|
|
111
124
|
next [] unless (message[:role] || message["role"]).to_s == "tool_response"
|
|
112
125
|
|
|
113
126
|
(message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
|
|
114
127
|
block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
|
|
115
128
|
end
|
|
116
|
-
end
|
|
117
|
-
ids.uniq.filter_map do |id|
|
|
118
|
-
record, _error = get_record(id)
|
|
119
|
-
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
120
|
-
end
|
|
129
|
+
end.uniq
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
|
|
133
|
+
|
|
123
134
|
def get_record(task_id)
|
|
124
135
|
record = load_record(task_id)
|
|
125
136
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
@@ -142,13 +153,19 @@ module Samagotchi
|
|
|
142
153
|
content.lines.last(line_count).join
|
|
143
154
|
end
|
|
144
155
|
|
|
145
|
-
|
|
156
|
+
# A requested stop ends as "stopped" whatever the exit code: the flag
|
|
157
|
+
# is on disk before the TERM, so a reader refreshing the record while
|
|
158
|
+
# the group dies records the stop, not a failure.
|
|
159
|
+
# @param by [String] "model" (task_stop) or "user" (the web's button)
|
|
160
|
+
def stop_task(task_id, by:)
|
|
146
161
|
record = load_record(task_id)
|
|
147
162
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
148
163
|
|
|
149
164
|
refreshed = refresh_record(record)
|
|
150
165
|
return [refreshed, nil] unless refreshed["status"] == "running"
|
|
151
166
|
|
|
167
|
+
refreshed["stop_requested_by"] = by.to_s
|
|
168
|
+
write_record(refreshed)
|
|
152
169
|
pid = refreshed["pid"].to_i
|
|
153
170
|
|
|
154
171
|
begin
|
|
@@ -168,10 +185,7 @@ module Samagotchi
|
|
|
168
185
|
end
|
|
169
186
|
end
|
|
170
187
|
|
|
171
|
-
updated = refresh_record(refreshed)
|
|
172
|
-
updated["status"] = "stopped" if updated["status"] == "running"
|
|
173
|
-
updated["finished_at"] ||= timestamp
|
|
174
|
-
updated["stop_reason"] = "stopped_by_user"
|
|
188
|
+
updated = mark_stopped(refresh_record(refreshed))
|
|
175
189
|
write_record(updated)
|
|
176
190
|
|
|
177
191
|
[updated, nil]
|
|
@@ -294,9 +308,15 @@ module Samagotchi
|
|
|
294
308
|
end
|
|
295
309
|
|
|
296
310
|
def mark_finished_without_exit_code(record)
|
|
311
|
+
record = current_record(record)
|
|
312
|
+
return record unless record["status"] == "running"
|
|
313
|
+
|
|
297
314
|
exit_code = read_exit_code(record["exit_code_path"])
|
|
298
315
|
|
|
299
|
-
if
|
|
316
|
+
if record["stop_requested_by"]
|
|
317
|
+
record["exit_code"] = exit_code
|
|
318
|
+
mark_stopped(record)
|
|
319
|
+
elsif exit_code.nil?
|
|
300
320
|
record["status"] = "failed"
|
|
301
321
|
record["exit_code"] = nil
|
|
302
322
|
record["stop_reason"] ||= "process_ended_without_exit_code"
|
|
@@ -313,6 +333,23 @@ module Samagotchi
|
|
|
313
333
|
record
|
|
314
334
|
end
|
|
315
335
|
|
|
336
|
+
# The disk copy when it is newer than +record+: already finished, or
|
|
337
|
+
# flagged by a stop that started after +record+ was read.
|
|
338
|
+
def current_record(record)
|
|
339
|
+
disk = load_record(record.fetch("id"))
|
|
340
|
+
return record unless disk
|
|
341
|
+
return disk if disk["status"] != "running" || disk["stop_requested_by"]
|
|
342
|
+
|
|
343
|
+
record
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def mark_stopped(record)
|
|
347
|
+
record["status"] = "stopped"
|
|
348
|
+
record["stop_reason"] = "stopped_by_#{record["stop_requested_by"]}"
|
|
349
|
+
record["finished_at"] ||= timestamp
|
|
350
|
+
record
|
|
351
|
+
end
|
|
352
|
+
|
|
316
353
|
def read_exit_code(path)
|
|
317
354
|
return nil if path.to_s.empty? || !File.exist?(path)
|
|
318
355
|
|
|
@@ -37,10 +37,10 @@ module Samagotchi
|
|
|
37
37
|
return "Error: #{error}" if error
|
|
38
38
|
|
|
39
39
|
status = record.fetch("status")
|
|
40
|
-
return
|
|
40
|
+
return finished_response(record, tail_lines) unless status == "running"
|
|
41
41
|
|
|
42
42
|
if completion_pattern
|
|
43
|
-
output_tail =
|
|
43
|
+
output_tail = output_tail(record, tail_lines)
|
|
44
44
|
return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -53,10 +53,9 @@ module Samagotchi
|
|
|
53
53
|
|
|
54
54
|
# Timeout reached — do one final refresh to capture the latest status
|
|
55
55
|
refreshed, = TaskRuntime.get_record(task_id)
|
|
56
|
-
return
|
|
56
|
+
return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
|
|
58
|
+
format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
|
|
60
59
|
rescue StandardError => e
|
|
61
60
|
"Error: #{e.message}"
|
|
62
61
|
end
|
|
@@ -66,18 +65,31 @@ module Samagotchi
|
|
|
66
65
|
def self.cancelled_response(task_id, tail_lines, started)
|
|
67
66
|
record, error = TaskRuntime.get_record(task_id)
|
|
68
67
|
return "Error: #{error}" if error
|
|
69
|
-
return
|
|
68
|
+
return finished_response(record, tail_lines) unless record.fetch("status") == "running"
|
|
70
69
|
|
|
71
|
-
output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
72
70
|
note = [
|
|
73
71
|
"note: the user stopped the turn while waiting; the task is STILL RUNNING.",
|
|
74
72
|
" To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
|
|
75
73
|
"waited: #{(monotonic_time - started).round}s"
|
|
76
74
|
]
|
|
77
|
-
format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
|
|
75
|
+
format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
|
|
78
76
|
end
|
|
79
77
|
private_class_method :cancelled_response
|
|
80
78
|
|
|
79
|
+
# A task the user stopped also says so, with the tail of its output:
|
|
80
|
+
# the model sees where the job was cut off.
|
|
81
|
+
def self.finished_response(record, tail_lines)
|
|
82
|
+
return format_response(record) unless TaskRuntime.stopped_by_user?(record)
|
|
83
|
+
|
|
84
|
+
format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
|
|
85
|
+
end
|
|
86
|
+
private_class_method :finished_response
|
|
87
|
+
|
|
88
|
+
def self.output_tail(record, tail_lines)
|
|
89
|
+
TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
90
|
+
end
|
|
91
|
+
private_class_method :output_tail
|
|
92
|
+
|
|
81
93
|
def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
|
|
82
94
|
lines = [
|
|
83
95
|
"task_id: #{record.fetch("id")}",
|
|
@@ -86,9 +98,9 @@ module Samagotchi
|
|
|
86
98
|
"stop_reason: #{record["stop_reason"]}",
|
|
87
99
|
"output_path: #{record.fetch("output_path")}"
|
|
88
100
|
]
|
|
89
|
-
return lines.join("\n") unless
|
|
101
|
+
return lines.join("\n") unless output_tail
|
|
90
102
|
|
|
91
|
-
lines << "wait_result: #{wait_result}"
|
|
103
|
+
lines << "wait_result: #{wait_result}" if wait_result
|
|
92
104
|
lines.concat(extra)
|
|
93
105
|
lines << "output_tail:"
|
|
94
106
|
lines << output_tail.rstrip unless output_tail.empty?
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
require_relative "thinking_tails"
|
|
5
|
+
|
|
3
6
|
module Samagotchi
|
|
4
7
|
# The one-line system note a turn leaves in the conversation when it ends
|
|
5
8
|
# without an answer: failed before the model replied, cancelled, or over
|
|
@@ -19,6 +22,13 @@ module Samagotchi
|
|
|
19
22
|
TASK_COMMAND_CHARS = 60
|
|
20
23
|
RETRY_NUDGE = :retry_nudge
|
|
21
24
|
EMPTY_ANSWER = :empty_answer
|
|
25
|
+
# An empty step's thinking kept on the marker: its tail, so a model
|
|
26
|
+
# that thought for minutes doesn't put megabytes in the session file.
|
|
27
|
+
# The same cap as ThinkingTails' saved tails.
|
|
28
|
+
STEP_CHARS = ThinkingTails::TAIL_CHARS
|
|
29
|
+
# A step's native thinking starts with one of these; the cut keeps it,
|
|
30
|
+
# so the UIs still find the thinking (MessageParts).
|
|
31
|
+
THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
|
|
22
32
|
|
|
23
33
|
module_function
|
|
24
34
|
|
|
@@ -75,16 +85,32 @@ module Samagotchi
|
|
|
75
85
|
# UIs, which draw the turn's notice and steps from it after a reload:
|
|
76
86
|
# +retries+ the empty-answer retries spent, +steps+ the empty
|
|
77
87
|
# generations as model messages (their thinking; the loops keep them out
|
|
78
|
-
# of the conversation, where they'd be empty assistant turns)
|
|
88
|
+
# of the conversation, where they'd be empty assistant turns), each
|
|
89
|
+
# text cut to its last STEP_CHARS. Never
|
|
79
90
|
# sent: the payload builders send a system message's content, and the
|
|
80
91
|
# copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
|
|
81
92
|
def empty(retries: 0, steps: [])
|
|
82
93
|
marker = { retries: retries.to_i }
|
|
83
|
-
marker[:steps] = steps.map { |step| step
|
|
94
|
+
marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
|
|
84
95
|
message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
|
|
85
96
|
.merge(EMPTY_ANSWER => marker)
|
|
86
97
|
end
|
|
87
98
|
|
|
99
|
+
def capped_step(step)
|
|
100
|
+
step.slice(:role, :content, :thinking).to_h do |key, value|
|
|
101
|
+
[key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The last STEP_CHARS of +text+ after a line saying how much was cut
|
|
106
|
+
# (and the thinking's opening tag when it had one); short text as is.
|
|
107
|
+
def tail_of(text)
|
|
108
|
+
return text if text.length <= STEP_CHARS
|
|
109
|
+
|
|
110
|
+
open = THINK_OPENS.find { |tag| text.start_with?(tag) }
|
|
111
|
+
"#{open}[… #{text.length - STEP_CHARS} earlier characters cut]\n#{text[-STEP_CHARS..]}"
|
|
112
|
+
end
|
|
113
|
+
|
|
88
114
|
# The empty-answer marker of +entry+ (either key type), or nil.
|
|
89
115
|
def empty_answer(entry)
|
|
90
116
|
return nil unless note?(entry)
|
|
@@ -133,6 +133,12 @@ module Samagotchi
|
|
|
133
133
|
true
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# @return [Boolean] whether #steer_next_turn queued anything a turn has
|
|
137
|
+
# yet to begin with
|
|
138
|
+
def carried_steers?
|
|
139
|
+
@lock.synchronize { !@carried_steers.empty? }
|
|
140
|
+
end
|
|
141
|
+
|
|
136
142
|
# Drop what #steer_next_turn queued and no turn began with (a no-op
|
|
137
143
|
# once #begin! took it).
|
|
138
144
|
# @return [Array<Hash>] the steers dropped ({text:, source:})
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -139,6 +139,7 @@ module Samagotchi
|
|
|
139
139
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
|
|
140
140
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
141
141
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
142
|
+
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
|
|
142
143
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
143
144
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
144
145
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
@@ -1019,6 +1020,30 @@ module Samagotchi
|
|
|
1019
1020
|
error_response(404, "not_found", e.message)
|
|
1020
1021
|
end
|
|
1021
1022
|
|
|
1023
|
+
# A task id as TaskRuntime.generate_task_id makes it.
|
|
1024
|
+
TASK_ID = /\A\d{14}-\h{8}\z/
|
|
1025
|
+
|
|
1026
|
+
# The stop-task button: the worker stops the task as the user
|
|
1027
|
+
# (Bridge#handle_task_stop) and its task_wait returns. Its own 404
|
|
1028
|
+
# (not this conversation's task) and 409 (already ended) pass through;
|
|
1029
|
+
# a 404 from a worker without the route is the stale worker's 501.
|
|
1030
|
+
def handle_task_stop(_req, id, task_id)
|
|
1031
|
+
return error_response(400, "invalid_task_id", "not a task id: #{task_id.to_s[0, 80]}") unless TASK_ID.match?(task_id)
|
|
1032
|
+
|
|
1033
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
1034
|
+
request = ->(client) { client.stop_task(task_id) }
|
|
1035
|
+
relay(id, bridge_client(id), request, what: "the task was not stopped", cant: "stop tasks") do |reply|
|
|
1036
|
+
body = reply.json || {}
|
|
1037
|
+
case reply.status
|
|
1038
|
+
when 200 then json_response(200, { status: body["status"], stop_reason: body["stop_reason"], task_id: task_id, session_id: id })
|
|
1039
|
+
when 404 then (error_response(404, "task_not_found", "no task #{task_id} in this conversation") if body["error"] == "task_not_found")
|
|
1040
|
+
when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
rescue ArgumentError => e
|
|
1044
|
+
error_response(404, "not_found", e.message)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1022
1047
|
def handle_stop(_req, id)
|
|
1023
1048
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1024
1049
|
# after it just means an immediate resume finds it (rare).
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
class MarkdownRenderer
|
|
9
9
|
WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
|
|
10
10
|
# NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
|
|
11
|
-
# emits
|
|
11
|
+
# emits classed <span>s; keeping it lets highlighting render.
|
|
12
12
|
ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
|
|
13
13
|
DROP_ELEMENTS = %w[iframe object script style svg].freeze
|
|
14
14
|
ALLOWED_PROTOCOLS = %w[http https mailto].freeze
|
|
@@ -35,6 +35,11 @@ module Samagotchi
|
|
|
35
35
|
html = Commonmarker.to_html(markdown.to_s, options: {
|
|
36
36
|
render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
|
|
37
37
|
extension: { header_ids: nil }
|
|
38
|
+
}, plugins: {
|
|
39
|
+
# An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
|
|
40
|
+
# instead of inline colours from one fixed theme, so the page's light and
|
|
41
|
+
# dark palettes colour code blocks (the --code-* tokens in index.html).
|
|
42
|
+
syntax_highlighter: { theme: "" }
|
|
38
43
|
})
|
|
39
44
|
return nil unless html
|
|
40
45
|
|
|
@@ -74,6 +79,7 @@ module Samagotchi
|
|
|
74
79
|
|
|
75
80
|
node.remove_attribute(attribute.name)
|
|
76
81
|
end
|
|
82
|
+
prefix_highlight_classes(node) if node.name == "span"
|
|
77
83
|
return unless node.name == "a"
|
|
78
84
|
|
|
79
85
|
href = node["href"].to_s.strip
|
|
@@ -85,16 +91,25 @@ module Samagotchi
|
|
|
85
91
|
node["rel"] = "noopener noreferrer"
|
|
86
92
|
end
|
|
87
93
|
|
|
88
|
-
# The syntax highlighter emits
|
|
89
|
-
#
|
|
90
|
-
# markdown source is already escaped by commonmarker
|
|
91
|
-
# attributes are safe to keep on these elements only.
|
|
94
|
+
# The syntax highlighter emits `class=` on <pre> ("syntax-highlighting") and
|
|
95
|
+
# <span> (syntect scope names). They come from the highlighter, not user input:
|
|
96
|
+
# raw HTML from the markdown source is already escaped by commonmarker
|
|
97
|
+
# (escape: true), so class attributes are safe to keep on these elements only.
|
|
92
98
|
def allowed_attribute?(node, name)
|
|
93
|
-
return true if name == "
|
|
99
|
+
return true if name == "class" && %w[span pre].include?(node.name)
|
|
94
100
|
|
|
95
101
|
node.name == "a" && %w[href title].include?(name)
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
# Scope names are plain words ("diff", "meta", "name", ...) that page CSS and
|
|
105
|
+
# querySelectors use too: prefix them so they only ever match the hl-* rules.
|
|
106
|
+
def prefix_highlight_classes(node)
|
|
107
|
+
words = node["class"].to_s.split
|
|
108
|
+
return node.remove_attribute("class") if words.empty?
|
|
109
|
+
|
|
110
|
+
node["class"] = words.map { |word| "hl-#{word}" }.join(" ")
|
|
111
|
+
end
|
|
112
|
+
|
|
98
113
|
def safe_href?(href)
|
|
99
114
|
return true if href.start_with?("#", "/")
|
|
100
115
|
|
|
@@ -23,6 +23,7 @@ import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, t
|
|
|
23
23
|
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
24
|
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
25
|
import { createStageView } from "./stage_view.js";
|
|
26
|
+
import { createTaskStopper } from "./task_stop.js";
|
|
26
27
|
import { appendQuote } from "./annotations.js";
|
|
27
28
|
import { parsePresets } from "./annotate_presets.js";
|
|
28
29
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
@@ -86,6 +87,9 @@ const viewArgs = {
|
|
|
86
87
|
isNearBottom: () => isNearBottom(),
|
|
87
88
|
sawText: (k) => seenContent.add(k),
|
|
88
89
|
sessionId: () => selected,
|
|
90
|
+
// The stop-task button on a running task_wait, for a worker that has
|
|
91
|
+
// the route (its sidecar features).
|
|
92
|
+
taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
|
|
89
93
|
};
|
|
90
94
|
const turnDom = view === "stage"
|
|
91
95
|
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
@@ -20,8 +20,9 @@ export function rowHover(row) {
|
|
|
20
20
|
//
|
|
21
21
|
// A view with steps (CommandSteps) shows them in the command's place, its
|
|
22
22
|
// leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
|
|
23
|
-
// only, so a live and a reloaded row behave alike
|
|
24
|
-
//
|
|
23
|
+
// only, so a live and a reloaded row behave alike; beside the first step
|
|
24
|
+
// when there's no "in" tag) that swaps the steps for the command as
|
|
25
|
+
// written. The copy button copies the raw command
|
|
25
26
|
// either way. A view without steps (the parser's fallback) is the raw
|
|
26
27
|
// block alone: nothing hides the command.
|
|
27
28
|
//
|
|
@@ -47,7 +48,9 @@ export function commandBlockHtml(view, title = null) {
|
|
|
47
48
|
const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
|
|
48
49
|
return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
// No "in" tag: the toggle alone, floated beside the first step (an
|
|
52
|
+
// empty line for it would sit above the steps).
|
|
53
|
+
const head = (where ? `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` : `<div class="activity-command-head bare">`) +
|
|
51
54
|
`<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
|
|
52
55
|
return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
|
|
53
56
|
}
|
|
@@ -123,6 +123,17 @@ export function stopSession(id, opts = {}) {
|
|
|
123
123
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Kills a background task the session's conversation started (the
|
|
127
|
+
// stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
|
|
128
|
+
// no live worker.
|
|
129
|
+
export function stopTask(id, taskId, opts = {}) {
|
|
130
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}/stop`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: JSON.stringify({}),
|
|
133
|
+
...opts,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
// Hands the session to a new worker on the newest chi installed: 200
|
|
127
138
|
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
128
139
|
export function restartSession(id, opts = {}) {
|