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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "bridge_client"
5
+ require_relative "worker_sidecar"
6
+ require_relative "question_desk"
7
+ require_relative "guardrails/parent_approvals"
8
+ require_relative "log"
9
+
10
+ module Samagotchi
11
+ # A child worker taking its parent's answer to a relayed approval. The
12
+ # POST that says "answered" carries no answer and no authority: this
13
+ # worker asks its parent session's live Bridge what the parent's relay
14
+ # holds (RelayDesk, which only the parent's question flow writes), and
15
+ # answers its own question with that, only when it is about this session
16
+ # and the question pending now, and is answered.
17
+ class RelayVerifier
18
+ # The client id a relayed answer is recorded with: relay:<parent8>.
19
+ CLIENT_PREFIX = "relay"
20
+
21
+ # @param engine [Engine] this (the child's) worker's Engine
22
+ def initialize(engine:, state_dir:, session_id:)
23
+ @engine = engine
24
+ @state_dir = state_dir
25
+ @session_id = session_id
26
+ end
27
+
28
+ # @return [Array(Hash, Integer, Hash)] headers, status, body: 200
29
+ # answered, 409 no longer pending, 403 refused (a parent agent's allow
30
+ # beyond this worker's guardrails.parent_approvals), 422
31
+ # relay_unverified (logged; the question stays open)
32
+ def answer(relay_id:, question_id:)
33
+ parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
34
+ relay = parent_relay(parent_id, relay_id) or return unverified("the parent has no such relay", relay_id)
35
+ return unverified("the relay is about another session", relay_id) unless relay["child_id"] == @session_id
36
+ return unverified("the relay is about another question", relay_id) unless relay["child_question_id"] == question_id
37
+ return unverified("the relay is #{relay["state"]}, not answered", relay_id) unless relay["state"] == "answered"
38
+
39
+ apply(relay, parent_id: parent_id, question_id: question_id, relay_id: relay_id)
40
+ end
41
+
42
+ private
43
+
44
+ def apply(relay, parent_id:, question_id:, relay_id:)
45
+ answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
46
+ parent_agent = relay["by"] != "user"
47
+ client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
48
+ if answer["dismissed"]
49
+ return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
50
+
51
+ log("relay_answered", relay_id, id: question_id, dismissed: true, by: relay["by"])
52
+ return [{}, 200, { status: "dismissed", question_id: question_id }]
53
+ end
54
+
55
+ selected = labels(answer["selected_indices"], question_id) or return unverified("the answer's option is out of range", relay_id)
56
+ result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
57
+ client_id: client_id, parent_agent: parent_agent)
58
+ log("relay_answered", relay_id, id: question_id, by: relay["by"])
59
+ [{}, 200, { status: "answered", question_id: question_id, answer: result }]
60
+ rescue QuestionDesk::NotPending
61
+ not_pending(question_id)
62
+ rescue QuestionDesk::Refused => e
63
+ log("relay_refused", relay_id, id: question_id, reason: e.reason)
64
+ [{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
65
+ detail: Guardrails::ParentApprovals.message(e.reason) }]
66
+ rescue ArgumentError => e
67
+ unverified("the answer doesn't fit the question: #{e.message}", relay_id)
68
+ end
69
+
70
+ # The parent's option indices as this question's own labels (the
71
+ # parent's are relabelled; the order is the same). nil when one is out
72
+ # of range; [] for a freeform-only deny.
73
+ def labels(indices, question_id)
74
+ pending = @engine.pending_question
75
+ return [] unless pending && pending[:id].to_s == question_id
76
+
77
+ options = Array(pending[:options])
78
+ indices = Array(indices)
79
+ return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
80
+
81
+ indices.map { |index| options[index] }
82
+ end
83
+
84
+ def own_parent_id
85
+ Session.load(@session_id, state_dir: @state_dir).parent_id
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ # @return [Hash, nil] what the parent's live Bridge says about the relay
91
+ def parent_relay(parent_id, relay_id)
92
+ dir = Session.session_dir(parent_id, state_dir: @state_dir)
93
+ client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
94
+ response = client.relay_status(relay_id)
95
+ response.ok? ? response.json : nil
96
+ rescue SystemCallError, IOError
97
+ nil
98
+ end
99
+
100
+ def not_pending(question_id)
101
+ [{}, 409, { error: "question_not_pending", detail: "no pending question #{question_id}" }]
102
+ end
103
+
104
+ def unverified(why, relay_id)
105
+ log("relay_unverified", relay_id, why: why)
106
+ [{}, 422, { error: "relay_unverified", detail: why }]
107
+ end
108
+
109
+ def log(event, relay_id, **fields)
110
+ Log.info(:bridge, event, sid: @session_id, relay: relay_id.to_s[0, 8], **fields)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "worker_sidecar"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # A child's look at its parent while its question is relayed there: when
8
+ # the parent's worker is gone, its relay card and RelayDesk went with it,
9
+ # so the question's relay mark is cleared (reason parent_gone) and its
10
+ # cards say "answer here" again. Outside the question desk: it clears a
11
+ # mark, never cancels the question. Stops once the question is no longer
12
+ # that relay's.
13
+ module RelayWatcher
14
+ INTERVAL = 2
15
+
16
+ module_function
17
+
18
+ # @param engine [Engine] the child's
19
+ # @param parent_dir [String] the parent session's folder (its sidecar)
20
+ # @param live [#call] whether the parent's worker is up
21
+ # @return [Thread]
22
+ def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
23
+ live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
24
+ Thread.new do
25
+ loop do
26
+ sleep(interval)
27
+ break unless relayed?(engine.pending_question, question_id, relay_id)
28
+ next if live.call
29
+
30
+ Log.info(:bridge, "relay_parent_gone", id: question_id)
31
+ engine.annotate_question(question_id, relayed_to: nil, reason: "parent_gone")
32
+ break
33
+ end
34
+ rescue StandardError => e
35
+ Log.warn(:bridge, "relay_watch_failed", error: e.class.name, message: e.message)
36
+ end.tap { |thread| thread.report_on_exception = false }
37
+ end
38
+
39
+ def relayed?(pending, question_id, relay_id)
40
+ relay = pending && pending[:relayed_to]
41
+ return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
42
+
43
+ (relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
44
+ end
45
+ end
46
+ end
@@ -50,10 +50,13 @@ module Samagotchi
50
50
  # a failure replaces the note and leaves the count as it was.
51
51
  # @param owner_grace [Numeric, nil] seconds with no live worker before
52
52
  # :worker_gone (one that died before its rescue leaves it running)
53
+ # @param interject [#call, nil] called once a poll, before the sleep
54
+ # (the delegate wait relays other children's approvals there); the
55
+ # time it takes doesn't count against +timeout+
53
56
  # @return [Result]
54
57
  # @raise [ArgumentError] no such session
55
58
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
56
- baseline: nil, owner_grace: nil)
59
+ baseline: nil, owner_grace: nil, interject: nil)
57
60
  deadline = timeout && (monotonic + timeout.to_f)
58
61
  seen_running = false
59
62
  gone_since = nil
@@ -104,6 +107,12 @@ module Samagotchi
104
107
 
105
108
  return Result.new(status: :timeout) if deadline && monotonic > deadline
106
109
 
110
+ if interject
111
+ started = monotonic
112
+ interject.call
113
+ deadline += monotonic - started if deadline
114
+ next if cancelled.call
115
+ end
107
116
  sleep(poll_interval)
108
117
  end
109
118
  end
@@ -111,7 +111,9 @@ module Samagotchi
111
111
  return nil unless live && pending.is_a?(Hash) && pending[:id]
112
112
 
113
113
  kind = pending[:kind].to_s
114
- { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
114
+ relayed = pending[:relayed_to]
115
+ relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
116
+ { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
115
117
  end
116
118
 
117
119
  # The stored project root, else (a file from before the field) the
@@ -464,7 +466,18 @@ module Samagotchi
464
466
  entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
465
467
  end
466
468
  end
467
- message
469
+ symbolize_empty_answer(message)
470
+ end
471
+
472
+ # An empty-answer note's marker (TurnNote.empty) and its steps, as
473
+ # they were saved.
474
+ def symbolize_empty_answer(message)
475
+ marker = message[:empty_answer]
476
+ return message unless marker.is_a?(Hash)
477
+
478
+ marker = marker.transform_keys(&:to_sym)
479
+ marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
480
+ message.merge(empty_answer: marker)
468
481
  end
469
482
 
470
483
  def session_path(session_id, state_dir:)
@@ -241,7 +241,8 @@ module Samagotchi
241
241
  # a turn running, recap = the saved recap's first sentence, project =
242
242
  # Session#project_root, waiting = the kind of question it waits on
243
243
  # ("question", "approval", "hook"; Session#waiting_question) or nil,
244
- # waiting_id = that question's id or nil
244
+ # waiting_id = that question's id or nil, relayed_to = the parent
245
+ # whose card it waits in too (the approval relay), its short id, or nil
245
246
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
246
247
  project_root: nil, include_archived: false, sort: nil, order: nil)
247
248
  sd = state_dir || Session.default_state_dir
@@ -269,7 +270,7 @@ module Samagotchi
269
270
  # waiting: the question's kind, waiting_id: its id (what chi answer
270
271
  # --question takes); both nil with none.
271
272
  def self.waiting_fields(waiting)
272
- { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
273
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
273
274
  end
274
275
  private_class_method :waiting_fields
275
276
 
@@ -83,6 +83,23 @@ module Samagotchi
83
83
  nil
84
84
  end
85
85
 
86
+ # One turn's tool records from the session's saved analytics.json, in
87
+ # call order (iteration, then call_index), string-keyed as saved: what a
88
+ # join replays the turn's tool rows' durations from. Empty without a turn
89
+ # id (an older session's prompt), the file, or records.
90
+ # @param session_dir [String]
91
+ # @param turn_id [String, nil]
92
+ # @return [Array<Hash>]
93
+ def self.saved_tool_records(session_dir, turn_id)
94
+ return [] if turn_id.to_s.empty?
95
+
96
+ records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
97
+ Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
98
+ .sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
99
+ rescue JSON::ParserError, SystemCallError, TypeError
100
+ []
101
+ end
102
+
86
103
  # "ctx 12%" for the session lists, "" when unknown.
87
104
  # @param pct [Float, nil]
88
105
  # @return [String]
@@ -148,15 +148,17 @@ module Samagotchi
148
148
  # ctx_pct: how full the context was after the last turn, or nil
149
149
  # waiting: the kind of question it waits on (question, approval,
150
150
  # hook: chi answer or the web answers it), or nil; waiting_id: its
151
- # id, for chi answer --question
151
+ # id, for chi answer --question; relayed_to: the parent session (short
152
+ # id) whose card it also waits in (the approval relay), or nil
152
153
  keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
153
- waiting_id]
154
+ waiting_id relayed_to]
154
155
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
155
156
  when "tsv"
156
157
  summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
157
158
  else
158
159
  summaries.each do |summary|
159
- state = if summary[:waiting] then "waiting"
160
+ state = if summary[:relayed_to] then "waiting (in parent #{summary[:relayed_to]})"
161
+ elsif summary[:waiting] then "waiting"
160
162
  elsif summary[:busy] then "running"
161
163
  else summary[:live] ? "live" : summary[:status].to_s
162
164
  end
@@ -206,7 +208,11 @@ module Samagotchi
206
208
  )
207
209
  # A question waits for an answer (chi answer, the web, chi --attach).
208
210
  live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
209
- status = s.waiting_question(live: !!live) ? "waiting" : s.status
211
+ waiting = s.waiting_question(live: !!live)
212
+ status = if waiting&.dig(:relayed_to) then "waiting (in parent #{waiting[:relayed_to]})"
213
+ elsif waiting then "waiting"
214
+ else s.status
215
+ end
210
216
  "#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
211
217
  end
212
218
  if project
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  # @param muted_memories [Array<String>] --mute: hidden from a new session
39
39
  # @return [Symbol] :detached, :closed when the worker went away,
40
40
  # :failed when the --model switch didn't go through, or (input from a
41
- # pipe) :turn_failed / :unanswered (AttachedLoop#run)
41
+ # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
42
42
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
43
43
  memories: [], muted_memories: [])
44
44
  client = connect(attach: attach, shared: shared, resume: resume, model: model,
@@ -56,6 +56,7 @@ module Samagotchi
56
56
  close_surface(surface)
57
57
  end
58
58
  report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
59
+ report_ended(ended)
59
60
  ended
60
61
  end
61
62
 
@@ -68,9 +69,17 @@ module Samagotchi
68
69
  err.flush
69
70
  end
70
71
 
72
+ # A turn of ours that ended with no answer (input from a pipe): one
73
+ # line on stderr, as the in-process `chi -p --non-interactive` says
74
+ # it, so a script doesn't take the silence for an answer (exit 1).
75
+ def report_ended(ended, err: $stderr)
76
+ err.puts(TerminalUI::EMPTY_ANSWER_ERROR) if ended == :empty_answer
77
+ end
78
+
71
79
  # chi's exit status after #run: 0 detached, 3 a question left waiting
72
80
  # for an answer (input from a pipe ran out; chi --attach or chi answer
73
- # answers it), 1 anything else (a failed turn, the worker gone).
81
+ # answers it), 1 anything else (a failed turn, an empty answer, the
82
+ # worker gone).
74
83
  # @param ended [Symbol] what #run returned
75
84
  # @return [Integer]
76
85
  def exit_status(ended)
@@ -20,6 +20,7 @@ require_relative "../model_profile"
20
20
  require_relative "../output_formatter"
21
21
  require_relative "../session_commands"
22
22
  require_relative "../session_manager"
23
+ require_relative "../session_metrics"
23
24
  require_relative "../guardrails/parent_approvals"
24
25
  require_relative "../tool_activity"
25
26
  require_relative "../web/message_parts"
@@ -143,8 +144,9 @@ module Samagotchi
143
144
  # asks it to exit too, and it does unless something still needs it.
144
145
  # @return [Symbol] :detached, :closed when the worker went away,
145
146
  # :failed when the first command (--model) didn't go through, or
146
- # (wait_at_eof) :turn_failed when one of this run's prompts failed and
147
- # :unanswered when a question came after the input ended
147
+ # (wait_at_eof) :turn_failed when one of this run's prompts failed,
148
+ # :empty_answer when one ended with no answer, and :unanswered when
149
+ # a question came after the input ended
148
150
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
149
151
  # Interrupt = Ctrl-C); defaults to Reline
150
152
  def run(input: nil)
@@ -259,7 +261,9 @@ module Samagotchi
259
261
  ask(event[:pending_question])
260
262
  when :question_answered then question_answered(event)
261
263
  when :question_cancelled
262
- close_question("(question cancelled)") if @question
264
+ close_question(@question.closed_text(event[:reason])) if @question
265
+ # Relayed to the parent's card, or no longer: the widget says so.
266
+ when :question_relay then question_relayed(event)
263
267
  # Written while the session sat idle: shown at the open prompt. One
264
268
  # collected just as a turn started describes the chat before it.
265
269
  when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
@@ -569,6 +573,7 @@ module Samagotchi
569
573
  id = (event[:origin] || {})[:enqueued_id]
570
574
  ours = @open_ids.include?(id) || !@merged_ids.empty?
571
575
  @own_failed = true if ours && event[:type] == :turn_failed
576
+ @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
572
577
  @open_ids.delete(id)
573
578
  @open_ids.subtract(@merged_ids)
574
579
  @merged_ids.clear
@@ -591,7 +596,9 @@ module Samagotchi
591
596
  # The input ended and nothing of ours is left to wait for.
592
597
  def end_of_input
593
598
  detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
594
- @own_failed ? :turn_failed : :detached
599
+ return :turn_failed if @own_failed
600
+
601
+ @own_empty ? :empty_answer : :detached
595
602
  end
596
603
 
597
604
  def unanswered_question(pending)
@@ -757,6 +764,13 @@ module Samagotchi
757
764
  end
758
765
  end
759
766
 
767
+ def question_relayed(event)
768
+ return unless @question && @question.id == event[:id].to_s
769
+
770
+ @question.relayed_to = event[:relayed_to]
771
+ @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
772
+ end
773
+
760
774
  def question_answered(event)
761
775
  return unless @question
762
776
  return close_question(nil) if @answered_ids.include?(event[:id])
@@ -939,39 +953,76 @@ module Samagotchi
939
953
 
940
954
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
941
955
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
942
- render_join_steps(messages, messages.index { |m| m.equal?(exchange[last_user]) })
943
- # The saved answer is raw: the latest keeps its thinking, and one may
944
- # be only a tool call.
945
- answer = exchange[(last_user + 1)..].reverse_each
946
- .map { |m| OutputFormatter.strip_markup(m[:content]) }
947
- .find { |text| !text.empty? }
948
- @screen.commit(last_lines(answer)) if answer
956
+ from = messages.index { |m| m.equal?(exchange[last_user]) }
957
+ render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
958
+ # A turn that ended with no answer: its notice, as live.
959
+ empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
960
+ if empty
961
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
962
+ else
963
+ # The saved answer is raw: the latest keeps its thinking, and one may
964
+ # be only a tool call.
965
+ answer = exchange[(last_user + 1)..].reverse_each
966
+ .map { |m| OutputFormatter.strip_markup(m[:content]) }
967
+ .find { |text| !text.empty? }
968
+ @screen.commit(last_lines(answer)) if answer
969
+ end
949
970
  render_join_notes(messages, after_exchange: true)
950
971
  end
951
972
 
952
973
  # What the last exchange did, as the live turn drew it: each saved
953
- # call's tool row (action, params, status from its result; no
954
- # duration, which the session doesn't keep) and a plugin's steer
955
- # where it came. The step texts stay out, as live (the ticker's).
956
- def render_join_steps(messages, from)
974
+ # call's tool row (action, params, status from its result, and its
975
+ # duration from +durations+, the turn's saved tool records) and a
976
+ # plugin's steer where it came. The step texts stay out, as live (the
977
+ # ticker's).
978
+ def render_join_steps(messages, from, durations: [])
979
+ records = durations.dup
957
980
  messages.each_with_index.drop(from + 1).each do |m, i|
958
981
  if Steer.steer?(m)
959
982
  @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
960
983
  elsif %w[model assistant].include?(m[:role].to_s)
961
984
  responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
962
- Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each { |part| @screen.commit(join_tool_line(part)) }
985
+ Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
986
+ @screen.commit(join_tool_line(part, duration_ms: join_duration(records, part)))
987
+ end
963
988
  end
964
989
  end
965
990
  end
966
991
 
992
+ # The prompt's turn's saved tool records (SessionMetrics, in this
993
+ # machine's state dir, as #attach_images): none for a prompt with no
994
+ # turn id (an older session) or a session with no analytics.
995
+ def join_tool_records(prompt)
996
+ SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
997
+ rescue Session::InvalidId
998
+ []
999
+ end
1000
+
1001
+ # The duration of +part+'s call: the next record of its tool, in call
1002
+ # order, consumed with the ones it skipped. By tool and order, not by
1003
+ # iteration: an empty retry spends an iteration that left no message.
1004
+ # nil (no duration shown) for a call that left none: it never
1005
+ # finished, or a worker before turn ids saved it.
1006
+ def join_duration(records, part)
1007
+ return nil if part[:output].nil?
1008
+
1009
+ at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
1010
+ return nil unless at
1011
+
1012
+ duration = records[at]["duration_ms"]
1013
+ records.shift(at + 1)
1014
+ duration.is_a?(Numeric) ? duration : nil
1015
+ end
1016
+
967
1017
  # A saved call's row; "no result" for a call the turn never answered
968
1018
  # (it ended first).
969
- def join_tool_line(part)
1019
+ def join_tool_line(part, duration_ms: nil)
970
1020
  output = part[:output]
971
1021
  status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
972
1022
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
973
1023
  params: part[:params], status: status }
974
- "#{format_tool_activity_line(activity)}#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1024
+ "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1025
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
975
1026
  end
976
1027
 
977
1028
  # The context notes that came since the last prompt (all of them in a
@@ -167,7 +167,12 @@ module Samagotchi
167
167
 
168
168
  @view.print_line(@view.format_tool_activity_line(activity))
169
169
  end
170
- @view.print_line(summary[:output])
170
+ # No answer: chi's muted notice, never an empty answer line.
171
+ if summary[:empty_answer]
172
+ @view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
173
+ else
174
+ @view.print_line(summary[:output])
175
+ end
171
176
  @view.print_line("iteration limit reached") if summary[:resumable]
172
177
  end
173
178
 
@@ -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) })
@@ -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: {