samagotchi 0.59.0 → 0.61.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +11 -6
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +82 -5
  9. data/docs/internals/engine.md +50 -13
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +6 -3
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +57 -4
  14. data/docs/sessions.md +7 -3
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/answer_display.rb +4 -3
  18. data/lib/samagotchi/answer_refs.rb +210 -0
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/card_store.rb +6 -1
  21. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  26. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +33 -5
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +101 -23
  32. data/lib/samagotchi/hooks/registry.rb +3 -1
  33. data/lib/samagotchi/interrupted_turn.rb +39 -0
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +40 -12
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +16 -8
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +8 -4
  41. data/lib/samagotchi/plugin/context.rb +16 -3
  42. data/lib/samagotchi/prompt_files.rb +127 -0
  43. data/lib/samagotchi/ref.rb +141 -0
  44. data/lib/samagotchi/reply_wait.rb +6 -2
  45. data/lib/samagotchi/session.rb +53 -5
  46. data/lib/samagotchi/session_manager.rb +64 -6
  47. data/lib/samagotchi/session_metrics.rb +8 -2
  48. data/lib/samagotchi/session_setup.rb +11 -5
  49. data/lib/samagotchi/system_prompt.rb +71 -15
  50. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  51. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  52. data/lib/samagotchi/terminal_ui.rb +21 -5
  53. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  54. data/lib/samagotchi/turn_flow.rb +22 -3
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +5 -0
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +15 -18
  59. data/lib/samagotchi/web/public/favicon.svg +1 -0
  60. data/lib/samagotchi/web/public/index.html +6 -3
  61. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  62. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  63. data/lib/samagotchi/web/public/refs.js +129 -15
  64. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  65. data/lib/samagotchi/web/public/turn_view.js +17 -7
  66. data/lib/samagotchi/worker.rb +169 -21
  67. data/lib/samagotchi/worker_signals.rb +81 -0
  68. metadata +11 -1
@@ -111,13 +111,15 @@ module Samagotchi
111
111
  end
112
112
 
113
113
  # The continue offer was answered yes: resume the conversation without a
114
- # user message, with the iteration limit the offer's turn had. A failure
114
+ # user message, with the iteration limit the offer's turn had, from the
115
+ # run that turn started (TurnFlow#resume_from: its reasoning stays). A failure
115
116
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
116
117
  # asks again, one before the turn began too (logged, no :turn_failed).
117
118
  # The answer's text is carried into the turn as it begins; a turn that
118
119
  # never began leaves it to no other.
119
120
  def run_continue_turn(command)
120
121
  offer = @turn_flow.offer
122
+ resume_from = @turn_flow.resume_from
121
123
  close("answered")
122
124
  steer = @continue_steer
123
125
  @continue_steer = nil
@@ -129,7 +131,8 @@ module Samagotchi
129
131
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
130
132
  return announce_offer(offer)
131
133
  end
132
- @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
134
+ @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
135
+ resume_from: resume_from,
133
136
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
134
137
  if error
135
138
  announce_offer(offer)
@@ -64,6 +64,7 @@ require_relative "sampling_settings"
64
64
  require_relative "thinking"
65
65
  require_relative "reasoning_history"
66
66
  require_relative "answer_display"
67
+ require_relative "answer_refs"
67
68
  require_relative "edit_preview"
68
69
  require_relative "question_desk"
69
70
  require_relative "child_reports"
@@ -105,11 +106,13 @@ module Samagotchi
105
106
  # @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
106
107
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
107
108
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
109
+ # @param appended_prompt_files [Array<String>] --append-system-prompt's files (absolute paths; a session's
110
+ # appended_prompt_files): their text goes into the system prompt after config.yml's append_system_prompt:
108
111
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
109
112
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
110
113
  # delegate (a child would outlive it) nor plugin fork
111
114
  def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
112
- plugins: true, scratch: false, model_typed: nil)
115
+ plugins: true, scratch: false, model_typed: nil, appended_prompt_files: [])
113
116
  # The turn as other threads see it (flag, cancel controller, sink,
114
117
  # steers) and the idle layer's activity clock. Built first: a plugin
115
118
  # may steer or ask whether a turn runs while it loads.
@@ -205,7 +208,7 @@ module Samagotchi
205
208
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
206
209
  thinking: -> { turn_thinking }, model: -> { model_identity },
207
210
  model_notes: -> { model_notes }, memories: memories,
208
- muted_memory_names: @muted_memory_names)
211
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
209
212
  @session = nil
210
213
  @session_observer = SessionObserver.new
211
214
  @metrics = SessionMetrics.new
@@ -1013,7 +1016,8 @@ module Samagotchi
1013
1016
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1014
1017
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1015
1018
  thinking: thinking_explained&.summary,
1016
- reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1019
+ reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
1020
+ prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
1017
1021
  unless snapshot.dig(:context, :window_tokens)
1018
1022
  window = current_context_window(target)
1019
1023
  if window
@@ -1475,19 +1479,31 @@ module Samagotchi
1475
1479
  private :save_quietly
1476
1480
 
1477
1481
  # Keep what the after_turn hooks presented as the answer's `display`
1478
- # and tell the observers (:answer_display), after the turn_completed
1479
- # they already had: the web re-reads the answer then. A turn_completed
1482
+ # and the refs they attached as its `display_refs` (AnswerRefs, one
1483
+ # replace for both), and tell the observers (:answer_display, the
1484
+ # display or nil, and how many refs), after the turn_completed they
1485
+ # already had: the web re-reads the answer then. A turn_completed
1480
1486
  # with display_pending (after_turn hooks were about to run) always gets
1481
1487
  # one, `display: nil` when nothing was presented, so the web holds the
1482
1488
  # answer's pop until then and never swaps it after. Terminals are not
1483
1489
  # sinks of it; they printed the answer already. The session is saved by
1484
1490
  # the caller, as after every turn.
1485
- def store_answer_display(session, display, pending: false)
1491
+ def store_answer_display(session, display, refs, pending: false)
1486
1492
  synchronize_events do
1493
+ # A plugin that kept event[:refs] and calls it later gets nil: the
1494
+ # list is stored now (and the turn is over on every path).
1495
+ refs.close!
1487
1496
  messages = Array(session.messages)
1488
- if display.changed? && messages.last.equal?(display.target)
1489
- replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
1490
- @session_observer.notify({ type: :answer_display, display: display.text })
1497
+ changed = display.changed? && messages.last.equal?(display.target)
1498
+ added = refs.added? && messages.last.equal?(refs.target)
1499
+ if changed || added
1500
+ merged = messages.last.dup
1501
+ merged[AnswerDisplay::KEY] = display.text if changed
1502
+ merged[AnswerRefs::KEY] = refs.to_a if added
1503
+ replace_session_messages(session, messages[0...-1] + [merged])
1504
+ event = { type: :answer_display, display: changed ? display.text : nil }
1505
+ event[:refs] = refs.count if added
1506
+ @session_observer.notify(event)
1491
1507
  elsif pending
1492
1508
  @session_observer.notify({ type: :answer_display, display: nil })
1493
1509
  end
@@ -1518,7 +1534,7 @@ module Samagotchi
1518
1534
  private :hook_source
1519
1535
 
1520
1536
  # What a notice may stand in for (hook_notify's fallback_for:).
1521
- NOTICE_FALLBACKS = %i[display].freeze
1537
+ NOTICE_FALLBACKS = %i[display editor].freeze
1522
1538
 
1523
1539
  # One line to the user (:hook_notice). During a turn it is a turn
1524
1540
  # event: the turn's sink (the REPL) and the observers (bridge, log).
@@ -1527,9 +1543,10 @@ module Samagotchi
1527
1543
  #
1528
1544
  # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1529
1545
  # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1530
- # render its links. A UI that does leaves the line out; the others, the
1531
- # log among them, show it. An unknown value is dropped, so the line
1532
- # shows everywhere.
1546
+ # render its links; :editor, a ref the viewer could open in its editor
1547
+ # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1548
+ # does leaves the line out; the others, the log among them, show it. An
1549
+ # unknown value is dropped, so the line shows everywhere.
1533
1550
  def hook_notify(text, level, hook, fallback_for: nil)
1534
1551
  level = (level || :info).to_sym
1535
1552
  level = :info unless %i[info warn].include?(level)
@@ -1801,8 +1818,14 @@ module Samagotchi
1801
1818
  # +id+: names the turn on :turn_started (its metrics record's id) and on
1802
1819
  # its saved prompt, so a UI pairs the two whatever left the conversation.
1803
1820
  # +limit+: the iteration limit #generate ran it with.
1821
+ # +resumed+: a step-limit Continue (run_turn's resumed:).
1822
+ # +resume_from+: where its reasoning starts, the run it resumes
1823
+ # (run_turn's resume_from:); nil for every other turn, and for a
1824
+ # Continue that starts cold (after a model change).
1804
1825
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1805
- :settings, :id, :limit) do
1826
+ :settings, :id, :limit, :resumed, :resume_from) do
1827
+ def resumed? = resumed ? true : false
1828
+
1806
1829
  # The turn's boundary events carry the origin only when there is one,
1807
1830
  # so payloads stay unchanged for callers that don't pass it.
1808
1831
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1832,6 +1855,14 @@ module Samagotchi
1832
1855
  # @param continue [Boolean] resume the conversation without appending a
1833
1856
  # user prompt (continue after the iteration limit, reminder turns);
1834
1857
  # +prompt+ is ignored and :turn_started carries `continue: true`
1858
+ # @param resumed [Boolean] a step-limit Continue (with continue:):
1859
+ # :turn_started carries `resumed: true`, so do the before_turn payload
1860
+ # and the turn's analytics record. false for every other continue (a
1861
+ # reminder, a wake).
1862
+ # @param resume_from [Integer, nil] a step-limit Continue's: where the
1863
+ # run it resumes started (ModelResult#run_start, kept in the offer by
1864
+ # TurnFlow), so it keeps that run's reasoning; nil starts cold (a
1865
+ # model change while the offer stood, every other turn).
1835
1866
  # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
1836
1867
  # when given, the turn's boundary events (:turn_started, :turn_completed,
1837
1868
  # :turn_canceled, :turn_failed) carry it as `origin:`
@@ -1854,9 +1885,13 @@ module Samagotchi
1854
1885
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1855
1886
  # one-line summary:.
1856
1887
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
1857
- images: [], id: nil)
1888
+ images: [], id: nil, resumed: false, resume_from: nil)
1858
1889
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1859
1890
  continue: continue, id: id)
1891
+ if continue
1892
+ turn.resumed = resumed
1893
+ turn.resume_from = resume_from
1894
+ end
1860
1895
  begin
1861
1896
  # A Stop cuts the /props probes this thread makes for the turn
1862
1897
  # (window, served model, vision) instead of waiting their timeout.
@@ -1939,6 +1974,7 @@ module Samagotchi
1939
1974
  bind_metrics(session)
1940
1975
  turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1941
1976
  turn_started[:continue] = true if turn.continue
1977
+ turn_started[:resumed] = true if turn.resumed?
1942
1978
  turn_started[:images] = image_refs unless image_refs.empty?
1943
1979
  turn_started[:cut_after] = steer_cut_after
1944
1980
  emit_event(turn.on_event, turn.tag(turn_started))
@@ -1980,9 +2016,9 @@ module Samagotchi
1980
2016
  end
1981
2017
 
1982
2018
  # Fire :before_turn hook, with a read-only copy of the history so far
1983
- # and the prompt (nil on a continue).
2019
+ # and the prompt (nil on a continue); resumed: a step-limit Continue.
1984
2020
  @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
1985
- messages: hook_messages(session.messages) })
2021
+ resumed: turn.resumed?, messages: hook_messages(session.messages) })
1986
2022
 
1987
2023
  turn_messages(turn, image_refs)
1988
2024
  true
@@ -2118,18 +2154,39 @@ module Samagotchi
2118
2154
  log_llm_context(explained)
2119
2155
 
2120
2156
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
2121
- backend.complete(
2157
+ # Where this run starts (ChatLoop::Run's start): an offer made at its
2158
+ # end keeps it, so a Continue resumes this run.
2159
+ run_start = turn.messages.length
2160
+ reasoning_since = resumed_run_start(turn, run_start)
2161
+ result = backend.complete(
2122
2162
  messages: turn.messages,
2123
2163
  max_iterations: turn.limit,
2124
2164
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
2125
2165
  cancel_controller: turn.controller,
2126
2166
  model_name: bare_for_backend,
2127
2167
  max_tool_output_chars: max_tool_output_chars,
2128
- pending_input: turn_drain(pending_input)
2168
+ pending_input: turn_drain(pending_input),
2169
+ reasoning_since: reasoning_since
2129
2170
  )
2171
+ result.run_start = run_start
2172
+ result
2130
2173
  end
2131
2174
  private :generate
2132
2175
 
2176
+ # A step-limit Continue's resume_from, when it is inside the messages the
2177
+ # run gets (a wrong one only drops reasoning: it is ignored, logged).
2178
+ # Entries before it never move between the turns (#turn_messages adds
2179
+ # at the tail, and the system head is replaced in place).
2180
+ def resumed_run_start(turn, run_start)
2181
+ start = turn.resume_from
2182
+ return nil if start.nil?
2183
+ return start if start.is_a?(Integer) && start.between?(0, run_start)
2184
+
2185
+ Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
2186
+ nil
2187
+ end
2188
+ private :resumed_run_start
2189
+
2133
2190
  # The turn's LLM context strategy and where it came from, in the turn
2134
2191
  # log: when it runs a layer, or the session set it (none too).
2135
2192
  def log_llm_context(explained)
@@ -2184,15 +2241,18 @@ module Samagotchi
2184
2241
 
2185
2242
  # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
2186
2243
  # ends the turn in #run_turn without it), with a read-only
2187
- # copy of the conversation the turn stored, and event[:present] for
2188
- # a display version of the answer (AnswerDisplay).
2244
+ # copy of the conversation the turn stored, event[:present] for
2245
+ # a display version of the answer (AnswerDisplay) and event[:refs]
2246
+ # for refs on it (AnswerRefs).
2189
2247
  session = turn.session
2190
2248
  display = AnswerDisplay.new(session.messages)
2249
+ refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
2191
2250
  after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2192
2251
  messages: hook_messages(session.messages) }
2193
2252
  after_turn[:present] = display.presenter(after_turn)
2253
+ after_turn[:refs] = refs.adder(after_turn)
2194
2254
  @hooks.fire(:after_turn, after_turn)
2195
- store_answer_display(session, display, pending: display_pending)
2255
+ store_answer_display(session, display, refs, pending: display_pending)
2196
2256
 
2197
2257
  # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
2198
2258
  @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
@@ -2203,7 +2263,8 @@ module Samagotchi
2203
2263
  # replace). Runs under the event lock: it also adds the turn's note to
2204
2264
  # result.conversation, which the REPL keeps as-is.
2205
2265
  # A turn that ran out of iterations ends at its tool results, so a
2206
- # continue resumes from them rather than after a made-up reply.
2266
+ # continue resumes from them rather than after a made-up reply (and a
2267
+ # step-limit Continue from its run's start: ModelResult#run_start).
2207
2268
  def kept_messages(turn, result, seconds)
2208
2269
  conversation = result.conversation if result.conversation.is_a?(Array)
2209
2270
  # Nothing visible (no text) is a turn the model should know ended
@@ -2502,6 +2563,19 @@ module Samagotchi
2502
2563
  clone_messages(@session&.messages)
2503
2564
  end
2504
2565
 
2566
+ # The running turn's conversation as its loop builds it (both loops
2567
+ # register it on the kernel's llm_context_turn): the same array the loop
2568
+ # appends to, not a copy, so read it on the turn's thread (the pending
2569
+ # input drain, at an iteration boundary: TurnCheckpoint). nil when no
2570
+ # turn runs (llm_context_turn is never cleared, and still holds the
2571
+ # last turn's then).
2572
+ # @return [Array<Hash>, nil]
2573
+ def live_conversation
2574
+ return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
2575
+
2576
+ @kernel.llm_context_turn&.conversation
2577
+ end
2578
+
2505
2579
  # Append messages to the current session's conversation.
2506
2580
  # @param messages [Array<Hash>]
2507
2581
  # @return [Array<Hash>] the session's messages
@@ -2624,6 +2698,10 @@ module Samagotchi
2624
2698
  state_dir: -> { session_state_dir },
2625
2699
  scratch: -> { @scratch },
2626
2700
  setup: -> { SessionSetup.of(@session) },
2701
+ # The files whose text the built system prompt holds; [] until a
2702
+ # build and after a rebuild that drops them (SystemPrompt#prompt_files
2703
+ # returns [] itself: the || keeps a nil builder safe).
2704
+ prompt_files: -> { @prompt_builder&.prompt_files || [] },
2627
2705
  frontend: -> { interface }
2628
2706
  )
2629
2707
  end
@@ -253,9 +253,11 @@ module Samagotchi
253
253
  # event[:steer].call(text) -> true when the text was queued for the
254
254
  # running turn's next boundary (its own user message, source: the
255
255
  # hook's bundle); from after_turn / session_end false
256
- # and after_turn's fire site adds one more (AnswerDisplay):
256
+ # and after_turn's fire site adds two more (AnswerDisplay, AnswerRefs):
257
257
  # event[:present].call { |text| new_text } -> the answer's display
258
258
  # text after the call (chained in hook order), nil without an answer
259
+ # event[:refs].call(match:, kind:, target:, label: nil, actions: [])
260
+ # -> true (kept), false (refused, logged), nil without an answer
259
261
  #
260
262
  # @param name [Symbol] the hook name to fire
261
263
  # @param event [Hash] the event payload (may be mutated by hooks)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The mark of a turn a worker checkpointed and never finished
5
+ # (Session#interrupted_turn, saved as session.json's "interrupted_turn"):
6
+ # TurnCheckpoint writes it with the turn's messages so far while the turn
7
+ # runs; the turn's end saves without it; the next worker to start finds it
8
+ # and settles the turn (Worker#settle_interrupted_turn).
9
+ #
10
+ # id: the turn's id (nil when unknown); started_at / at: iso8601, when the
11
+ # turn began and when this checkpoint was taken; iteration: the loop
12
+ # boundaries it had passed (Integer).
13
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration) do
14
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil) = super
15
+
16
+ # A saved record (string or symbol keys), an InterruptedTurn as it is;
17
+ # nil for anything else.
18
+ # @return [InterruptedTurn, nil]
19
+ def self.from_file(raw)
20
+ return raw if raw.is_a?(InterruptedTurn)
21
+ return nil unless raw.is_a?(Hash)
22
+
23
+ new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
24
+ end
25
+
26
+ # @return [Hash{String => Object}] string keys, unset fields left out
27
+ def to_file = to_h.compact.transform_keys(&:to_s)
28
+
29
+ # Seconds from the turn's start to this checkpoint (nil when unknown).
30
+ # @return [Float, nil]
31
+ def seconds
32
+ return nil unless started_at && at
33
+
34
+ (Time.iso8601(at) - Time.iso8601(started_at)).clamp(0, Float::INFINITY)
35
+ rescue ArgumentError
36
+ nil
37
+ end
38
+ end
39
+ end
@@ -10,8 +10,9 @@ module Samagotchi
10
10
  module IterationLimit
11
11
  KEY = "turn.max_iterations"
12
12
  # A turn's limit unless turn.max_iterations says otherwise, and the
13
- # loops' own default.
14
- DEFAULT = 100
13
+ # loops' own default. Loops are caught by the loop-guard bundle and a
14
+ # long turn shows check-in's card well before it; this catches the rest.
15
+ DEFAULT = 300
15
16
  # A --no-interrupt / --non-interactive turn's, at least: nobody is there
16
17
  # to answer a continue offer.
17
18
  NO_INTERRUPT = 1000
@@ -204,12 +204,18 @@ module Samagotchi
204
204
  # steering mid-stream); drained lines merge into ONE user message appended
205
205
  # at the conversation tail (prefix KV cache preserved) and a
206
206
  # :pending_input_merged stream event is emitted.
207
+ # @param reasoning_since [Integer, nil] a step-limit Continue's: where the
208
+ # run it resumes started in +messages+; that run's model messages keep
209
+ # their thought blocks (#prepare_conversation), as within one run. nil:
210
+ # every earlier thought is stripped.
207
211
  # @return [LLM::ModelResult] final visible response with continuation metadata
208
- def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
212
+ def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil,
213
+ reasoning_since: nil)
209
214
  @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
210
215
  @last_slot = nil
211
216
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
212
- pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
217
+ pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
218
+ reasoning_since: reasoning_since)
213
219
 
214
220
  max_iterations.times do |iteration_index|
215
221
  turn.iteration = iteration_index + 1
@@ -284,8 +290,8 @@ module Samagotchi
284
290
  :buffer_mark, keyword_init: true)
285
291
  private_constant :Turn, :Request, :Generation
286
292
 
287
- def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
288
- conversation = prepare_conversation(messages)
293
+ def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
294
+ conversation = prepare_conversation(messages, keep_thought_from: thought_floor(messages, reasoning_since))
289
295
  context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
290
296
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
291
297
  @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
@@ -992,20 +998,42 @@ module Samagotchi
992
998
  @profile.uses_role_prefixes? ? @profile.user_prefix : "#{@profile.turn_start}user\n"
993
999
  end
994
1000
 
995
- def sanitize_history(messages)
996
- messages.map do |m|
997
- if m[:role] == "model"
998
- # `display` rides along so the stored conversation keeps it; the
999
- # prompt formatter never reads it (AnswerDisplay).
1000
- { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
1001
+ # +keep_thought_from+: model messages from this index on keep their
1002
+ # thought blocks (a resumed run's); nil strips every one.
1003
+ def sanitize_history(messages, keep_thought_from: nil)
1004
+ messages.each_with_index.map do |m, index|
1005
+ if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
1006
+ { role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs] }.compact
1007
+ elsif m[:role] == "model"
1008
+ # `display` and `display_refs` ride along so the stored
1009
+ # conversation keeps them; the prompt formatter never reads them
1010
+ # (AnswerDisplay, AnswerRefs).
1011
+ { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
1012
+ display_refs: m[:display_refs] }.compact
1001
1013
  else
1002
1014
  m.dup
1003
1015
  end
1004
1016
  end
1005
1017
  end
1006
1018
 
1007
- # Standard multi-turn compliance: never pass prior raw thought blocks.
1008
- def prepare_conversation(messages) = sanitize_history(messages)
1019
+ # Standard multi-turn compliance: never pass prior raw thought blocks,
1020
+ # but a resumed run's (+keep_thought_from+, #thought_floor): the same
1021
+ # run going on after a step-limit Continue.
1022
+ def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
1023
+
1024
+ # Where a resumed run's thought blocks start: its start (+since+). Inside
1025
+ # one native run nothing re-sanitizes, so a user entry the run merged (a
1026
+ # steer, a delegate report, the Qwen recovery prompt) left the thoughts
1027
+ # before it in the prompt: they stay. Only a user or turn_start entry
1028
+ # added after the run ended (after its last model message: a !cmd's
1029
+ # output) starts them again past itself. nil without +since+.
1030
+ def thought_floor(messages, since)
1031
+ return nil if since.nil?
1032
+
1033
+ last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
1034
+ query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
1035
+ query && query > last_reply ? [query + 1, since].max : since
1036
+ end
1009
1037
 
1010
1038
  def duplicate_conversation(messages)
1011
1039
  messages.map(&:dup)
@@ -14,9 +14,14 @@ module Samagotchi
14
14
  # extra surface lives on backend-specific subclasses, never on this interface.
15
15
  #
16
16
  # The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
17
+ #
18
+ # +reasoning_since+: where the run a step-limit Continue resumes started
19
+ # in +messages+ (Engine#run_turn resume_from:); the replies from there on
20
+ # keep the model's reasoning (thought blocks), as within one run. nil:
21
+ # the run starts here, as every other turn's does.
17
22
  class ModelBackend
18
23
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
19
- model_name: nil, max_tool_output_chars: nil, pending_input: nil)
24
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
20
25
  raise NotImplementedError, "#{self.class}#complete must be implemented"
21
26
  end
22
27
  end
@@ -11,6 +11,7 @@ require_relative "native_tool_normalizer"
11
11
  require_relative "stream_drops"
12
12
  require_relative "../kernel_loop"
13
13
  require_relative "../answer_display"
14
+ require_relative "../answer_refs"
14
15
  require_relative "../cut_policy"
15
16
  require_relative "../context_status"
16
17
  require_relative "../context_window"
@@ -68,12 +69,14 @@ module Samagotchi
68
69
 
69
70
  def provider = :chat
70
71
 
72
+ # +reasoning_since+ (ModelBackend#complete): the resumed run's start,
73
+ # the Run's own start then (#reasoning_boundary).
71
74
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
72
- model_name: nil, max_tool_output_chars: nil, pending_input: nil)
75
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
73
76
  raise ArgumentError, "ChatLoop has no adapter" unless @adapter
74
77
 
75
78
  conversation = Array(messages).map(&:dup)
76
- Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
79
+ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
77
80
  .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
78
81
  rescue StandardError => e
79
82
  FailedTurn.attach(e, conversation && plain(conversation))
@@ -195,7 +198,8 @@ module Samagotchi
195
198
  # user message: a prompt, a steer, merged input, the chat template's
196
199
  # last query; or an entry that starts a turn, turn_start) and +since+
197
200
  # (the Run's start, so a continue turn -- a reminder, a context wake,
198
- # a continue after a stop -- sends none of the run before it).
201
+ # a delegate report's wake -- sends none of the run before it; a
202
+ # step-limit Continue's Run starts where the run it resumes did).
199
203
  # @return [Integer, nil]
200
204
  def reasoning_boundary(conversation, since: nil, level: reasoning_history)
201
205
  case level
@@ -299,8 +303,9 @@ module Samagotchi
299
303
  # refs of a user message or a tool result, a prompt's turn_id (Engine,
300
304
  # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
301
305
  # (the live row's params line and label, never sent back), an
302
- # edit/write result's tool_diffs (never sent), and an empty-answer
303
- # note's marker (TurnNote.empty, never sent).
306
+ # edit/write result's tool_diffs (never sent), an empty-answer
307
+ # note's marker (TurnNote.empty, never sent), and an answer's display
308
+ # and display_refs (AnswerDisplay, AnswerRefs; never sent).
304
309
  def plain(conversation)
305
310
  conversation.map do |entry|
306
311
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -316,6 +321,7 @@ module Samagotchi
316
321
  marker = TurnNote.empty_answer(entry)
317
322
  message[TurnNote::EMPTY_ANSWER] = marker if marker
318
323
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
324
+ message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
319
325
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
320
326
  message
321
327
  end
@@ -402,7 +408,9 @@ module Samagotchi
402
408
  # One turn's state: the conversation, the stream sink and tool
403
409
  # activity.
404
410
  class Run
405
- def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
411
+ # +start+: where the run a step-limit Continue resumes started (nil:
412
+ # this run starts at the conversation's end).
413
+ def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
406
414
  @loop = loop
407
415
  @conversation = conversation
408
416
  @on_stream_event = on_stream_event
@@ -410,8 +418,8 @@ module Samagotchi
410
418
  @model_name = model_name
411
419
  @pending_input = pending_input
412
420
  # Where this run's entries start: the reasoning it sends back
413
- # (ChatLoop#reasoning_boundary) is its own.
414
- @start = conversation.length
421
+ # (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
422
+ @start = start || conversation.length
415
423
  @tool_activity = []
416
424
  @empty_retry = EmptyAnswerRetry.new
417
425
  # The empty generations, never saved: the UIs draw the turn's
@@ -14,9 +14,15 @@ module Samagotchi
14
14
  # generations as model messages, which the loops keep out of the
15
15
  # conversation) and .empty_retries (the retries spent): the Engine saves
16
16
  # them on the turn's note for the UIs (TurnNote.empty).
17
+ #
18
+ # .run_start is where the turn's run started in the conversation (the
19
+ # length of the messages the loop got), set by the Engine after the loop
20
+ # returns; nil from a loop called directly. A step-limit offer keeps it
21
+ # (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
17
22
  class ModelResult
18
23
  attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
19
24
  :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
25
+ attr_accessor :run_start
20
26
 
21
27
  def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
22
28
  tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
@@ -57,8 +63,11 @@ module Samagotchi
57
63
  !!pending_tool_calls
58
64
  end
59
65
 
60
- # KernelLoop only marks a run exhausted when it stopped on pending tool
61
- # calls, so exhausted? alone already means "resumable".
66
+ # A run that ran out of iterations can be continued (the step-limit
67
+ # offer). KernelLoop marks one exhausted only when it stopped on pending
68
+ # tool calls; ChatLoop also leaves it exhausted when its last iteration
69
+ # ended in a retry (an empty answer's nudge, a cut) or merged input
70
+ # after an answer, which a Continue then picks up.
62
71
  def resumable?
63
72
  exhausted?
64
73
  end
@@ -17,7 +17,7 @@ module Samagotchi
17
17
  def provider = :native
18
18
 
19
19
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
20
- model_name: nil, max_tool_output_chars: nil, pending_input: nil)
20
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
21
21
  @kernel.run(
22
22
  messages,
23
23
  max_iterations: max_iterations,
@@ -25,7 +25,8 @@ module Samagotchi
25
25
  cancel_controller: cancel_controller,
26
26
  model_name: model_name,
27
27
  max_tool_output_chars: max_tool_output_chars,
28
- pending_input: pending_input
28
+ pending_input: pending_input,
29
+ reasoning_since: reasoning_since
29
30
  )
30
31
  end
31
32
  end
@@ -63,7 +63,8 @@ module Samagotchi
63
63
  @generation_started_at.clear
64
64
  @tool_started_at.clear
65
65
  log(:info, :turn_started, session: event[:session_id], prompt_chars: event[:prompt].to_s.length,
66
- continue: event[:continue] || nil, **items(event), **origin(event))
66
+ continue: event[:continue] || nil, resumed: event[:resumed] || nil, **items(event),
67
+ **origin(event))
67
68
  end
68
69
 
69
70
  def on_turn_completed(event)
@@ -151,10 +152,13 @@ module Samagotchi
151
152
  log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
152
153
  end
153
154
 
154
- # after_turn hooks presented the answer (AnswerDisplay): its size only
155
- # (none for the display: nil that says nothing was presented).
155
+ # after_turn hooks presented the answer (AnswerDisplay) or attached refs
156
+ # to it (AnswerRefs): the display's size and the refs' count only (none
157
+ # for the display: nil that says nothing was presented).
156
158
  def on_answer_display(event)
157
- log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
159
+ return unless event[:display] || event[:refs]
160
+
161
+ log(:info, :answer_display, chars: event[:display]&.length, refs: event[:refs])
158
162
  end
159
163
 
160
164
  # A card shown to the user (Engine#show_card): whose, which, and its