samagotchi 0.14.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -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/plugins.md +3 -3
  8. data/docs/sessions.md +7 -2
  9. data/docs/sub-agent.md +15 -3
  10. data/lib/samagotchi/answer_display.rb +6 -2
  11. data/lib/samagotchi/bridge/card_store.rb +16 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  13. data/lib/samagotchi/bridge.rb +84 -0
  14. data/lib/samagotchi/bridge_client.rb +33 -6
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  17. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  18. data/lib/samagotchi/engine.rb +58 -17
  19. data/lib/samagotchi/kernel_loop.rb +16 -5
  20. data/lib/samagotchi/llm/chat_loop.rb +17 -9
  21. data/lib/samagotchi/llm/model_result.rb +12 -7
  22. data/lib/samagotchi/log_subscriber.rb +1 -0
  23. data/lib/samagotchi/parent_report.rb +8 -0
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/question_desk.rb +97 -18
  26. data/lib/samagotchi/relay.rb +88 -0
  27. data/lib/samagotchi/relay_desk.rb +73 -0
  28. data/lib/samagotchi/relay_verifier.rb +113 -0
  29. data/lib/samagotchi/relay_watcher.rb +46 -0
  30. data/lib/samagotchi/reply_wait.rb +10 -1
  31. data/lib/samagotchi/self_report.rb +24 -4
  32. data/lib/samagotchi/session.rb +15 -2
  33. data/lib/samagotchi/session_manager.rb +5 -2
  34. data/lib/samagotchi/session_metrics.rb +18 -1
  35. data/lib/samagotchi/session_retention.rb +6 -1
  36. data/lib/samagotchi/sessions_command.rb +10 -4
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +88 -12
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  41. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  43. data/lib/samagotchi/terminal_ui.rb +11 -0
  44. data/lib/samagotchi/tool_declarations.rb +2 -2
  45. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  46. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  47. data/lib/samagotchi/tools/peers.rb +6 -4
  48. data/lib/samagotchi/turn_note.rb +30 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +24 -0
  51. data/lib/samagotchi/web/public/app.js +32 -11
  52. data/lib/samagotchi/web/public/index.html +9 -0
  53. data/lib/samagotchi/web/public/notify.js +18 -5
  54. data/lib/samagotchi/web/public/question_card.js +45 -3
  55. data/lib/samagotchi/web/public/question_cards.js +49 -1
  56. data/lib/samagotchi/web/public/sessions_list.js +16 -1
  57. data/lib/samagotchi/web/public/stage_model.js +8 -3
  58. data/lib/samagotchi/web/public/stage_view.js +7 -2
  59. data/lib/samagotchi/web/public/strip.js +8 -1
  60. data/lib/samagotchi/web/public/timing.js +22 -5
  61. data/lib/samagotchi/web/public/turn_events.js +9 -0
  62. data/lib/samagotchi/web/public/turn_view.js +38 -13
  63. metadata +7 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../served_model"
4
4
  require_relative "../image_store"
5
+ require_relative "../turn_note"
5
6
 
6
7
  module Samagotchi
7
8
  class TerminalUI
@@ -44,6 +45,12 @@ module Samagotchi
44
45
  paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
45
46
  end
46
47
 
48
+ # "no answer: the model returned nothing (after 1 retry)", dim like the
49
+ # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
50
+ def format_empty_answer_line(retries)
51
+ paint(TurnNote.empty_answer_line(retries), 90)
52
+ end
53
+
47
54
  # "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
48
55
  # retries there will be (as the web counts), the wait before it, and
49
56
  # what failed (generation_retrying).
@@ -22,6 +22,9 @@ module Samagotchi
22
22
  end
23
23
 
24
24
  attr_reader :id, :question, :options, :header
25
+ # @return [String, nil] the parent's short id while this question waits
26
+ # in its card too (the approval relay)
27
+ attr_reader :relayed_to
25
28
 
26
29
  # @param pending [Hash] Engine#pending_question (symbol or string keys)
27
30
  def initialize(pending)
@@ -30,6 +33,18 @@ module Samagotchi
30
33
  @preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
31
34
  @id = field.(:id).to_s
32
35
  @question = field.(:question).to_s
36
+ # A delegate's approval relayed here: the delegate's own text is the
37
+ # question, who asks a dim line (#note).
38
+ relay = field.(:relay)
39
+ if relay.is_a?(Hash)
40
+ get = ->(key) { relay[key] || relay[key.to_s] }
41
+ chain = Array(get.(:chain)).map(&:to_s)
42
+ chain = [get.(:child_id).to_s[0, 8]] if chain.empty?
43
+ @delegate = chain.reverse.join(" → ")
44
+ @delegate_task = get.(:task).to_s
45
+ @question = get.(:asked).to_s unless get.(:asked).to_s.strip.empty?
46
+ end
47
+ self.relayed_to = field.(:relayed_to)
33
48
  @options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
34
49
  header = field.(:header).to_s.strip
35
50
  @header = header.empty? ? nil : header
@@ -42,6 +57,36 @@ module Samagotchi
42
57
  def free? = @free
43
58
  def approval? = @approval
44
59
 
60
+ # The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
61
+ # short id), or nil when cleared.
62
+ def relayed_to=(value)
63
+ short = value.is_a?(Hash) ? (value[:parent_short] || value["parent_short"]) : value
64
+ @relayed_to = short.to_s.strip.empty? ? nil : short.to_s
65
+ end
66
+
67
+ # The dim line under the header: which delegate asks (and its task),
68
+ # or that this question waits in a parent too.
69
+ def note
70
+ if @delegate
71
+ task = @delegate_task.empty? ? "" : " · #{@delegate_task}"
72
+ return " delegate #{@delegate}#{task}"
73
+ end
74
+ " waiting in parent #{@relayed_to} too (answering here works)" if @relayed_to
75
+ end
76
+
77
+ # What a question closed with no answer here says: a relayed approval
78
+ # names where it went; else "(question cancelled)".
79
+ def closed_text(reason)
80
+ return "(question cancelled)" unless @delegate
81
+
82
+ case reason.to_s
83
+ when "answered_on_child" then "(answered in #{@delegate})"
84
+ when "child_gone" then "(#{@delegate}'s worker is gone)"
85
+ when "dismissed" then "(denied)"
86
+ else "(left open in #{@delegate})"
87
+ end
88
+ end
89
+
45
90
  # The widget as slot content, fitted to the rows it gets (QuestionSlot).
46
91
  # @return [QuestionSlot]
47
92
  def slot(paint: ->(text, _code) { text })
@@ -50,7 +95,7 @@ module Samagotchi
50
95
  QuestionSlot.new(header: approval? ? (header || "Approve tool call?") : header,
51
96
  question: first.to_s, mark: mark, details: rest,
52
97
  options: keys.zip(options).map { |key, label| QuestionSlot::Option.new(key, label) },
53
- hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
98
+ hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
54
99
  end
55
100
 
56
101
  # The most diff lines #preview_lines prints; the web shows them all.
@@ -85,7 +130,8 @@ module Samagotchi
85
130
  # the question and what became of it.
86
131
  # @param outcome [String] the answer (#answer_text) or what closed it
87
132
  def summary(outcome, paint: ->(text, _code) { text })
88
- "#{paint.("#{mark}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
133
+ who = @delegate ? "#{@delegate}: " : ""
134
+ "#{paint.("#{mark}#{who}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
89
135
  end
90
136
 
91
137
  # @param answer [Answer] an accepted one
@@ -38,9 +38,12 @@ module Samagotchi
38
38
  # @param mark [String] its prefix ("? ", "! ")
39
39
  # @param options [Array<Option>] key ("1", "yes") and label
40
40
  # @param paint [#call] (text, code) -> coloured text
41
+ # @param note [String, nil] a dim row under the header (a relayed
42
+ # approval's delegate, or where else it waits); gives way with it
41
43
  def initialize(question:, mark:, options:, header: nil, details: [], hint: nil, question_code: 94,
42
- paint: ->(text, _code) { text })
44
+ paint: ->(text, _code) { text }, note: nil)
43
45
  @header = header
46
+ @note = note
44
47
  @question = question.to_s
45
48
  @mark = mark
46
49
  @details = details
@@ -75,6 +78,7 @@ module Samagotchi
75
78
  def layout(width, hint:, details:, header:, folded:)
76
79
  rows = []
77
80
  rows << @paint.(@header, 1) if header && @header
81
+ rows << @paint.(@note, 2) if header && @note
78
82
  question_rows = wrap(@question, width, first: @mark, indent: " " * @mark.size)
79
83
  question_rows = question_rows.first(1) unless details
80
84
  rows.concat(question_rows.map { |row| @paint.(row, @question_code) })
@@ -14,6 +14,7 @@ require_relative "llm/errors"
14
14
  require_relative "host_registry"
15
15
  require_relative "session"
16
16
  require_relative "owner_lock"
17
+ require_relative "bridge/card_store"
17
18
  require_relative "engine"
18
19
  require_relative "guardrails/parent_approvals"
19
20
  require_relative "tools/memory"
@@ -223,6 +224,7 @@ module Samagotchi
223
224
  scratch: @scratch
224
225
  ))
225
226
  claim_session!(session.id) unless @owner_lock
227
+ keep_cards(session)
226
228
  # Saved at once, so a process killed before its first turn leaves a
227
229
  # file the sweep knows to delete.
228
230
  session.save if @scratch
@@ -345,6 +347,15 @@ module Samagotchi
345
347
  @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
346
348
  end
347
349
 
350
+ # The session's cards.json, kept as a worker's Bridge keeps it (it owns
351
+ # the session as a worker would): this REPL's kept turns count on from
352
+ # the saved count, so a card an earlier worker showed stays that many
353
+ # turns back, and its own cards and notices are saved for the next.
354
+ def keep_cards(session)
355
+ path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
356
+ @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
357
+ end
358
+
348
359
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
349
360
  # its own Engine, so no worker may run the session meanwhile.
350
361
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -326,7 +326,7 @@ module Samagotchi
326
326
  },
327
327
  {
328
328
  name: "delegate",
329
- 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. 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).",
329
+ 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).",
330
330
  parameters: {
331
331
  type: "object",
332
332
  properties: {
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  },
357
357
  {
358
358
  name: "delegate_result",
359
- 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 an approval or a question the user must answer.",
359
+ 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.",
360
360
  parameters: {
361
361
  type: "object",
362
362
  properties: {
@@ -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.14.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
 
@@ -1269,6 +1276,10 @@ module Samagotchi
1269
1276
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
1270
1277
  # read by step +step+.
1271
1278
  message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1279
+ # The turn it started (Engine): the page pairs it with that turn's
1280
+ # record by this, not by its place.
1281
+ turn_id = m[:turn_id] || m["turn_id"]
1282
+ message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
1272
1283
  images = m[:images] || m["images"]
1273
1284
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1274
1285
  if norm_role == "assistant"
@@ -1289,6 +1300,19 @@ module Samagotchi
1289
1300
  []
1290
1301
  end
1291
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
+
1292
1316
  # The last turn failed and its prompt went back to the user: the
1293
1317
  # prompt (session.last_prompt) and why, for the page to show after the
1294
1318
  # conversation as it did live. nil otherwise.