samagotchi 0.15.0 → 0.16.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +10 -0
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +3 -1
  6. data/docs/guardrails.md +16 -2
  7. data/docs/sessions.md +7 -2
  8. data/docs/sub-agent.md +15 -3
  9. data/lib/samagotchi/answer_display.rb +6 -2
  10. data/lib/samagotchi/bridge/card_store.rb +16 -1
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  12. data/lib/samagotchi/bridge.rb +84 -0
  13. data/lib/samagotchi/bridge_client.rb +33 -6
  14. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  16. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  17. data/lib/samagotchi/engine.rb +51 -13
  18. data/lib/samagotchi/kernel_loop.rb +16 -5
  19. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  20. data/lib/samagotchi/llm/model_result.rb +12 -7
  21. data/lib/samagotchi/log_subscriber.rb +1 -0
  22. data/lib/samagotchi/parent_report.rb +8 -0
  23. data/lib/samagotchi/question_desk.rb +97 -18
  24. data/lib/samagotchi/relay.rb +88 -0
  25. data/lib/samagotchi/relay_desk.rb +73 -0
  26. data/lib/samagotchi/relay_verifier.rb +113 -0
  27. data/lib/samagotchi/relay_watcher.rb +46 -0
  28. data/lib/samagotchi/reply_wait.rb +10 -1
  29. data/lib/samagotchi/session.rb +15 -2
  30. data/lib/samagotchi/session_manager.rb +3 -2
  31. data/lib/samagotchi/session_metrics.rb +17 -0
  32. data/lib/samagotchi/sessions_command.rb +10 -4
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +69 -18
  35. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  36. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  37. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  38. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  39. data/lib/samagotchi/tool_declarations.rb +2 -2
  40. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  42. data/lib/samagotchi/tools/peers.rb +6 -4
  43. data/lib/samagotchi/turn_note.rb +30 -2
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +20 -0
  46. data/lib/samagotchi/web/public/app.js +24 -6
  47. data/lib/samagotchi/web/public/index.html +7 -0
  48. data/lib/samagotchi/web/public/notify.js +18 -5
  49. data/lib/samagotchi/web/public/question_card.js +45 -3
  50. data/lib/samagotchi/web/public/question_cards.js +49 -1
  51. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  52. data/lib/samagotchi/web/public/stage_model.js +8 -3
  53. data/lib/samagotchi/web/public/stage_view.js +7 -2
  54. data/lib/samagotchi/web/public/timing.js +11 -3
  55. data/lib/samagotchi/web/public/turn_events.js +9 -0
  56. data/lib/samagotchi/web/public/turn_view.js +34 -12
  57. metadata +6 -1
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../bridge_client"
5
+ require_relative "../relay"
6
+ require_relative "../guardrails/approval"
7
+ require_relative "../log"
8
+
9
+ module Samagotchi
10
+ # Loaded on first use (a require cycle otherwise; see delegate.rb).
11
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
+
13
+ module Tools
14
+ # A child's approval, met by DelegateWait, reopened as the parent's own
15
+ # (the approval relay): the parent's user answers it in the UI they are
16
+ # in, the child takes the answer after asking the parent's Bridge for it
17
+ # (RelayVerifier), and the parent's model sees only the outcome line.
18
+ # Runs on the parent's turn thread, which blocks in the parent's
19
+ # question flow until the card is answered, dismissed, closed by its
20
+ # watch (the child answered first, or its worker went) or the turn is
21
+ # stopped.
22
+ module DelegateRelay
23
+ # How often the card's watch looks at the child.
24
+ WATCH_EVERY = 0.5
25
+
26
+ # How often a wait looks at the parent's other children (a full
27
+ # session listing) for approvals to relay.
28
+ OTHERS_EVERY = 2.0
29
+
30
+ # What happened to one relayed approval.
31
+ # @!attribute line [String] the outcome line for the tool result
32
+ # @!attribute stopped [Boolean] the parent's turn was stopped
33
+ Outcome = Struct.new(:line, :stopped, keyword_init: true)
34
+
35
+ module_function
36
+
37
+ # @param relay [#open_question, #relay_desk] Peers#relay
38
+ # @return [Boolean] a relay can take this question
39
+ def relayable?(relay, question)
40
+ !relay.nil? && question.is_a?(Hash) && question[:kind].to_s == Guardrails::Approval::KIND
41
+ end
42
+
43
+ # Relay the child's pending approval and wait until it settles.
44
+ # @param more [Integer] other delegates' approvals waiting behind it
45
+ # @return [Outcome]
46
+ def call(child_id, question, relay:, state_dir:, more: 0)
47
+ child = Session.load(child_id, state_dir: state_dir)
48
+ qid = question[:id].to_s
49
+ desk = relay.relay_desk
50
+ relay_id = desk.open(child_id: child_id, child_question_id: qid)
51
+ client = BridgeClient.discover(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
52
+ post(client, "opened", relay_id, qid)
53
+ Log.info(:turn, "relay_opened", child: child_id[0, 8], id: qid)
54
+
55
+ fields = Relay.card(child, question, relay_id: relay_id, more: more)
56
+ answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
57
+ settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
58
+ end
59
+
60
+ # Closes the card when the child's question is no longer pending (it
61
+ # was answered or cancelled there) or its worker is gone.
62
+ def watch(child_id, qid, state_dir)
63
+ lambda do
64
+ unless SessionManager.worker_live?(child_id, state_dir: state_dir)
65
+ next "child_gone"
66
+ end
67
+
68
+ pending = Session.load(child_id, state_dir: state_dir).pending_question
69
+ pending && pending[:id].to_s == qid ? nil : "answered_on_child"
70
+ rescue ArgumentError
71
+ "child_gone"
72
+ end
73
+ end
74
+
75
+ # Record the parent's answer, tell the child, and say what came of it.
76
+ def settle(answer, fields, client:, desk:, relay_id:, qid:)
77
+ what = what_text(fields[:approval])
78
+ if answer.is_a?(Hash) && answer[:error] == "cancelled"
79
+ desk.close(relay_id)
80
+ return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
81
+ end
82
+
83
+ dismissed = !(answer.is_a?(Hash) && answer[:selected])
84
+ by = answer.is_a?(Hash) && answer[:by] == "parent_agent" ? "parent_agent" : "user"
85
+ desk.record(relay_id, selected_indices: dismissed ? [] : Array(answer[:selected_indices]),
86
+ freeform: dismissed ? nil : answer[:freeform], dismissed: dismissed, by: by)
87
+ response = post(client, "answered", relay_id, qid)
88
+ Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
89
+ end
90
+
91
+ def closed(reason, what, client:, relay_id:, qid:)
92
+ case reason
93
+ when "answered_on_child" then Outcome.new(line: line(what, "answered on the child"), stopped: false)
94
+ when "child_gone" then Outcome.new(line: line(what, "not delivered (the child's worker is gone)"), stopped: false)
95
+ else
96
+ # A Stop: the child's question stays open there.
97
+ post(client, "closed", relay_id, qid, reason: "stopped")
98
+ Outcome.new(line: line(what, "still open (your turn was stopped)"), stopped: true)
99
+ end
100
+ end
101
+
102
+ # The outcome of an answer the child was told about.
103
+ def delivered(response, answer, fields, dismissed)
104
+ return "not delivered (the child's worker is gone)" unless response
105
+
106
+ case response.status
107
+ when 200 then dismissed ? "dismissed (denied)" : verdict(answer, fields)
108
+ when 409 then "answered on the child"
109
+ when 403 then "refused by the child (a parent agent may not allow it); it waits for the user there"
110
+ else "not delivered (#{response.json&.fetch("error", nil) || response.status})"
111
+ end
112
+ end
113
+
114
+ # allowed once / allowed for its session / denied ("why")
115
+ def verdict(answer, fields)
116
+ scopes = Array(fields.dig(:approval, :scopes))
117
+ index = Array(answer[:selected_indices]).first
118
+ if index && index < scopes.size
119
+ return case scopes[index].to_s
120
+ when "once" then "allowed once"
121
+ when "session" then "allowed for the delegate's session"
122
+ when "repo" then "allowed in this repo or directory"
123
+ when "rule" then "allowed by rule #{fields.dig(:approval, :rule)}"
124
+ else "allowed"
125
+ end
126
+ end
127
+
128
+ why = answer[:freeform].to_s.strip
129
+ why.empty? ? "denied" : "denied (#{why.inspect})"
130
+ end
131
+
132
+ def line(what, outcome) = "approval relayed to your user: #{what} → #{outcome}"
133
+
134
+ # execute: git push origin main
135
+ def what_text(facts)
136
+ facts = facts.is_a?(Hash) ? facts : {}
137
+ tool = facts[:label] || facts[:tool] || "tool"
138
+ what = facts[:command] || Array(facts[:paths]).join(", ")
139
+ what = facts[:args].to_s if what.to_s.empty?
140
+ what.to_s.empty? ? tool.to_s : "#{tool}: #{what}"
141
+ end
142
+
143
+ # The parent's other running children, while it waits on one (D2):
144
+ # their approvals are relayed too, oldest first, one card at a time.
145
+ # Each outcome is kept for that child's next DelegateWait result
146
+ # (DelegateWait.relayed_outcomes). Every question is relayed once per
147
+ # wait.
148
+ class Others
149
+ # @param parent_id [String]
150
+ # @param except [String] the child waited on (its own approvals come
151
+ # through ReplyWait)
152
+ # @param on_outcome [#call] (child_id, Outcome)
153
+ def initialize(parent_id:, except:, relay:, state_dir:, on_outcome:, every: OTHERS_EVERY)
154
+ @parent_id = parent_id
155
+ @except = except
156
+ @relay = relay
157
+ @state_dir = state_dir
158
+ @on_outcome = on_outcome
159
+ @every = every
160
+ @done = {}
161
+ @next_at = 0.0
162
+ end
163
+
164
+ # Relay the oldest waiting approval of another child, if any (a
165
+ # ReplyWait interject). Throttled to one look every +every+ s.
166
+ def poll
167
+ return if monotonic < @next_at
168
+
169
+ @next_at = monotonic + @every
170
+ waiting = self.waiting
171
+ return if waiting.empty?
172
+
173
+ child_id, question = waiting.first
174
+ @done[question[:id].to_s] = true
175
+ outcome = DelegateRelay.call(child_id, question, relay: @relay, state_dir: @state_dir, more: waiting.size - 1)
176
+ @on_outcome.call(child_id, outcome)
177
+ # The next one, if any, right after.
178
+ @next_at = 0.0
179
+ end
180
+
181
+ # Other children's approvals not yet relayed in this wait.
182
+ # @return [Integer]
183
+ def count = waiting.size
184
+
185
+ # @return [Array<Array(String, Hash)>] [child id, pending question],
186
+ # oldest question first
187
+ def waiting
188
+ rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
189
+ row[:id] != @except && row[:waiting] == Guardrails::Approval::KIND && !@done[row[:waiting_id].to_s]
190
+ end
191
+ rows.filter_map do |row|
192
+ pending = Session.load(row[:id], state_dir: @state_dir).pending_question
193
+ [row[:id], pending] if pending && pending[:id].to_s == row[:waiting_id].to_s
194
+ rescue ArgumentError
195
+ nil
196
+ end.sort_by { |_, pending| pending[:created_at].to_s }
197
+ end
198
+
199
+ private
200
+
201
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
202
+ end
203
+
204
+ # @return [BridgeClient::Response, nil] nil when the child's worker
205
+ # can't be reached
206
+ def post(client, action, relay_id, qid, **extra)
207
+ return nil unless client
208
+
209
+ client.relay(action: action, relay_id: relay_id, question_id: qid, **extra)
210
+ rescue SystemCallError, IOError => e
211
+ Log.info(:turn, "relay_post_failed", action: action, error: e.class.name)
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end
@@ -5,6 +5,7 @@ require_relative "../reply_wait"
5
5
  require_relative "../parent_report"
6
6
  require_relative "output_guardrails"
7
7
  require_relative "peers"
8
+ require_relative "delegate_relay"
8
9
 
9
10
  module Samagotchi
10
11
  module Tools
@@ -41,6 +42,13 @@ module Samagotchi
41
42
  @baselines ||= {}
42
43
  end
43
44
 
45
+ # @return [Hash{Array(String, String) => Array<String>}] [parent id,
46
+ # child id] → outcome lines of that child's approvals relayed while
47
+ # the parent waited on another child, for its next result
48
+ def relayed_outcomes
49
+ @relayed_outcomes ||= {}
50
+ end
51
+
44
52
  # @param child_id [String] the child's full id
45
53
  # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
46
54
  # @param timeout [Integer] seconds
@@ -48,13 +56,73 @@ module Samagotchi
48
56
  # @param owner_grace [Numeric] seconds with no live worker before
49
57
  # the child's worker counts as gone
50
58
  # @return [String] the tool result
59
+ #
60
+ # A child's approval, when this session can host a relay (Peers#relay),
61
+ # goes to this session's user as its own approval (DelegateRelay); the
62
+ # wait goes on after it settles, with the time it was open not counted
63
+ # against +timeout+, and the result gets one outcome line per relayed
64
+ # approval. Without a relay, and for the model's and hooks' questions,
65
+ # the question comes back to the model as before.
51
66
  def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL, owner_grace: OWNER_GRACE)
52
67
  sd = peers.state_dir || Session.default_state_dir
53
68
  key = [peers.session_id, child_id]
54
69
  cancelled = -> { peers.cancelled? }
55
- wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
56
- poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key],
57
- owner_grace: owner_grace)
70
+ relay = peers.respond_to?(:relay) ? peers.relay : nil
71
+ others = relay && others_for(peers.session_id, child_id, relay, sd)
72
+ baseline = baselines[key]
73
+ left = timeout.to_i
74
+ outcomes = relayed_outcomes.delete(key) || []
75
+ loop do
76
+ started = monotonic
77
+ wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: [left, 0].max,
78
+ poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
79
+ owner_grace: owner_grace, interject: others && -> { others.poll })
80
+ left -= monotonic - started
81
+ unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
82
+ outcomes.concat(relayed_outcomes.delete(key) || [])
83
+ return with_outcomes(finish(wait, child_id, key: key, timeout: timeout), outcomes)
84
+ end
85
+
86
+ outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
87
+ outcomes << outcome.line
88
+ return with_outcomes(canceled_result(child_id), outcomes) if outcome.stopped
89
+
90
+ # Wait on with the child as it is now: a turn that ends with no
91
+ # reply after the relay still ends the wait, and the relayed
92
+ # question isn't reported again.
93
+ baseline = ReplyWait.baseline_of(Session.load(child_id, state_dir: sd), question_id: wait.question[:id])
94
+ end
95
+ rescue ArgumentError => e
96
+ "Error: #{e.message}"
97
+ end
98
+
99
+ # The parent's other children whose approvals a wait relays too, their
100
+ # outcomes kept for their own next result.
101
+ def others_for(parent_id, child_id, relay, state_dir)
102
+ DelegateRelay::Others.new(parent_id: parent_id, except: child_id, relay: relay, state_dir: state_dir,
103
+ on_outcome: lambda { |other, outcome|
104
+ (relayed_outcomes[[parent_id, other]] ||= []) << outcome.line
105
+ })
106
+ end
107
+
108
+ # The relayed approvals' outcome lines, after the status line.
109
+ def with_outcomes(text, outcomes)
110
+ return text if outcomes.empty?
111
+
112
+ text.sub(/\A(session: [^\n]*\nstatus: [^\n]*\n)/) { "#{::Regexp.last_match(1)}#{outcomes.join("\n")}\n" }
113
+ end
114
+
115
+ def canceled_result(child_id)
116
+ result(child_id, "running", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
117
+ end
118
+
119
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
+
121
+ # The tool result for how the wait ended.
122
+ # @param wait [ReplyWait::Result]
123
+ # @param key [Array(String, String)] [parent id, child id]
124
+ # @return [String]
125
+ def finish(wait, child_id, key:, timeout:)
58
126
  # The turn sent to is handed over: a later wait looks for a later one.
59
127
  baselines.delete(key) if %i[done no_reply].include?(wait.status)
60
128
  status = ParentReport.status(wait)
@@ -69,7 +137,7 @@ module Samagotchi
69
137
  when :waiting_for_answer
70
138
  result(child_id, status, waiting_text(child_id, wait.question))
71
139
  when :canceled
72
- result(child_id, status, "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
140
+ canceled_result(child_id)
73
141
  when :no_reply
74
142
  result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
75
143
  when :worker_gone
@@ -78,8 +146,6 @@ module Samagotchi
78
146
  else
79
147
  timeout_result(child_id, timeout)
80
148
  end
81
- rescue ArgumentError => e
82
- "Error: #{e.message}"
83
149
  end
84
150
 
85
151
  # Point the cursor at the newest reply now, so only a later one counts
@@ -5,10 +5,12 @@ module Samagotchi
5
5
  # What list_sessions, send_note and the delegate tools know about the
6
6
  # asking session: its id (left out of the list, the note's sender), its
7
7
  # folder, where sessions live, and whether the running turn was
8
- # canceled (a waiting tool returns on it). Engine hands KernelLoop one
9
- # that follows its current session (Engine::PeerView); this plain one
10
- # is for specs and a kernel without an Engine.
11
- Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, keyword_init: true) do
8
+ # canceled (a waiting tool returns on it), and the question flow a
9
+ # delegate's approval is relayed through (relay: nil when this session
10
+ # can't host one). Engine hands KernelLoop one that follows its current
11
+ # session (Engine::PeerView); this plain one is for specs and a kernel
12
+ # without an Engine.
13
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, keyword_init: true) do
12
14
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
13
15
  def cancelled?
14
16
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -4,7 +4,7 @@ module Samagotchi
4
4
  # The one-line system note a turn leaves in the conversation when it ends
5
5
  # without an answer: failed before the model replied, cancelled, or over
6
6
  # with nothing visible. The UIs show these ends live (`turn failed …`, a
7
- # cancel line, `(the model returned an empty answer)`), but the model saw
7
+ # cancel line, `no answer: …`), but the model saw
8
8
  # none of that on its next turn: the session held an unanswered user
9
9
  # message and no reason. A tail system message, like a reminder or a
10
10
  # context note, keeps the prompt cache and is hidden by the UIs (they
@@ -18,6 +18,7 @@ module Samagotchi
18
18
  TASKS_LISTED = 5
19
19
  TASK_COMMAND_CHARS = 60
20
20
  RETRY_NUDGE = :retry_nudge
21
+ EMPTY_ANSWER = :empty_answer
21
22
 
22
23
  module_function
23
24
 
@@ -69,8 +70,35 @@ module Samagotchi
69
70
  " Still running: #{listed.join(", ")}. Continue with task_wait <id> or stop with task_stop <id>."
70
71
  end
71
72
 
72
- def empty
73
+ # The model reads the text only. The marker (EMPTY_ANSWER) is for the
74
+ # UIs, which draw the turn's notice and steps from it after a reload:
75
+ # +retries+ the empty-answer retries spent, +steps+ the empty
76
+ # generations as model messages (their thinking; the loops keep them out
77
+ # of the conversation, where they'd be empty assistant turns). Never
78
+ # sent: the payload builders send a system message's content, and the
79
+ # copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
80
+ def empty(retries: 0, steps: [])
81
+ marker = { retries: retries.to_i }
82
+ marker[:steps] = steps.map { |step| step.slice(:role, :content, :thinking) } unless steps.empty?
73
83
  message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
84
+ .merge(EMPTY_ANSWER => marker)
85
+ end
86
+
87
+ # The empty-answer marker of +entry+ (either key type), or nil.
88
+ def empty_answer(entry)
89
+ return nil unless note?(entry)
90
+
91
+ marker = entry[EMPTY_ANSWER] || entry[EMPTY_ANSWER.to_s]
92
+ marker.is_a?(Hash) ? marker : nil
93
+ end
94
+
95
+ # The notice every UI shows for a turn that ended with no answer:
96
+ # "no answer: the model returned nothing (after 1 retry)" (turn_events.js
97
+ # emptyAnswerLine words it the same).
98
+ def empty_answer_line(retries)
99
+ count = retries.to_i
100
+ after = count.positive? ? " (after #{count} #{count == 1 ? "retry" : "retries"})" : ""
101
+ "no answer: the model returned nothing#{after}"
74
102
  end
75
103
 
76
104
  # The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.15.0"
4
+ VERSION = "0.16.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1252,6 +1252,13 @@ module Samagotchi
1252
1252
  filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
1253
1253
  next
1254
1254
  end
1255
+ # A turn that ended with no answer (TurnNote.empty's marker): its
1256
+ # empty steps (with ?parts=1) and the item the page draws its
1257
+ # notice from; the note itself stays hidden.
1258
+ if (marker = Samagotchi::TurnNote.empty_answer(m))
1259
+ filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
1260
+ next
1261
+ end
1255
1262
  next if role == "system"
1256
1263
  next if role == "tool_response"
1257
1264
 
@@ -1293,6 +1300,19 @@ module Samagotchi
1293
1300
  []
1294
1301
  end
1295
1302
 
1303
+ # A no-answer turn's marker as display items: each empty step that
1304
+ # has something to show (its thinking) as a text-less assistant step
1305
+ # (only with +parts+, as other text-less steps), then
1306
+ # { role: "empty_answer", retries: }.
1307
+ def empty_answer_items(marker, parts:, cwd:)
1308
+ steps = parts ? Array(marker[:steps] || marker["steps"]) : []
1309
+ items = steps.filter_map do |step|
1310
+ did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
1311
+ { role: "assistant", content: "", parts: did } if did
1312
+ end
1313
+ items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i }
1314
+ end
1315
+
1296
1316
  # The last turn failed and its prompt went back to the user: the
1297
1317
  # prompt (session.last_prompt) and why, for the page to show after the
1298
1318
  # conversation as it did live. nil otherwise.
@@ -21,7 +21,7 @@ import { emptySessionState } from "./session_state.js";
21
21
  import { createModelPicker } from "./model_picker.js";
22
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
23
  import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
- import { createTurnView, turnHistoryHtml } from "./turn_view.js";
24
+ import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
25
  import { createStageView } from "./stage_view.js";
26
26
  import { appendQuote } from "./annotations.js";
27
27
  import { parsePresets } from "./annotate_presets.js";
@@ -44,7 +44,7 @@ import {
44
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
45
45
  } from "./timing.js";
46
46
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
47
- import { clientLabel, commandView, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
47
+ import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
48
48
 
49
49
  const $ = (s) => document.querySelector(s);
50
50
  const topStripEl = $("#topStrip");
@@ -776,6 +776,9 @@ function renderSessionView(data) {
776
776
  // A question the worker is blocked on (also after a reload).
777
777
  if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
778
778
  questionCards.renderQuestion(data.pending_question);
779
+ // The turn's replay drew it as asked; the snapshot knows whether it is
780
+ // relayed to the parent now.
781
+ questionCards.markRelayed(data.pending_question.id, data.pending_question.relayed_to || null);
779
782
  }
780
783
  // A turn ran out of iterations and waits for a yes or no.
781
784
  if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
@@ -897,12 +900,25 @@ function addEndBubble(text) {
897
900
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
898
901
  }
899
902
 
903
+ // The notice of a turn that ended with no answer (turn_summary.empty_answer),
904
+ // as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
905
+ function emptyAnswerEl(emptyAnswer) {
906
+ const el = document.createElement("div");
907
+ el.className = EMPTY_ANSWER_CLASS;
908
+ el.textContent = emptyAnswerLine(emptyAnswer);
909
+ return el;
910
+ }
911
+
900
912
  // Everything a turn's end (completed, canceled, failed) closes: then its
901
913
  // end line (+endText+, the cancel or failed line), then the step cards that
902
914
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
903
- function endTurnView(kind, endText = null) {
915
+ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
904
916
  setTurnRunning(false);
905
- const kept = turnDom.turnEnded({ kind }) || [];
917
+ // A completed turn with no answer: its notice where the answer goes
918
+ // (the turn view places it), else after the turn; before the timing.
919
+ const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
920
+ const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
921
+ if (endNote && !endNote.isConnected) appendToHistory(endNote);
906
922
  finishLiveTurnTiming(null, { canceled: kind === "canceled" });
907
923
  if (endText) addEndBubble(endText);
908
924
  if (kept.length) {
@@ -1203,7 +1219,7 @@ function reloadedTurnEls(before) {
1203
1219
  // What goes after a reloaded turn's own rows and cards: its answer, else
1204
1220
  // its timing line; null when it has neither.
1205
1221
  function turnAnswerEl(turnEls) {
1206
- return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1222
+ return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1207
1223
  }
1208
1224
 
1209
1225
  // A finished turn's question (the snapshot's cards keep it with its
@@ -1526,7 +1542,7 @@ const streamHandlers = {
1526
1542
  startLiveTurnTiming(data.started_at);
1527
1543
  }),
1528
1544
  turn_completed: async (data) => {
1529
- endTurnView("completed");
1545
+ endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
1530
1546
  const out = turnOutput(data);
1531
1547
  if (out) appendChunk(out);
1532
1548
  // after_turn hooks are running and may present the answer: its
@@ -1643,6 +1659,8 @@ const streamHandlers = {
1643
1659
  question_answered: (data) => {
1644
1660
  questionCards.resolveQuestion(data.id, { answer: data.answer });
1645
1661
  },
1662
+ // A delegate's question relayed to its parent's card, or no longer.
1663
+ question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
1646
1664
  question_cancelled: (data) => {
1647
1665
  questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
1648
1666
  },
@@ -253,6 +253,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
253
253
  .bubble.question.approval.answered.denied .question-result{color:var(--danger)}
254
254
  .bubble.question.approval.answered.denied .question-option.selected{border-color:var(--danger)}
255
255
  .approval-details{margin-bottom:8px;font-size:13px;color:var(--text-2)}
256
+ .approval-delegate{font-size:12px;color:var(--text-muted);margin-bottom:4px;overflow-wrap:anywhere}
257
+ .approval-delegate a{color:var(--text);font-weight:600}
258
+ .question-relayed{font-size:12px;color:var(--text-muted);margin-bottom:6px}
259
+ .question-relayed.hidden{display:none}
260
+ .question-relayed a{color:var(--text);font-weight:600}
256
261
  .approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
257
262
  .approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
258
263
  .approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
@@ -429,6 +434,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
429
434
  .turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
430
435
  .turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
431
436
  .turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
437
+ .turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
432
438
  .turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
433
439
  @keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
434
440
  .turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
@@ -441,6 +447,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
441
447
  .turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
442
448
  .turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
443
449
  .turn-stage[data-phase="failed"] .ts-mark,.turn-stage[data-phase="failed"] .ts-phase{color:var(--danger-text)}
450
+ .turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
444
451
  .turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
445
452
  .ts-phase{color:var(--accent);font-weight:600}
446
453
  .ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
@@ -30,16 +30,25 @@ export function waitingOn(summary) {
30
30
  return null;
31
31
  }
32
32
 
33
+ // How long a delegated child's new approval waits before it notifies: its
34
+ // parent relays it within a poll or two when it waits on the child, and
35
+ // then the parent's card notifies instead (one bell, not two).
36
+ export const RELAY_HOLD_MS = 1500;
37
+
33
38
  // What changed between two summaries of one session that needs the user.
34
39
  // @param prev the summary held before (null for a session not seen yet)
35
40
  // @param next the new summary
36
41
  // @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
37
- // key names this one event, the same in every tab (the Notification tag)
42
+ // key names this one event, the same in every tab (the Notification tag);
43
+ // holdMs, when there, delays it (a delegated child's approval)
38
44
  export function attentionFor(prev, next) {
39
45
  if (!next || !next.id) return null;
40
46
  const q = next.pending_question;
41
47
  if (q && q.id && q.id !== prev?.pending_question?.id) {
42
- return { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
48
+ // Relayed to the parent already: the parent's card notifies.
49
+ if (q.relayed_to) return null;
50
+ const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
51
+ return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
43
52
  }
44
53
  const card = next.pending_card;
45
54
  if (card && card.id && card.id !== prev?.pending_card?.id) {
@@ -74,7 +83,9 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
74
83
  export function closedKeys(prev, next) {
75
84
  const keys = [];
76
85
  const q = prev?.pending_question;
77
- if (q && q.id && next?.pending_question?.id !== q.id) keys.push(`${prev.id}:${q.id}`);
86
+ const nq = next?.pending_question;
87
+ // Gone, or relayed to the parent (its card notifies and badges instead).
88
+ if (q && q.id && (nq?.id !== q.id || (nq.relayed_to && !q.relayed_to))) keys.push(`${prev.id}:${q.id}`);
78
89
  const card = prev?.pending_card;
79
90
  if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
80
91
  return keys;
@@ -178,10 +189,12 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
178
189
  },
179
190
  // A new attention in a tab behind: +deliver+ (notify, badge) unless
180
191
  // another tab has it in front. @return whether it may still be delivered
192
+ // An attention's own holdMs (a delegated child's approval) holds it
193
+ // longer, alone too.
181
194
  offer(attention, deliver) {
182
195
  const key = attention.key;
183
196
  if (seen.has(key)) return false;
184
- if (!channel) {
197
+ if (!channel && !attention.holdMs) {
185
198
  deliver();
186
199
  return true;
187
200
  }
@@ -189,7 +202,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
189
202
  pending.set(key, schedule(() => {
190
203
  pending.delete(key);
191
204
  if (!seen.has(key)) deliver();
192
- }, holdMs));
205
+ }, Math.max(holdMs, attention.holdMs || 0)));
193
206
  return true;
194
207
  },
195
208
  // Closed before its hold was up (a question answered at once).