samagotchi 0.15.0 → 0.17.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +23 -0
  5. data/docs/cli.md +7 -4
  6. data/docs/configuration.md +7 -5
  7. data/docs/guardrails.md +16 -2
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/memory.md +39 -0
  10. data/docs/plugins.md +2 -0
  11. data/docs/sessions.md +10 -4
  12. data/docs/sub-agent.md +16 -3
  13. data/docs/testing.md +47 -0
  14. data/lib/samagotchi/answer_display.rb +6 -2
  15. data/lib/samagotchi/bridge/card_store.rb +16 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
  17. data/lib/samagotchi/bridge.rb +122 -7
  18. data/lib/samagotchi/bridge_client.rb +33 -6
  19. data/lib/samagotchi/bundle_command.rb +13 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  22. data/lib/samagotchi/bundles/system/self_map.md +3 -1
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  24. data/lib/samagotchi/engine.rb +82 -18
  25. data/lib/samagotchi/kernel_loop.rb +16 -5
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  27. data/lib/samagotchi/llm/model_result.rb +12 -7
  28. data/lib/samagotchi/log_subscriber.rb +1 -0
  29. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  30. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  31. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  32. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  33. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  34. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  36. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  37. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  38. data/lib/samagotchi/parent_report.rb +8 -0
  39. data/lib/samagotchi/plugin/context.rb +14 -2
  40. data/lib/samagotchi/question_desk.rb +97 -18
  41. data/lib/samagotchi/relay.rb +88 -0
  42. data/lib/samagotchi/relay_desk.rb +73 -0
  43. data/lib/samagotchi/relay_verifier.rb +113 -0
  44. data/lib/samagotchi/relay_watcher.rb +46 -0
  45. data/lib/samagotchi/reply_wait.rb +10 -1
  46. data/lib/samagotchi/self_report.rb +49 -3
  47. data/lib/samagotchi/send_command.rb +45 -6
  48. data/lib/samagotchi/session.rb +15 -2
  49. data/lib/samagotchi/session_manager.rb +3 -2
  50. data/lib/samagotchi/session_metrics.rb +154 -48
  51. data/lib/samagotchi/sessions_command.rb +10 -4
  52. data/lib/samagotchi/system_prompt.rb +45 -4
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
  55. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
  56. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  57. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  58. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  59. data/lib/samagotchi/terminal_ui.rb +24 -0
  60. data/lib/samagotchi/tool_declarations.rb +2 -2
  61. data/lib/samagotchi/tools/builtins.rb +11 -3
  62. data/lib/samagotchi/tools/delegate_relay.rb +220 -0
  63. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  64. data/lib/samagotchi/tools/peers.rb +7 -4
  65. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  66. data/lib/samagotchi/turn_note.rb +30 -2
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +22 -1
  69. data/lib/samagotchi/web/public/app.js +60 -33
  70. data/lib/samagotchi/web/public/index.html +7 -0
  71. data/lib/samagotchi/web/public/notify.js +22 -5
  72. data/lib/samagotchi/web/public/question_card.js +45 -3
  73. data/lib/samagotchi/web/public/question_cards.js +49 -1
  74. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  75. data/lib/samagotchi/web/public/stage_model.js +8 -3
  76. data/lib/samagotchi/web/public/stage_view.js +20 -3
  77. data/lib/samagotchi/web/public/strip.js +11 -0
  78. data/lib/samagotchi/web/public/timing.js +37 -15
  79. data/lib/samagotchi/web/public/turn_events.js +22 -3
  80. data/lib/samagotchi/web/public/turn_view.js +34 -12
  81. data/lib/samagotchi/worker.rb +28 -2
  82. data/lib/samagotchi/worker_sidecar.rb +4 -1
  83. metadata +8 -1
@@ -63,7 +63,8 @@ module Samagotchi
63
63
  when :generation_chunk
64
64
  @view.generation_feedback_chunk(event)
65
65
  when :tool_call_started
66
- @tool_started_at[tool_call_key(event)] = @clock.call
66
+ # A joined running call (a snapshot's replay) started elapsed_ms ago.
67
+ @tool_started_at[tool_call_key(event)] = @clock.call - (event[:elapsed_ms].to_f / 1000)
67
68
  @view.tool_call_feedback_started(event)
68
69
  when :tool_call_completed
69
70
  @view.clear_generation_retry
@@ -167,7 +168,12 @@ module Samagotchi
167
168
 
168
169
  @view.print_line(@view.format_tool_activity_line(activity))
169
170
  end
170
- @view.print_line(summary[:output])
171
+ # No answer: chi's muted notice, never an empty answer line.
172
+ if summary[:empty_answer]
173
+ @view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
174
+ else
175
+ @view.print_line(summary[:output])
176
+ end
171
177
  @view.print_line("iteration limit reached") if summary[:resumable]
172
178
  end
173
179
 
@@ -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) })
@@ -232,6 +232,10 @@ module Samagotchi
232
232
  @engine.session = session
233
233
  messages = messages_for(session)
234
234
 
235
+ # As typed at the prompt: a session command (/models, !ls) runs as
236
+ # the command, not as a turn.
237
+ return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
238
+
235
239
  if @prompt && @non_interactive
236
240
  # Headless / CI mode: run directly without TTY rendering.
237
241
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
@@ -281,6 +285,22 @@ module Samagotchi
281
285
 
282
286
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
283
287
 
288
+ # `chi -p "/models" --non-interactive`: the command's output; a refused
289
+ # one fails the run (bin/chi exits 1). The session is saved only when
290
+ # the command changed it.
291
+ # @return [Symbol, nil] :turn_failed when the command was refused
292
+ def run_one_shot_command(session)
293
+ line = @prompt.strip
294
+ result = begin
295
+ @commands.run(line)
296
+ rescue StandardError => e
297
+ SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
298
+ end
299
+ @surface.commit(result.output) unless result.output.to_s.empty?
300
+ session.save unless Array(result.changed).empty?
301
+ :turn_failed if result.status == :error
302
+ end
303
+
284
304
  # Delete the scratch session, the last thing the REPL does. Quiet after
285
305
  # a one-shot or on the way out of an error or a signal.
286
306
  def delete_scratch(session, quiet:)
@@ -1184,6 +1204,10 @@ module Samagotchi
1184
1204
 
1185
1205
  # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1186
1206
  def read_choice_line(question_prompt)
1207
+ # Off a terminal the ? prompt can't be taken back: at the input's end
1208
+ # it would stay bare above the summary. eof? waits as gets would.
1209
+ return nil if !$stdin.tty? && $stdin.eof?
1210
+
1187
1211
  @surface.set_slot(:editor, [question_prompt])
1188
1212
  line = $stdin.gets
1189
1213
  # A pipe doesn't echo: the line completes the ? prompt here, once,
@@ -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: {
@@ -105,12 +105,20 @@ module Samagotchi
105
105
  # What execute and task_create export into the commands they run: a
106
106
  # chi started there answers as a parent agent
107
107
  # (Guardrails::ParentApprovals.parent_process?), even under a PTY
108
- # wrapper. The session's id, "chi" without one.
108
+ # wrapper. The session's id, "chi" without one. And the model the
109
+ # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
110
+ # /model), which chi self reports; unset when unknown, so a value
111
+ # inherited from an outer session never passes for this one.
109
112
  def parent_env(kctx)
110
- id = kctx.peers.respond_to?(:session_id) ? kctx.peers.session_id.to_s : ""
111
- { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id }
113
+ peers = kctx.peers
114
+ id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
115
+ model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
116
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
117
+ SESSION_MODEL_ENV => model.empty? ? nil : model }
112
118
  end
113
119
 
120
+ SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
121
+
114
122
  # Stop flips the turn's controller, seen through the Engine's PeerView
115
123
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
116
124
  def cancelled_proc(kctx)
@@ -0,0 +1,220 @@
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
+ ensure
59
+ # A relay that failed before it settled closes, so the desk (which
60
+ # prunes only settled ones) keeps none open; a settled one stays.
61
+ desk&.close(relay_id) if relay_id
62
+ end
63
+
64
+ # Closes the card when the child's question is no longer pending (it
65
+ # was answered or cancelled there) or its worker is gone.
66
+ def watch(child_id, qid, state_dir)
67
+ lambda do
68
+ unless SessionManager.worker_live?(child_id, state_dir: state_dir)
69
+ next "child_gone"
70
+ end
71
+
72
+ pending = Session.load(child_id, state_dir: state_dir).pending_question
73
+ pending && pending[:id].to_s == qid ? nil : "answered_on_child"
74
+ rescue ArgumentError
75
+ "child_gone"
76
+ end
77
+ end
78
+
79
+ # Record the parent's answer, tell the child, and say what came of it.
80
+ def settle(answer, fields, client:, desk:, relay_id:, qid:)
81
+ what = what_text(fields[:approval])
82
+ if answer.is_a?(Hash) && answer[:error] == "cancelled"
83
+ desk.close(relay_id)
84
+ return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
85
+ end
86
+
87
+ dismissed = !(answer.is_a?(Hash) && answer[:selected])
88
+ by = answer.is_a?(Hash) && answer[:by] == "parent_agent" ? "parent_agent" : "user"
89
+ desk.record(relay_id, selected_indices: dismissed ? [] : Array(answer[:selected_indices]),
90
+ freeform: dismissed ? nil : answer[:freeform], dismissed: dismissed, by: by)
91
+ response = post(client, "answered", relay_id, qid)
92
+ Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
93
+ end
94
+
95
+ def closed(reason, what, client:, relay_id:, qid:)
96
+ case reason
97
+ when "answered_on_child" then Outcome.new(line: line(what, "answered on the child"), stopped: false)
98
+ when "child_gone" then Outcome.new(line: line(what, "not delivered (the child's worker is gone)"), stopped: false)
99
+ else
100
+ # A Stop: the child's question stays open there.
101
+ post(client, "closed", relay_id, qid, reason: "stopped")
102
+ Outcome.new(line: line(what, "still open (your turn was stopped)"), stopped: true)
103
+ end
104
+ end
105
+
106
+ # The outcome of an answer the child was told about.
107
+ def delivered(response, answer, fields, dismissed)
108
+ return "not delivered (the child's worker is gone)" unless response
109
+
110
+ case response.status
111
+ when 200 then dismissed ? "dismissed (denied)" : verdict(answer, fields)
112
+ when 409 then "answered on the child"
113
+ when 403 then "refused by the child (a parent agent may not allow it); it waits for the user there"
114
+ else "not delivered (#{response.json&.fetch("error", nil) || response.status})"
115
+ end
116
+ end
117
+
118
+ # allowed once / allowed for its session / denied ("why")
119
+ def verdict(answer, fields)
120
+ scopes = Array(fields.dig(:approval, :scopes))
121
+ index = Array(answer[:selected_indices]).first
122
+ if index && index < scopes.size
123
+ return case scopes[index].to_s
124
+ when "once" then "allowed once"
125
+ when "session" then "allowed for the delegate's session"
126
+ when "repo" then "allowed in this repo or directory"
127
+ when "rule" then "allowed by rule #{fields.dig(:approval, :rule)}"
128
+ else "allowed"
129
+ end
130
+ end
131
+
132
+ why = answer[:freeform].to_s.strip
133
+ why.empty? ? "denied" : "denied (#{why.inspect})"
134
+ end
135
+
136
+ def line(what, outcome) = "approval relayed to your user: #{what} → #{outcome}"
137
+
138
+ # execute: git push origin main
139
+ def what_text(facts)
140
+ facts = facts.is_a?(Hash) ? facts : {}
141
+ tool = facts[:label] || facts[:tool] || "tool"
142
+ what = facts[:command] || Array(facts[:paths]).join(", ")
143
+ what = facts[:args].to_s if what.to_s.empty?
144
+ what.to_s.empty? ? tool.to_s : "#{tool}: #{what}"
145
+ end
146
+
147
+ # The parent's other running children, while it waits on one (D2):
148
+ # their approvals are relayed too, oldest first, one card at a time.
149
+ # Each outcome is kept for that child's next DelegateWait result
150
+ # (DelegateWait.relayed_outcomes). Every question is relayed once per
151
+ # wait.
152
+ class Others
153
+ # @param parent_id [String]
154
+ # @param except [String] the child waited on (its own approvals come
155
+ # through ReplyWait)
156
+ # @param on_outcome [#call] (child_id, Outcome)
157
+ def initialize(parent_id:, except:, relay:, state_dir:, on_outcome:, every: OTHERS_EVERY)
158
+ @parent_id = parent_id
159
+ @except = except
160
+ @relay = relay
161
+ @state_dir = state_dir
162
+ @on_outcome = on_outcome
163
+ @every = every
164
+ @done = {}
165
+ @next_at = 0.0
166
+ end
167
+
168
+ # Relay the oldest waiting approval of another child, if any (a
169
+ # ReplyWait interject). Throttled to one look every +every+ s.
170
+ def poll
171
+ return if monotonic < @next_at
172
+
173
+ @next_at = monotonic + @every
174
+ waiting = self.waiting
175
+ return if waiting.empty?
176
+
177
+ child_id, question = waiting.first
178
+ @done[question[:id].to_s] = true
179
+ outcome = DelegateRelay.call(child_id, question, relay: @relay, state_dir: @state_dir, more: waiting.size - 1)
180
+ @on_outcome.call(child_id, outcome)
181
+ # The next one, if any, right after.
182
+ @next_at = 0.0
183
+ end
184
+
185
+ # Other children's approvals not yet relayed in this wait.
186
+ # @return [Integer]
187
+ def count = waiting.size
188
+
189
+ # @return [Array<Array(String, Hash)>] [child id, pending question],
190
+ # oldest question first
191
+ def waiting
192
+ rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
193
+ row[:id] != @except && row[:waiting] == Guardrails::Approval::KIND && !@done[row[:waiting_id].to_s]
194
+ end
195
+ rows.filter_map do |row|
196
+ pending = Session.load(row[:id], state_dir: @state_dir).pending_question
197
+ [row[:id], pending] if pending && pending[:id].to_s == row[:waiting_id].to_s
198
+ rescue ArgumentError
199
+ nil
200
+ end.sort_by { |_, pending| pending[:created_at].to_s }
201
+ end
202
+
203
+ private
204
+
205
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+ end
207
+
208
+ # @return [BridgeClient::Response, nil] nil when the child's worker
209
+ # can't be reached
210
+ def post(client, action, relay_id, qid, **extra)
211
+ return nil unless client
212
+
213
+ client.relay(action: action, relay_id: relay_id, question_id: qid, **extra)
214
+ rescue SystemCallError, IOError => e
215
+ Log.info(:turn, "relay_post_failed", action: action, error: e.class.name)
216
+ nil
217
+ end
218
+ end
219
+ end
220
+ 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,13 @@ 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), and the model the session runs on (model_ref: the
11
+ # resolved ref, live after /model). Engine hands KernelLoop one that follows its current
12
+ # session (Engine::PeerView); this plain one is for specs and a kernel
13
+ # without an Engine.
14
+ Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
12
15
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
13
16
  def cancelled?
14
17
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
21
21
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
22
22
  # Set by chi (Builtins.parent_env); the model's env can't change it.
23
- MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
23
+ MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
24
24
 
25
25
  module_function
26
26
 
@@ -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):