samagotchi 0.30.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 +46 -1
- data/docs/configuration.md +4 -0
- data/docs/internals/engine.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- 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/config.rb +5 -0
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +26 -5
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +32 -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 +31 -4
- data/lib/samagotchi/terminal_ui.rb +9 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +7 -7
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- 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/app.js +12 -4
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/notify.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +26 -8
- data/lib/samagotchi/web/public/turn_view.js +23 -4
- data/lib/samagotchi/worker.rb +217 -8
- metadata +3 -1
|
@@ -254,6 +254,7 @@ module Samagotchi
|
|
|
254
254
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
255
255
|
# Retry lines and hook notices on stderr; stdout gets the answer below.
|
|
256
256
|
sink = OneShotSink.new
|
|
257
|
+
save_before_first_turn(session)
|
|
257
258
|
begin
|
|
258
259
|
result = @engine.run_turn(
|
|
259
260
|
session,
|
|
@@ -582,6 +583,7 @@ module Samagotchi
|
|
|
582
583
|
refresh_status_row
|
|
583
584
|
# A prompt the user typed brings an archived session back to the lists.
|
|
584
585
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
586
|
+
save_before_first_turn(session)
|
|
585
587
|
with_steering do
|
|
586
588
|
@engine.run_turn(
|
|
587
589
|
session,
|
|
@@ -809,6 +811,13 @@ module Samagotchi
|
|
|
809
811
|
session.save
|
|
810
812
|
end
|
|
811
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
|
+
|
|
812
821
|
# A !cmd shows its own output; everything else is the model> line.
|
|
813
822
|
def show_command_result(result)
|
|
814
823
|
if result.shell
|
|
@@ -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
|
|
|
@@ -62,12 +63,20 @@ module Samagotchi
|
|
|
62
63
|
return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
|
|
63
64
|
|
|
64
65
|
started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
|
|
65
|
-
"#{note}session: #{child_id}\nstatus: running\n#{started};
|
|
66
|
+
"#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint} " \
|
|
66
67
|
"It shows in chi sessions list and the web as a child of this session; the user can attach to it."
|
|
67
68
|
rescue ArgumentError => e
|
|
68
69
|
"Error: #{e.message}"
|
|
69
70
|
end
|
|
70
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
|
+
|
|
71
80
|
# @return [String] the new child's id, or an Error: line
|
|
72
81
|
def self.start_child(task, parent:, model:, state_dir:)
|
|
73
82
|
if parent.parent_id
|
|
@@ -84,8 +93,8 @@ module Samagotchi
|
|
|
84
93
|
|
|
85
94
|
child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
|
|
86
95
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
87
|
-
parent_id: parent.id, state_dir: state_dir)
|
|
88
|
-
DelegateWait.mark_started(parent.id, child)
|
|
96
|
+
parent_id: parent.id, delegate: true, state_dir: state_dir)
|
|
97
|
+
DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
|
|
89
98
|
[child.id, child.model_warning]
|
|
90
99
|
end
|
|
91
100
|
private_class_method :start_child
|
|
@@ -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
|
|
@@ -6,18 +6,18 @@ require_relative "../parent_report"
|
|
|
6
6
|
require_relative "output_guardrails"
|
|
7
7
|
require_relative "peers"
|
|
8
8
|
require_relative "delegate_relay"
|
|
9
|
+
require_relative "delegate_cursor"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Tools
|
|
12
13
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
13
14
|
# delegate_result: ReplyWait in the tools' words, with the status words
|
|
14
|
-
# `chi send --wait` and `chi answer` use (ParentReport.status), with a
|
|
15
|
-
# child
|
|
16
|
-
# as it was
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# reply once or waits for a later turn.
|
|
15
|
+
# `chi send --wait` and `chi answer` use (ParentReport.status), with a
|
|
16
|
+
# DelegateCursor per child: the newest reply this parent was already
|
|
17
|
+
# given, and the child as it was when the parent last heard from it or
|
|
18
|
+
# sent it a message (ReplyWait's baseline), so a turn that ends before
|
|
19
|
+
# the wait's first look (a fast failure) still ends it. The cursors are
|
|
20
|
+
# on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
|
|
21
21
|
module DelegateWait
|
|
22
22
|
TIMEOUT_DEFAULT = 600
|
|
23
23
|
POLL_INTERVAL = 0.5
|
|
@@ -32,16 +32,6 @@ module Samagotchi
|
|
|
32
32
|
|
|
33
33
|
module_function
|
|
34
34
|
|
|
35
|
-
# @return [Hash{Array(String, String) => String}] [parent id, child id] → newest reply filename given
|
|
36
|
-
def seen
|
|
37
|
-
@seen ||= {}
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
|
|
41
|
-
def baselines
|
|
42
|
-
@baselines ||= {}
|
|
43
|
-
end
|
|
44
|
-
|
|
45
35
|
# @return [Hash{Array(String, String) => Array<String>}] [parent id,
|
|
46
36
|
# child id] → outcome lines of that child's approvals relayed while
|
|
47
37
|
# the parent waited on another child, for its next result
|
|
@@ -69,18 +59,20 @@ module Samagotchi
|
|
|
69
59
|
cancelled = -> { peers.cancelled? }
|
|
70
60
|
relay = peers.respond_to?(:relay) ? peers.relay : nil
|
|
71
61
|
others = relay && others_for(peers.session_id, child_id, relay, sd)
|
|
72
|
-
|
|
62
|
+
cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
|
|
63
|
+
baseline = cursor.baseline
|
|
73
64
|
left = timeout.to_i
|
|
74
65
|
outcomes = relayed_outcomes.delete(key) || []
|
|
75
66
|
loop do
|
|
76
67
|
started = monotonic
|
|
77
|
-
wait = ReplyWait.call(child_id, state_dir: sd, cursor:
|
|
68
|
+
wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
|
|
78
69
|
poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
|
|
79
70
|
owner_grace: owner_grace, interject: others && -> { others.poll })
|
|
80
71
|
left -= monotonic - started
|
|
81
72
|
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
82
73
|
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
83
|
-
|
|
74
|
+
advance(peers.session_id, child_id, wait, state_dir: sd)
|
|
75
|
+
return with_outcomes(finish(wait, child_id, timeout: timeout), outcomes)
|
|
84
76
|
end
|
|
85
77
|
|
|
86
78
|
outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
|
|
@@ -118,24 +110,43 @@ module Samagotchi
|
|
|
118
110
|
|
|
119
111
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
120
112
|
|
|
113
|
+
REPORT_NEXT_REPLY = "chi brings the child's next reply here by itself; don't wait for it with delegate_result."
|
|
114
|
+
|
|
115
|
+
# Outcomes that tell the parent something about the child's state.
|
|
116
|
+
REPORTED = %i[done no_reply error stopped waiting_for_answer worker_gone].freeze
|
|
117
|
+
|
|
118
|
+
# Move the parent's cursor past what +wait+ reported: the reply it
|
|
119
|
+
# gave, and the child as that same look loaded it (a fresh load could
|
|
120
|
+
# swallow a turn that ended in between), so a later wait looks for a
|
|
121
|
+
# later turn and a question already reported isn't reported again.
|
|
122
|
+
# Nothing moves on a timeout or a cancel.
|
|
123
|
+
# @param wait [ReplyWait::Result]
|
|
124
|
+
# @return [DelegateCursor, nil] the new cursor
|
|
125
|
+
def advance(parent_id, child_id, wait, state_dir:)
|
|
126
|
+
return nil unless REPORTED.include?(wait.status) && wait.session
|
|
127
|
+
|
|
128
|
+
DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
|
|
129
|
+
cursor = cursor.with(reply_file: wait.file) if wait.status == :done
|
|
130
|
+
cursor.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
121
134
|
# The tool result for how the wait ended.
|
|
122
135
|
# @param wait [ReplyWait::Result]
|
|
123
|
-
# @param
|
|
136
|
+
# @param report [Boolean] a delegate report's text (ChildReports), not
|
|
137
|
+
# the tool's: a question's last line says chi brings the next reply
|
|
124
138
|
# @return [String]
|
|
125
|
-
def finish(wait, child_id,
|
|
126
|
-
# The turn sent to is handed over: a later wait looks for a later one.
|
|
127
|
-
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
139
|
+
def finish(wait, child_id, timeout:, report: false)
|
|
128
140
|
status = ParentReport.status(wait)
|
|
129
141
|
case wait.status
|
|
130
142
|
when :done
|
|
131
|
-
seen[key] = wait.file
|
|
132
143
|
reply_result(child_id, status, wait.text)
|
|
133
144
|
when :error
|
|
134
145
|
result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
|
|
135
146
|
when :stopped
|
|
136
147
|
result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
137
148
|
when :waiting_for_answer
|
|
138
|
-
result(child_id, status, waiting_text(child_id, wait.question))
|
|
149
|
+
result(child_id, status, waiting_text(child_id, wait.question, report: report))
|
|
139
150
|
when :canceled
|
|
140
151
|
canceled_result(child_id)
|
|
141
152
|
when :no_reply
|
|
@@ -153,14 +164,18 @@ module Samagotchi
|
|
|
153
164
|
# child's baseline before the follow-up goes in.
|
|
154
165
|
# @param child [Session] the child as loaded before sending
|
|
155
166
|
def mark_seen(parent_id, child, state_dir:)
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
|
|
168
|
+
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
169
|
+
cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
|
|
170
|
+
end
|
|
158
171
|
end
|
|
159
172
|
|
|
160
173
|
# A new child's baseline: as spawned, before its first turn ran.
|
|
161
174
|
# @param child [Session] the session spawn_session returned
|
|
162
|
-
def mark_started(parent_id, child)
|
|
163
|
-
|
|
175
|
+
def mark_started(parent_id, child, state_dir:)
|
|
176
|
+
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
177
|
+
cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
|
|
178
|
+
end
|
|
164
179
|
end
|
|
165
180
|
|
|
166
181
|
def reply_result(child_id, status, text)
|
|
@@ -188,13 +203,17 @@ module Samagotchi
|
|
|
188
203
|
# decide (it isn't relayed: Continue grants no permission): continue
|
|
189
204
|
# it with chi answer through execute, send a follow-up instead, or
|
|
190
205
|
# report back.
|
|
191
|
-
|
|
206
|
+
# A report's last line differs (+report+): "waits again" made models
|
|
207
|
+
# call delegate_result right after answering Continue.
|
|
208
|
+
def waiting_text(child_id, pending, report: false)
|
|
192
209
|
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
193
210
|
if ParentReport.continue?(pending)
|
|
194
211
|
text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
|
|
195
212
|
"above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
|
|
196
213
|
"session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
|
|
197
214
|
end
|
|
215
|
+
return "#{text}#{REPORT_NEXT_REPLY}" if report
|
|
216
|
+
|
|
198
217
|
"#{text}delegate_result #{child_id} waits again once it is answered."
|
|
199
218
|
end
|
|
200
219
|
|
|
@@ -154,7 +154,7 @@ module Samagotchi
|
|
|
154
154
|
|
|
155
155
|
def get_record(task_id)
|
|
156
156
|
record = load_record(task_id)
|
|
157
|
-
return [nil,
|
|
157
|
+
return [nil, not_found(task_id)] unless record
|
|
158
158
|
|
|
159
159
|
[refresh_record(record), nil]
|
|
160
160
|
rescue StandardError => e
|
|
@@ -180,7 +180,7 @@ module Samagotchi
|
|
|
180
180
|
# @param by [String] "model" (task_stop) or "user" (the web's button)
|
|
181
181
|
def stop_task(task_id, by:)
|
|
182
182
|
record = load_record(task_id)
|
|
183
|
-
return [nil,
|
|
183
|
+
return [nil, not_found(task_id)] unless record
|
|
184
184
|
|
|
185
185
|
refreshed = refresh_record(record)
|
|
186
186
|
return [refreshed, nil] unless refreshed["status"] == "running"
|
|
@@ -223,7 +223,26 @@ module Samagotchi
|
|
|
223
223
|
record
|
|
224
224
|
end
|
|
225
225
|
|
|
226
|
+
# A session id (a uuid) is never a task's: task ids are a timestamp
|
|
227
|
+
# and hex (20261005093000-1a2b3c4d).
|
|
228
|
+
SESSION_ID_SHAPE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
229
|
+
|
|
230
|
+
# A model mixing up ids (Qwen passes session ids to task_get and
|
|
231
|
+
# task_stop) is told which kind it passed and where task ids come from.
|
|
232
|
+
def not_found(task_id)
|
|
233
|
+
error = "Error: task not found: #{task_id}"
|
|
234
|
+
return error unless SESSION_ID_SHAPE.match?(task_id.to_s)
|
|
235
|
+
|
|
236
|
+
"#{error} (that is a session id; task ids come from task_create, and task_list lists them)"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Exactly the shape generate_task_id makes. Anything else (../x, an
|
|
240
|
+
# absolute path) is no task, so a model's id never leaves tmp/tasks.
|
|
241
|
+
TASK_ID_SHAPE = /\A\d{14}-\h{8}\z/
|
|
242
|
+
|
|
226
243
|
def load_record(task_id)
|
|
244
|
+
return nil unless TASK_ID_SHAPE.match?(task_id.to_s)
|
|
245
|
+
|
|
227
246
|
path = metadata_path(task_id)
|
|
228
247
|
return nil unless File.exist?(path)
|
|
229
248
|
|
|
@@ -34,7 +34,7 @@ module Samagotchi
|
|
|
34
34
|
|
|
35
35
|
loop do
|
|
36
36
|
record, error = TaskRuntime.get_record(task_id)
|
|
37
|
-
return
|
|
37
|
+
return error if error
|
|
38
38
|
|
|
39
39
|
status = record.fetch("status")
|
|
40
40
|
return finished_response(record, tail_lines) unless status == "running"
|
|
@@ -64,7 +64,7 @@ module Samagotchi
|
|
|
64
64
|
# normal finished result. Only a task still running says so.
|
|
65
65
|
def self.cancelled_response(task_id, tail_lines, started)
|
|
66
66
|
record, error = TaskRuntime.get_record(task_id)
|
|
67
|
-
return
|
|
67
|
+
return error if error
|
|
68
68
|
return finished_response(record, tail_lines) unless record.fetch("status") == "running"
|
|
69
69
|
|
|
70
70
|
note = [
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1522,8 +1522,14 @@ module Samagotchi
|
|
|
1522
1522
|
|
|
1523
1523
|
message = { role: norm_role, content: stripped }
|
|
1524
1524
|
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1525
|
-
# read by step +step
|
|
1526
|
-
|
|
1525
|
+
# read by step +step+; a wake turn's report starts its turn
|
|
1526
|
+
# (Steer.turn_prompt?). Its sender (a delegate report, chi send):
|
|
1527
|
+
# the page's label.
|
|
1528
|
+
if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1529
|
+
message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
|
|
1530
|
+
source = (m[:source] || m["source"]).to_s
|
|
1531
|
+
message[:source] = source unless source.empty?
|
|
1532
|
+
end
|
|
1527
1533
|
# The turn it started (Engine): the page pairs it with that turn's
|
|
1528
1534
|
# record by this, not by its place.
|
|
1529
1535
|
turn_id = m[:turn_id] || m["turn_id"]
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS, turnTimingText,
|
|
47
47
|
} from "./timing.js";
|
|
48
48
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
49
|
-
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
49
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
50
50
|
|
|
51
51
|
const $ = (s) => document.querySelector(s);
|
|
52
52
|
const topStripEl = $("#topStrip");
|
|
@@ -142,8 +142,11 @@ let selectSeq = null;
|
|
|
142
142
|
// new turn has started on it yet: see followWokenWorker.
|
|
143
143
|
let streamFromGuess = false;
|
|
144
144
|
// The last input_merged had an origin with no bubble: its merged text
|
|
145
|
-
// (pending_input_merged) gets one.
|
|
145
|
+
// (pending_input_merged) gets one ("report": a delegate report's).
|
|
146
146
|
let unmatchedMerge = false;
|
|
147
|
+
// The running turn is one chi ran for delegate reports and its report
|
|
148
|
+
// bubble isn't drawn yet: that bubble starts the turn.
|
|
149
|
+
let wakeStart = false;
|
|
147
150
|
// The enqueued_ids this page sent (from the /turn acks): only these come back
|
|
148
151
|
// into the composer when their turn fails.
|
|
149
152
|
const sentIds = new Set();
|
|
@@ -710,6 +713,7 @@ function resetTurnView() {
|
|
|
710
713
|
questionCards.removeQuestion();
|
|
711
714
|
questionCards.forgetContinue();
|
|
712
715
|
unmatchedMerge = false;
|
|
716
|
+
wakeStart = false;
|
|
713
717
|
setTurnRunning(false);
|
|
714
718
|
releaseDisplayWait();
|
|
715
719
|
}
|
|
@@ -1067,14 +1071,18 @@ function untaggedOwnBubble(prompt) {
|
|
|
1067
1071
|
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
1068
1072
|
// into its prompt line), else null.
|
|
1069
1073
|
function applyPromptOps(event) {
|
|
1070
|
-
|
|
1074
|
+
if (event.type === "turn_started") wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
1075
|
+
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge, wakeStart });
|
|
1071
1076
|
if (event.type === "input_merged") unmatchedMerge = false;
|
|
1072
1077
|
if (event.type === "pending_input_merged") unmatchedMerge = false;
|
|
1073
1078
|
let started = null;
|
|
1074
1079
|
for (const op of ops) {
|
|
1075
|
-
if (op.op === "
|
|
1080
|
+
if (op.op === "report") {
|
|
1081
|
+
unmatchedMerge = "report";
|
|
1082
|
+
} else if (op.op === "add") {
|
|
1076
1083
|
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
1077
1084
|
if (op.state === "started") started = bubble;
|
|
1085
|
+
if (event.type === "pending_input_merged") wakeStart = false;
|
|
1078
1086
|
} else if (op.op === "tag") {
|
|
1079
1087
|
if (bubbleById(op.enqueuedId)) continue;
|
|
1080
1088
|
const echo = untaggedOwnBubble(op.prompt);
|
|
@@ -26,12 +26,19 @@ export function noteHtml(note) {
|
|
|
26
26
|
// Who a steer names as its sender: a plugin's own label, chi's sender ids
|
|
27
27
|
// in words (the TUI's Formatting#steer_sender agrees;
|
|
28
28
|
// spec/shared/labels_matrix.json). "": no one named.
|
|
29
|
-
const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
|
|
29
|
+
const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin", delegate_report: "delegate report" };
|
|
30
30
|
export function steerSender(source) {
|
|
31
31
|
const id = String(source ?? "");
|
|
32
32
|
return STEER_SENDERS[id] || id;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// The label a user-role line gets on reload for who sent it, by its saved
|
|
36
|
+
// source: a delegate report (the live bubble's clientLabel of child:<id>);
|
|
37
|
+
// null for the user's own (and anything else, as before).
|
|
38
|
+
export function sourceLabel(source) {
|
|
39
|
+
return source === "delegate_report" ? steerSender(source) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
36
43
|
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
37
44
|
// own between bubbles (no step holds it), not a row of a step.
|
|
@@ -12,7 +12,9 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
12
12
|
return wanted && permission === "granted" ? "on" : "off";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// A finished turn shorter than this was watched: no "done".
|
|
15
|
+
// A finished turn shorter than this was watched: no "done". Not a turn
|
|
16
|
+
// chi ran for a delegate child's report (last_turn origin delegate_report):
|
|
17
|
+
// nobody started it, so nobody watched it.
|
|
16
18
|
export const DONE_MIN_SECONDS = 10;
|
|
17
19
|
|
|
18
20
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
@@ -66,7 +68,7 @@ export function attentionFor(prev, next) {
|
|
|
66
68
|
if (next.parent_id) return null;
|
|
67
69
|
const key = `${next.id}:${turn.ended_at}`;
|
|
68
70
|
if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
|
|
69
|
-
if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
|
|
71
|
+
if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || turn.origin === "delegate_report")) {
|
|
70
72
|
return { reason: "done", sessionId: next.id, key };
|
|
71
73
|
}
|
|
72
74
|
return null;
|