samagotchi 0.29.0 → 0.31.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 +99 -1
- data/docs/cli.md +4 -4
- data/docs/configuration.md +4 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +11 -4
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/child_reports.rb +214 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +63 -11
- data/lib/samagotchi/llm/chat_loop.rb +14 -7
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +31 -5
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +41 -5
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/steer.rb +39 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
- data/lib/samagotchi/terminal_ui.rb +44 -5
- data/lib/samagotchi/tool_activity.rb +6 -2
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +7 -7
- data/lib/samagotchi/tools/delegate.rb +26 -13
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +21 -11
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +14 -6
- data/lib/samagotchi/web/public/notify.js +4 -2
- data/lib/samagotchi/web/public/stage_model.js +18 -0
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +29 -9
- data/lib/samagotchi/web/public/turn_model.js +16 -11
- data/lib/samagotchi/web/public/turn_view.js +49 -7
- data/lib/samagotchi/worker.rb +240 -23
- metadata +4 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../served_model"
|
|
4
4
|
require_relative "../image_store"
|
|
5
5
|
require_relative "../turn_note"
|
|
6
|
+
require_relative "../steer"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
class TerminalUI
|
|
@@ -43,7 +44,8 @@ module Samagotchi
|
|
|
43
44
|
# Who a steer names as its sender: a plugin's own label, chi's sender
|
|
44
45
|
# ids in words (the web's format.js steerSender agrees;
|
|
45
46
|
# spec/shared/labels_matrix.json). "": no one named.
|
|
46
|
-
STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin"
|
|
47
|
+
STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin",
|
|
48
|
+
"delegate_report" => "delegate report" }.freeze
|
|
47
49
|
|
|
48
50
|
def steer_sender(source)
|
|
49
51
|
STEER_SENDERS.fetch(source.to_s, source.to_s)
|
|
@@ -59,9 +61,13 @@ module Samagotchi
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
|
|
62
|
-
# "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it
|
|
64
|
+
# "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it;
|
|
65
|
+
# "↻ malformed answer, …" after a corrupt native generation.
|
|
63
66
|
def format_empty_retry_line(event)
|
|
64
|
-
what = event[:stopped_by]
|
|
67
|
+
what = if event[:stopped_by] then "cut by #{event[:stopped_by]}"
|
|
68
|
+
elsif event[:malformed] then "malformed answer"
|
|
69
|
+
else "empty answer"
|
|
70
|
+
end
|
|
65
71
|
paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
66
72
|
end
|
|
67
73
|
|
|
@@ -109,7 +115,8 @@ module Samagotchi
|
|
|
109
115
|
|
|
110
116
|
# Prompt labels by the sender's client_id prefix (turn_events.js
|
|
111
117
|
# CLIENT_LABELS; spec/shared/labels_matrix.json).
|
|
112
|
-
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate"
|
|
118
|
+
CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate",
|
|
119
|
+
"child" => "delegate report" }.freeze
|
|
113
120
|
|
|
114
121
|
# "web> <prompt>": a prompt, labelled by who sent it.
|
|
115
122
|
def prompt_line(client_id, prompt)
|
|
@@ -117,6 +124,26 @@ module Samagotchi
|
|
|
117
124
|
"#{paint("#{label}>", 35)} #{prompt}"
|
|
118
125
|
end
|
|
119
126
|
|
|
127
|
+
# "delegate report> 3f2a1c9e answered: <the reply's first line>": a
|
|
128
|
+
# delegate child's report (ChildReports) merged into the turn.
|
|
129
|
+
def report_line(report)
|
|
130
|
+
id = report[/\Asession: (\S+)/, 1].to_s[0, 8]
|
|
131
|
+
status = report[/^status: (.*)$/, 1]
|
|
132
|
+
rest = report.split("\n").drop(2).reject { |line| line.strip.empty? || line == "---" }.first.to_s
|
|
133
|
+
rest = "#{rest[0, STEER_PREVIEW - 1]}…" if rest.length > STEER_PREVIEW
|
|
134
|
+
"#{paint("#{CLIENT_LABELS["child"]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The last prompt as a join shows it: a delegate report (a wake
|
|
138
|
+
# turn's) as the child's lines, anything else as the user's.
|
|
139
|
+
def join_prompt_line(message)
|
|
140
|
+
source = (message[:source] || message["source"]).to_s
|
|
141
|
+
content = (message[:content] || message["content"]).to_s
|
|
142
|
+
return prompt_line(nil, content) unless source == Steer::DELEGATE_REPORT
|
|
143
|
+
|
|
144
|
+
content.split(/\n\n(?=session: )/).map { |report| report_line(report) }.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
120
147
|
# "reminder: a, b": the reminders a turn runs for (names or hashes).
|
|
121
148
|
def reminder_line(reminders)
|
|
122
149
|
names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
|
|
@@ -226,19 +253,22 @@ module Samagotchi
|
|
|
226
253
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
227
254
|
# spec/shared/labels_matrix.json pins it for both.
|
|
228
255
|
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
256
|
+
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
257
|
+
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
258
|
+
STOP_MARK = "■"
|
|
229
259
|
# Under a canceled prompt turn (a canceled continue is back where it
|
|
230
260
|
# started): its partial progress stays in the conversation.
|
|
231
261
|
ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
|
|
232
262
|
|
|
233
|
-
# "
|
|
234
|
-
# (+by+, turn_canceled's cancelled_by): "
|
|
263
|
+
# "■ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
|
|
264
|
+
# (+by+, turn_canceled's cancelled_by): "■ turn stopped by loop-guard"
|
|
235
265
|
def turn_canceled_line(reason, duration_ms, by: nil)
|
|
236
266
|
if reason.to_s == "hook" && !by.to_s.empty?
|
|
237
|
-
return "#{paint(
|
|
267
|
+
return "#{paint(STOP_MARK, 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
|
|
238
268
|
end
|
|
239
269
|
|
|
240
270
|
label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
|
|
241
|
-
"#{paint(
|
|
271
|
+
"#{paint(STOP_MARK, 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
|
|
242
272
|
end
|
|
243
273
|
|
|
244
274
|
# "✕ turn failed: <summary> · 2.0s"
|
|
@@ -16,6 +16,7 @@ require_relative "host_registry"
|
|
|
16
16
|
require_relative "session"
|
|
17
17
|
require_relative "owner_lock"
|
|
18
18
|
require_relative "bridge/card_store"
|
|
19
|
+
require_relative "bridge/pending_card"
|
|
19
20
|
require_relative "engine"
|
|
20
21
|
require_relative "guardrails/parent_approvals"
|
|
21
22
|
require_relative "tools/memory"
|
|
@@ -253,6 +254,7 @@ module Samagotchi
|
|
|
253
254
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
254
255
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
255
256
|
sink = OneShotSink.new
|
|
257
|
+
save_before_first_turn(session)
|
|
256
258
|
begin
|
|
257
259
|
result = @engine.run_turn(
|
|
258
260
|
session,
|
|
@@ -298,7 +300,10 @@ module Samagotchi
|
|
|
298
300
|
ensure
|
|
299
301
|
# However it ends (the early returns too; the Engine was built in
|
|
300
302
|
# #initialize): the anytime commands finish, the plugins' services
|
|
301
|
-
# stop (a server process).
|
|
303
|
+
# stop (a server process). Nothing resolves an open card any more, so
|
|
304
|
+
# its pending_card.json goes (a scratch session's folder is deleted
|
|
305
|
+
# below anyway; the others stay).
|
|
306
|
+
clear_pending_card
|
|
302
307
|
@engine&.shutdown
|
|
303
308
|
# A scratch session goes on every way out: /exit, Ctrl-D, an error,
|
|
304
309
|
# SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
|
|
@@ -308,6 +313,17 @@ module Samagotchi
|
|
|
308
313
|
|
|
309
314
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
310
315
|
|
|
316
|
+
# The session is over for this process (its exits and the ensure below):
|
|
317
|
+
# nothing will resolve the open card any more, so its pending_card.json
|
|
318
|
+
# goes (the Engine's TURN_END would clear it, but the REPL may end
|
|
319
|
+
# mid-turn).
|
|
320
|
+
def clear_pending_card
|
|
321
|
+
return unless @pending_card_handle
|
|
322
|
+
|
|
323
|
+
@pending_card_handle.observer.clear
|
|
324
|
+
@pending_card_handle = nil
|
|
325
|
+
end
|
|
326
|
+
|
|
311
327
|
# A -p --non-interactive turn that failed (bin/chi exits 1): the error,
|
|
312
328
|
# then how to go on. The Engine saved the failed turn with its prompt,
|
|
313
329
|
# so the session is kept and `chi --resume` takes it up again.
|
|
@@ -415,13 +431,28 @@ module Samagotchi
|
|
|
415
431
|
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
416
432
|
end
|
|
417
433
|
|
|
418
|
-
# The session's cards.json, kept as a worker's
|
|
419
|
-
# the session as a worker would): this REPL's
|
|
420
|
-
# the saved count, so a card an earlier worker
|
|
421
|
-
# turns back, and its own cards and notices are
|
|
434
|
+
# The session's cards.json and pending_card.json, kept as a worker's
|
|
435
|
+
# Bridge keeps them (it owns the session as a worker would): this REPL's
|
|
436
|
+
# kept turns count on from the saved count, so a card an earlier worker
|
|
437
|
+
# showed stays that many turns back, and its own cards and notices are
|
|
438
|
+
# saved for the next; an open card with actions is on disk, where
|
|
439
|
+
# `chi web`'s session hub reads it to mark the session as waiting.
|
|
422
440
|
def keep_cards(session)
|
|
423
441
|
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
424
442
|
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
443
|
+
keep_pending_card(session)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# A card a worker that died left isn't open: the file goes before the
|
|
447
|
+
# observer takes over, as Bridge#start does. Subscribed once: the REPL
|
|
448
|
+
# keeps one session, and a second observer would fold every card twice
|
|
449
|
+
# and clear the file at the first turn's end.
|
|
450
|
+
def keep_pending_card(session)
|
|
451
|
+
return if @pending_card_handle
|
|
452
|
+
|
|
453
|
+
pending = Bridge::PendingCard.new(Session.session_dir(session.id))
|
|
454
|
+
pending.clear
|
|
455
|
+
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
425
456
|
end
|
|
426
457
|
|
|
427
458
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
@@ -552,6 +583,7 @@ module Samagotchi
|
|
|
552
583
|
refresh_status_row
|
|
553
584
|
# A prompt the user typed brings an archived session back to the lists.
|
|
554
585
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
586
|
+
save_before_first_turn(session)
|
|
555
587
|
with_steering do
|
|
556
588
|
@engine.run_turn(
|
|
557
589
|
session,
|
|
@@ -779,6 +811,13 @@ module Samagotchi
|
|
|
779
811
|
session.save
|
|
780
812
|
end
|
|
781
813
|
|
|
814
|
+
# A fresh session reaches the disk before its first turn: a tool in that
|
|
815
|
+
# turn reads it from there (delegate loads its parent to start a child,
|
|
816
|
+
# "Session not found" otherwise). Later turns find it saved already.
|
|
817
|
+
def save_before_first_turn(session)
|
|
818
|
+
session.save unless Session.exist?(session.id)
|
|
819
|
+
end
|
|
820
|
+
|
|
782
821
|
# A !cmd shows its own output; everything else is the model> line.
|
|
783
822
|
def show_command_result(result)
|
|
784
823
|
if result.shell
|
|
@@ -176,10 +176,14 @@ module Samagotchi
|
|
|
176
176
|
# "error" for an "Error:" result, and for an execute whose command
|
|
177
177
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
178
178
|
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
179
|
-
# task_wait the user's Stop ended (the task itself runs on),
|
|
180
|
-
# whose task was stopped (the stop-task button, or the model's task_stop)
|
|
179
|
+
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
180
|
+
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
181
|
+
# and for a command the user's Stop killed or kept from starting: a
|
|
182
|
+
# cancel, not a failure (its text still starts "Error:" for the model).
|
|
181
183
|
def tool_activity_status(result, tool_name = nil)
|
|
182
184
|
text = result.to_s
|
|
185
|
+
return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
|
|
186
|
+
return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
|
|
183
187
|
return "error" if text.start_with?("Error:")
|
|
184
188
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
185
189
|
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
@@ -40,6 +40,8 @@ module Samagotchi
|
|
|
40
40
|
TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
|
|
41
41
|
THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
|
|
42
42
|
THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
|
|
43
|
+
# Any channel's header (a thought's is THOUGHT_CHANNEL_OPEN).
|
|
44
|
+
THOUGHT_CHANNEL_HEADER = "<|channel>"
|
|
43
45
|
CONTROL_TOKEN_START = "<|"
|
|
44
46
|
|
|
45
47
|
def initialize(profile)
|
|
@@ -120,8 +122,43 @@ module Samagotchi
|
|
|
120
122
|
[parse(response.to_s), nil]
|
|
121
123
|
end
|
|
122
124
|
|
|
125
|
+
# Why +text+ (one generation) is corrupt, or nil. A Gemma 4
|
|
126
|
+
# generation ends a tool call with its close (the stop then comes at
|
|
127
|
+
# <|tool_response>), and thinks once, before the answer. A poisoned
|
|
128
|
+
# llama.cpp prompt cache (ggml-org/llama.cpp#27148) broke both: a
|
|
129
|
+
# call left open, then a fresh thought header and another
|
|
130
|
+
# conversation's answer.
|
|
131
|
+
def malformed(text)
|
|
132
|
+
text = text.to_s
|
|
133
|
+
return "unclosed tool call" if unclosed_tool_call?(text)
|
|
134
|
+
|
|
135
|
+
"thought header after the answer" if thought_after_answer?(strip_tool_calls(text))
|
|
136
|
+
end
|
|
137
|
+
|
|
123
138
|
private
|
|
124
139
|
|
|
140
|
+
def unclosed_tool_call?(text)
|
|
141
|
+
pos = 0
|
|
142
|
+
while (open_pos = text.index(@tool_call_open, pos))
|
|
143
|
+
close_pos = text.index(@tool_call_close, open_pos + @tool_call_open.length)
|
|
144
|
+
return true unless close_pos
|
|
145
|
+
|
|
146
|
+
pos = close_pos + @tool_call_close.length
|
|
147
|
+
end
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A <|channel> header with visible text before it.
|
|
152
|
+
def thought_after_answer?(text)
|
|
153
|
+
pos = 0
|
|
154
|
+
while (header = text.index(THOUGHT_CHANNEL_HEADER, pos))
|
|
155
|
+
return true unless strip_thought(text[0...header]).strip.empty?
|
|
156
|
+
|
|
157
|
+
pos = header + THOUGHT_CHANNEL_HEADER.length
|
|
158
|
+
end
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
125
162
|
# Scan forward from +start+ for the next <| sequence that is NOT the Gemma
|
|
126
163
|
# string delimiter <|"|>. Returns the position of that <| or nil if none.
|
|
127
164
|
def next_real_control_token(text, start)
|
|
@@ -307,6 +344,9 @@ module Samagotchi
|
|
|
307
344
|
read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args]) }
|
|
308
345
|
end
|
|
309
346
|
|
|
347
|
+
# An open <tool_call> has its own recovery (#parse_with_recovery).
|
|
348
|
+
def malformed(_text) = nil
|
|
349
|
+
|
|
310
350
|
# Each <tool_call> block as {name:, args:}: its <parameter=…>
|
|
311
351
|
# (or <arg_key>/<arg_value>) text by lowercased key, unbuilt.
|
|
312
352
|
def read(text)
|
|
@@ -179,13 +179,13 @@ module Samagotchi
|
|
|
179
179
|
},
|
|
180
180
|
{
|
|
181
181
|
name: "task_get",
|
|
182
|
-
description: "Get
|
|
182
|
+
description: "Get a background task's metadata by its task id (from task_create or task_list; not a session id). Use read on output_path to inspect command output.",
|
|
183
183
|
parameters: {
|
|
184
184
|
type: "object",
|
|
185
185
|
properties: {
|
|
186
186
|
id: {
|
|
187
187
|
type: "string",
|
|
188
|
-
description: "Task id
|
|
188
|
+
description: "Task id from task_create/task_list, e.g. 20261005093000-1a2b3c4d"
|
|
189
189
|
}
|
|
190
190
|
},
|
|
191
191
|
required: ["id"]
|
|
@@ -201,13 +201,13 @@ module Samagotchi
|
|
|
201
201
|
},
|
|
202
202
|
{
|
|
203
203
|
name: "task_stop",
|
|
204
|
-
description: "Stop a running background task by id.",
|
|
204
|
+
description: "Stop a running background task by its task id (from task_create or task_list). Session ids don't work here.",
|
|
205
205
|
parameters: {
|
|
206
206
|
type: "object",
|
|
207
207
|
properties: {
|
|
208
208
|
id: {
|
|
209
209
|
type: "string",
|
|
210
|
-
description: "Task id
|
|
210
|
+
description: "Task id from task_create/task_list, e.g. 20261005093000-1a2b3c4d"
|
|
211
211
|
}
|
|
212
212
|
},
|
|
213
213
|
required: ["id"]
|
|
@@ -221,7 +221,7 @@ module Samagotchi
|
|
|
221
221
|
properties: {
|
|
222
222
|
task_id: {
|
|
223
223
|
type: "string",
|
|
224
|
-
description: "Task id
|
|
224
|
+
description: "Task id from task_create/task_list, e.g. 20261005093000-1a2b3c4d"
|
|
225
225
|
},
|
|
226
226
|
timeout: {
|
|
227
227
|
type: "integer",
|
|
@@ -330,7 +330,7 @@ module Samagotchi
|
|
|
330
330
|
},
|
|
331
331
|
{
|
|
332
332
|
name: "delegate",
|
|
333
|
-
description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled
|
|
333
|
+
description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled. With wait false, chi brings each child's reply to you by itself when the child ends its turn (a delegate report, also when you are idle): don't poll it with delegate_result. To retry or redirect a child, use delegate with its session (task_* tools are for background commands, not sessions). A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
|
|
334
334
|
parameters: {
|
|
335
335
|
type: "object",
|
|
336
336
|
properties: {
|
|
@@ -360,7 +360,7 @@ module Samagotchi
|
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
362
|
name: "delegate_result",
|
|
363
|
-
description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
|
|
363
|
+
description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child (name the child when a delegate report is about it). A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
|
|
364
364
|
parameters: {
|
|
365
365
|
type: "object",
|
|
366
366
|
properties: {
|
|
@@ -5,6 +5,7 @@ require_relative "../config"
|
|
|
5
5
|
require_relative "../model_profile"
|
|
6
6
|
require_relative "peers"
|
|
7
7
|
require_relative "delegate_wait"
|
|
8
|
+
require_relative "../child_reports"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -30,7 +31,7 @@ module Samagotchi
|
|
|
30
31
|
STARTING_GRACE_SECONDS = 15
|
|
31
32
|
# The memory every child starts with, scoped so a project memory of
|
|
32
33
|
# the same name cannot shadow it.
|
|
33
|
-
CHILD_MEMORIES = [
|
|
34
|
+
CHILD_MEMORIES = [Session::DELEGATE_MEMORY].freeze
|
|
34
35
|
|
|
35
36
|
def self.name = NAME
|
|
36
37
|
|
|
@@ -51,21 +52,31 @@ module Samagotchi
|
|
|
51
52
|
wait = parse_wait(wait)
|
|
52
53
|
timeout = parse_timeout(timeout)
|
|
53
54
|
|
|
54
|
-
child_id = if session.to_s.strip.empty?
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
child_id, warning = if session.to_s.strip.empty?
|
|
56
|
+
start_child(task, parent: parent, model: model, state_dir: sd)
|
|
57
|
+
else
|
|
58
|
+
follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
|
|
59
|
+
end
|
|
59
60
|
return child_id if child_id.start_with?("Error:")
|
|
60
|
-
|
|
61
|
+
|
|
62
|
+
note = warning ? "Warning: #{warning}\n" : ""
|
|
63
|
+
return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
|
|
61
64
|
|
|
62
65
|
started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
|
|
63
|
-
"session: #{child_id}\nstatus: running\n#{started};
|
|
66
|
+
"#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint} " \
|
|
64
67
|
"It shows in chi sessions list and the web as a child of this session; the user can attach to it."
|
|
65
68
|
rescue ArgumentError => e
|
|
66
69
|
"Error: #{e.message}"
|
|
67
70
|
end
|
|
68
71
|
|
|
72
|
+
# How the reply of a child left running comes back.
|
|
73
|
+
def self.running_hint
|
|
74
|
+
return "delegate_result waits for its reply." if ChildRing.mode == "off"
|
|
75
|
+
|
|
76
|
+
"chi brings its reply to you by itself when it ends its turn (a delegate report); don't poll with delegate_result."
|
|
77
|
+
end
|
|
78
|
+
private_class_method :running_hint
|
|
79
|
+
|
|
69
80
|
# @return [String] the new child's id, or an Error: line
|
|
70
81
|
def self.start_child(task, parent:, model:, state_dir:)
|
|
71
82
|
if parent.parent_id
|
|
@@ -82,9 +93,9 @@ module Samagotchi
|
|
|
82
93
|
|
|
83
94
|
child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
|
|
84
95
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
85
|
-
parent_id: parent.id, state_dir: state_dir)
|
|
86
|
-
DelegateWait.mark_started(parent.id, child)
|
|
87
|
-
child.id
|
|
96
|
+
parent_id: parent.id, delegate: true, state_dir: state_dir)
|
|
97
|
+
DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
|
|
98
|
+
[child.id, child.model_warning]
|
|
88
99
|
end
|
|
89
100
|
private_class_method :start_child
|
|
90
101
|
|
|
@@ -118,8 +129,10 @@ module Samagotchi
|
|
|
118
129
|
private_class_method :follow_up
|
|
119
130
|
|
|
120
131
|
# The model as typed (spawn_session stores its resolved ref), else the
|
|
121
|
-
# parent's. No existence check:
|
|
122
|
-
#
|
|
132
|
+
# parent's. No existence check here: spawn_session checks the host
|
|
133
|
+
# (check_host!, its UnknownHost comes back as this tool's Error: line)
|
|
134
|
+
# and warns about an id the host's saved model list doesn't have
|
|
135
|
+
# (ModelProfile.model_warning, added to this tool's result).
|
|
123
136
|
def self.child_model(model, parent)
|
|
124
137
|
return parent.model_name if model.to_s.strip.empty?
|
|
125
138
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../atomic_file"
|
|
6
|
+
require_relative "../session"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Tools
|
|
10
|
+
# What a parent was already told about one delegate child: the newest
|
|
11
|
+
# reply it was given (+reply_file+, ReplyWait's cursor) and the child as
|
|
12
|
+
# it was then (+messages+, +question_id+, +last_turn+: ReplyWait's
|
|
13
|
+
# baseline). nil +messages+ means no baseline.
|
|
14
|
+
DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages) do
|
|
15
|
+
def self.empty = new(reply_file: nil, last_turn: nil, question_id: nil, messages: nil)
|
|
16
|
+
|
|
17
|
+
# @param baseline [Hash, nil] ReplyWait.baseline_of's
|
|
18
|
+
def with_baseline(baseline)
|
|
19
|
+
b = baseline || {}
|
|
20
|
+
with(messages: b[:messages], question_id: b[:question_id], last_turn: b[:last_turn])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash, nil] ReplyWait's baseline:, nil when none was taken
|
|
24
|
+
def baseline
|
|
25
|
+
return nil if messages.nil?
|
|
26
|
+
|
|
27
|
+
{ messages: messages, question_id: question_id, last_turn: last_turn }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_json_hash = to_h.transform_keys(&:to_s).compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The parent's cursors, one per child, in <parent dir>/delegates.json, so
|
|
34
|
+
# a respawned worker (or a REPL resuming the session) doesn't hand the
|
|
35
|
+
# model a reply it already had. Written by whoever owns the parent (its
|
|
36
|
+
# worker, or the REPL its kernel runs in), so a Mutex is enough for its
|
|
37
|
+
# turn thread vs its loop thread; the file is replaced whole.
|
|
38
|
+
module DelegateCursors
|
|
39
|
+
FILE = "delegates.json"
|
|
40
|
+
LOCK = Mutex.new
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# @return [DelegateCursor] the child's cursor, empty when none
|
|
45
|
+
def get(parent_id, child_id, state_dir:)
|
|
46
|
+
get_from(read(parent_id, state_dir: state_dir), child_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Read, change and write one child's cursor under the lock.
|
|
50
|
+
# @yieldparam cursor [DelegateCursor]
|
|
51
|
+
# @yieldreturn [DelegateCursor]
|
|
52
|
+
# @return [DelegateCursor] the new cursor
|
|
53
|
+
def update(parent_id, child_id, state_dir:)
|
|
54
|
+
LOCK.synchronize do
|
|
55
|
+
all = read(parent_id, state_dir: state_dir)
|
|
56
|
+
cursor = yield get_from(all, child_id)
|
|
57
|
+
all[child_id] = cursor.to_json_hash
|
|
58
|
+
write(parent_id, all, state_dir: state_dir)
|
|
59
|
+
cursor
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_from(all, child_id)
|
|
64
|
+
data = all[child_id]
|
|
65
|
+
return DelegateCursor.empty unless data.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
|
|
68
|
+
question_id: data["question_id"], messages: data["messages"])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def path(parent_id, state_dir:)
|
|
72
|
+
File.join(Session.session_dir(parent_id, state_dir: state_dir), FILE)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash{String => Hash}] child id → cursor fields; {} when none
|
|
76
|
+
# or unreadable
|
|
77
|
+
def read(parent_id, state_dir:)
|
|
78
|
+
data = JSON.parse(File.read(path(parent_id, state_dir: state_dir)))
|
|
79
|
+
data.is_a?(Hash) ? data : {}
|
|
80
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
81
|
+
{}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write(parent_id, all, state_dir:)
|
|
85
|
+
file = path(parent_id, state_dir: state_dir)
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
87
|
+
AtomicFile.write(file, JSON.generate(all))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|