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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/configuration.md +4 -0
  4. data/docs/internals/engine.md +1 -1
  5. data/docs/sessions.md +9 -2
  6. data/lib/samagotchi/bridge/card_store.rb +1 -1
  7. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  8. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  9. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  11. data/lib/samagotchi/child_reports.rb +214 -0
  12. data/lib/samagotchi/client.rb +35 -4
  13. data/lib/samagotchi/config.rb +5 -0
  14. data/lib/samagotchi/engine.rb +2 -0
  15. data/lib/samagotchi/idle_recap.rb +4 -0
  16. data/lib/samagotchi/kernel_loop.rb +44 -5
  17. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  18. data/lib/samagotchi/llm/errors.rb +9 -0
  19. data/lib/samagotchi/log_subscriber.rb +1 -1
  20. data/lib/samagotchi/prompt_warmup.rb +30 -5
  21. data/lib/samagotchi/reply_wait.rb +39 -8
  22. data/lib/samagotchi/session.rb +26 -5
  23. data/lib/samagotchi/session_inbox.rb +34 -1
  24. data/lib/samagotchi/session_manager.rb +32 -3
  25. data/lib/samagotchi/steer.rb +39 -7
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  27. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  28. data/lib/samagotchi/terminal_ui/formatting.rb +31 -4
  29. data/lib/samagotchi/terminal_ui.rb +9 -0
  30. data/lib/samagotchi/tool_call_parser.rb +40 -0
  31. data/lib/samagotchi/tool_declarations.rb +7 -7
  32. data/lib/samagotchi/tools/delegate.rb +13 -4
  33. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  34. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  35. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  36. data/lib/samagotchi/tools/task_wait.rb +2 -2
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +8 -2
  39. data/lib/samagotchi/web/public/app.js +12 -4
  40. data/lib/samagotchi/web/public/format.js +8 -1
  41. data/lib/samagotchi/web/public/notify.js +4 -2
  42. data/lib/samagotchi/web/public/turn_events.js +26 -8
  43. data/lib/samagotchi/web/public/turn_view.js +23 -4
  44. data/lib/samagotchi/worker.rb +217 -8
  45. 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 full metadata for a task by id. Use read on output_path to inspect command output.",
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 returned by task_create"
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 to stop"
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 returned by task_create"
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; delegate_result waits for them later. 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).",
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 = ["system/delegated"].freeze
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}; delegate_result waits for its reply. " \
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 cursor per
15
- # child (the newest reply this parent was already given) and the child
16
- # as it was before the message went in (ReplyWait's baseline), so a turn
17
- # that ends before the wait's first look (a fast failure) still ends it.
18
- # Both live in this process (a worker runs one session), keyed by parent
19
- # and child; a worker respawn loses them, which only repeats the newest
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
- baseline = baselines[key]
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: seen[key], timeout: [left, 0].max,
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
- return with_outcomes(finish(wait, child_id, key: key, timeout: timeout), outcomes)
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 key [Array(String, String)] [parent id, child id]
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, key:, timeout:)
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
- seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
157
- baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
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
- baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
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
- def waiting_text(child_id, pending)
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, "Error: task not found: #{task_id}"] unless record
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, "Error: task not found: #{task_id}"] unless record
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 "Error: #{error}" if error
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 "Error: #{error}" if error
67
+ return error if error
68
68
  return finished_response(record, tail_lines) unless record.fetch("status") == "running"
69
69
 
70
70
  note = [
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.30.0"
4
+ VERSION = "0.31.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
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
- const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
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 === "add") {
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;