samagotchi 0.58.0 → 0.60.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +6 -4
  6. data/docs/cli.md +14 -7
  7. data/docs/configuration.md +96 -31
  8. data/docs/hooks.md +12 -6
  9. data/docs/internals/background-tasks.md +7 -0
  10. data/docs/internals/engine.md +20 -12
  11. data/docs/internals/gemma4-contract.md +1 -0
  12. data/docs/internals/prompt-caching.md +4 -3
  13. data/docs/internals/tool-guardrails.md +2 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +75 -6
  16. data/docs/sessions.md +2 -2
  17. data/docs/sub-agent.md +2 -2
  18. data/docs/testing.md +4 -2
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  21. data/lib/samagotchi/bridge.rb +6 -2
  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/core/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  26. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  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 +44 -10
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +89 -15
  32. data/lib/samagotchi/host_config.rb +3 -1
  33. data/lib/samagotchi/host_registry.rb +2 -1
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +35 -9
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +11 -6
  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 +2 -1
  41. data/lib/samagotchi/model_settings.rb +4 -2
  42. data/lib/samagotchi/plugin/context.rb +16 -3
  43. data/lib/samagotchi/process_group.rb +76 -3
  44. data/lib/samagotchi/prompt_files.rb +127 -0
  45. data/lib/samagotchi/ref.rb +124 -0
  46. data/lib/samagotchi/send_command.rb +1 -1
  47. data/lib/samagotchi/session.rb +15 -3
  48. data/lib/samagotchi/session_commands.rb +15 -3
  49. data/lib/samagotchi/session_metrics.rb +8 -2
  50. data/lib/samagotchi/session_setup.rb +11 -5
  51. data/lib/samagotchi/steer_cut.rb +12 -8
  52. data/lib/samagotchi/steer_cut_after.rb +69 -0
  53. data/lib/samagotchi/system_prompt.rb +74 -18
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  56. data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
  57. data/lib/samagotchi/terminal_ui.rb +18 -6
  58. data/lib/samagotchi/tool_activity.rb +18 -1
  59. data/lib/samagotchi/tool_declarations.rb +1 -1
  60. data/lib/samagotchi/tools/execute.rb +318 -70
  61. data/lib/samagotchi/tools/task_get.rb +2 -0
  62. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  63. data/lib/samagotchi/tools/task_wait.rb +23 -5
  64. data/lib/samagotchi/turn_flow.rb +22 -3
  65. data/lib/samagotchi/utf8_default.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/public/favicon.svg +1 -0
  68. data/lib/samagotchi/web/public/index.html +1 -0
  69. data/lib/samagotchi/web/public/read_view.js +3 -1
  70. data/lib/samagotchi/web/public/refs.js +107 -15
  71. data/lib/samagotchi/web/public/task_stop.js +25 -7
  72. data/lib/samagotchi/worker.rb +1 -0
  73. metadata +7 -1
@@ -105,17 +105,20 @@ module Samagotchi
105
105
  # @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
106
106
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
107
107
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
108
+ # @param appended_prompt_files [Array<String>] --append-system-prompt's files (absolute paths; a session's
109
+ # appended_prompt_files): their text goes into the system prompt after config.yml's append_system_prompt:
108
110
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
109
111
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
110
112
  # delegate (a child would outlive it) nor plugin fork
111
113
  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)
114
+ plugins: true, scratch: false, model_typed: nil, appended_prompt_files: [])
113
115
  # The turn as other threads see it (flag, cancel controller, sink,
114
116
  # steers) and the idle layer's activity clock. Built first: a plugin
115
117
  # may steer or ask whether a turn runs while it loads.
116
118
  @turn_state = TurnState.new(clock: -> { monotonic_now })
117
119
  # A message for the running turn cutting its generation.
118
- @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller })
120
+ @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
121
+ cut_after: -> { steer_cut_after })
119
122
  @scratch = scratch
120
123
  @no_interrupt = no_interrupt
121
124
  @chat_backend = nil
@@ -204,7 +207,7 @@ module Samagotchi
204
207
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
205
208
  thinking: -> { turn_thinking }, model: -> { model_identity },
206
209
  model_notes: -> { model_notes }, memories: memories,
207
- muted_memory_names: @muted_memory_names)
210
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
208
211
  @session = nil
209
212
  @session_observer = SessionObserver.new
210
213
  @metrics = SessionMetrics.new
@@ -1012,7 +1015,8 @@ module Samagotchi
1012
1015
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1013
1016
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1014
1017
  thinking: thinking_explained&.summary,
1015
- reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1018
+ reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
1019
+ prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
1016
1020
  unless snapshot.dig(:context, :window_tokens)
1017
1021
  window = current_context_window(target)
1018
1022
  if window
@@ -1094,6 +1098,29 @@ module Samagotchi
1094
1098
  explained&.source ? explained.label : nil
1095
1099
  end
1096
1100
 
1101
+ # The seconds a cut waits for the effective model to stream only
1102
+ # thinking (SteerCutAfter.resolve), resolved on every call, as
1103
+ # turn_sampling; on any error, the global and its name.
1104
+ # @return [Array(Integer, String|nil)]
1105
+ def steer_cut_after_explained
1106
+ target = @host_registry.resolve(@effective_model_name)
1107
+ SteerCutAfter.resolve(target, names: model_lookup_names(target))
1108
+ rescue StandardError
1109
+ [SteerCut.cut_after, "steer.cut_after"]
1110
+ end
1111
+
1112
+ # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
1113
+ # reads, per host and model.
1114
+ def steer_cut_after = steer_cut_after_explained.first
1115
+
1116
+ # "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
1117
+ # effective model's cut wait and where it came from, nil for the
1118
+ # default.
1119
+ def steer_cut_summary
1120
+ seconds, source = steer_cut_after_explained
1121
+ source ? SteerCutAfter.label(seconds, source) : nil
1122
+ end
1123
+
1097
1124
  # The effective model's thinking level with where it came from and the
1098
1125
  # session's own (SettingExplained), as the next turn resolves it;
1099
1126
  # nil when it can't be resolved.
@@ -1494,7 +1521,7 @@ module Samagotchi
1494
1521
  private :hook_source
1495
1522
 
1496
1523
  # What a notice may stand in for (hook_notify's fallback_for:).
1497
- NOTICE_FALLBACKS = %i[display].freeze
1524
+ NOTICE_FALLBACKS = %i[display editor].freeze
1498
1525
 
1499
1526
  # One line to the user (:hook_notice). During a turn it is a turn
1500
1527
  # event: the turn's sink (the REPL) and the observers (bridge, log).
@@ -1503,9 +1530,10 @@ module Samagotchi
1503
1530
  #
1504
1531
  # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1505
1532
  # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1506
- # render its links. A UI that does leaves the line out; the others, the
1507
- # log among them, show it. An unknown value is dropped, so the line
1508
- # shows everywhere.
1533
+ # render its links; :editor, a ref the viewer could open in its editor
1534
+ # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1535
+ # does leaves the line out; the others, the log among them, show it. An
1536
+ # unknown value is dropped, so the line shows everywhere.
1509
1537
  def hook_notify(text, level, hook, fallback_for: nil)
1510
1538
  level = (level || :info).to_sym
1511
1539
  level = :info unless %i[info warn].include?(level)
@@ -1777,8 +1805,14 @@ module Samagotchi
1777
1805
  # +id+: names the turn on :turn_started (its metrics record's id) and on
1778
1806
  # its saved prompt, so a UI pairs the two whatever left the conversation.
1779
1807
  # +limit+: the iteration limit #generate ran it with.
1808
+ # +resumed+: a step-limit Continue (run_turn's resumed:).
1809
+ # +resume_from+: where its reasoning starts, the run it resumes
1810
+ # (run_turn's resume_from:); nil for every other turn, and for a
1811
+ # Continue that starts cold (after a model change).
1780
1812
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1781
- :settings, :id, :limit) do
1813
+ :settings, :id, :limit, :resumed, :resume_from) do
1814
+ def resumed? = resumed ? true : false
1815
+
1782
1816
  # The turn's boundary events carry the origin only when there is one,
1783
1817
  # so payloads stay unchanged for callers that don't pass it.
1784
1818
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1808,6 +1842,14 @@ module Samagotchi
1808
1842
  # @param continue [Boolean] resume the conversation without appending a
1809
1843
  # user prompt (continue after the iteration limit, reminder turns);
1810
1844
  # +prompt+ is ignored and :turn_started carries `continue: true`
1845
+ # @param resumed [Boolean] a step-limit Continue (with continue:):
1846
+ # :turn_started carries `resumed: true`, so do the before_turn payload
1847
+ # and the turn's analytics record. false for every other continue (a
1848
+ # reminder, a wake).
1849
+ # @param resume_from [Integer, nil] a step-limit Continue's: where the
1850
+ # run it resumes started (ModelResult#run_start, kept in the offer by
1851
+ # TurnFlow), so it keeps that run's reasoning; nil starts cold (a
1852
+ # model change while the offer stood, every other turn).
1811
1853
  # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
1812
1854
  # when given, the turn's boundary events (:turn_started, :turn_completed,
1813
1855
  # :turn_canceled, :turn_failed) carry it as `origin:`
@@ -1830,9 +1872,13 @@ module Samagotchi
1830
1872
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1831
1873
  # one-line summary:.
1832
1874
  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,
1833
- images: [], id: nil)
1875
+ images: [], id: nil, resumed: false, resume_from: nil)
1834
1876
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1835
1877
  continue: continue, id: id)
1878
+ if continue
1879
+ turn.resumed = resumed
1880
+ turn.resume_from = resume_from
1881
+ end
1836
1882
  begin
1837
1883
  # A Stop cuts the /props probes this thread makes for the turn
1838
1884
  # (window, served model, vision) instead of waiting their timeout.
@@ -1915,7 +1961,9 @@ module Samagotchi
1915
1961
  bind_metrics(session)
1916
1962
  turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1917
1963
  turn_started[:continue] = true if turn.continue
1964
+ turn_started[:resumed] = true if turn.resumed?
1918
1965
  turn_started[:images] = image_refs unless image_refs.empty?
1966
+ turn_started[:cut_after] = steer_cut_after
1919
1967
  emit_event(turn.on_event, turn.tag(turn_started))
1920
1968
  raise image_error if image_error
1921
1969
 
@@ -1955,9 +2003,9 @@ module Samagotchi
1955
2003
  end
1956
2004
 
1957
2005
  # Fire :before_turn hook, with a read-only copy of the history so far
1958
- # and the prompt (nil on a continue).
2006
+ # and the prompt (nil on a continue); resumed: a step-limit Continue.
1959
2007
  @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
1960
- messages: hook_messages(session.messages) })
2008
+ resumed: turn.resumed?, messages: hook_messages(session.messages) })
1961
2009
 
1962
2010
  turn_messages(turn, image_refs)
1963
2011
  true
@@ -2093,18 +2141,39 @@ module Samagotchi
2093
2141
  log_llm_context(explained)
2094
2142
 
2095
2143
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
2096
- backend.complete(
2144
+ # Where this run starts (ChatLoop::Run's start): an offer made at its
2145
+ # end keeps it, so a Continue resumes this run.
2146
+ run_start = turn.messages.length
2147
+ reasoning_since = resumed_run_start(turn, run_start)
2148
+ result = backend.complete(
2097
2149
  messages: turn.messages,
2098
2150
  max_iterations: turn.limit,
2099
2151
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
2100
2152
  cancel_controller: turn.controller,
2101
2153
  model_name: bare_for_backend,
2102
2154
  max_tool_output_chars: max_tool_output_chars,
2103
- pending_input: turn_drain(pending_input)
2155
+ pending_input: turn_drain(pending_input),
2156
+ reasoning_since: reasoning_since
2104
2157
  )
2158
+ result.run_start = run_start
2159
+ result
2105
2160
  end
2106
2161
  private :generate
2107
2162
 
2163
+ # A step-limit Continue's resume_from, when it is inside the messages the
2164
+ # run gets (a wrong one only drops reasoning: it is ignored, logged).
2165
+ # Entries before it never move between the turns (#turn_messages adds
2166
+ # at the tail, and the system head is replaced in place).
2167
+ def resumed_run_start(turn, run_start)
2168
+ start = turn.resume_from
2169
+ return nil if start.nil?
2170
+ return start if start.is_a?(Integer) && start.between?(0, run_start)
2171
+
2172
+ Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
2173
+ nil
2174
+ end
2175
+ private :resumed_run_start
2176
+
2108
2177
  # The turn's LLM context strategy and where it came from, in the turn
2109
2178
  # log: when it runs a layer, or the session set it (none too).
2110
2179
  def log_llm_context(explained)
@@ -2178,7 +2247,8 @@ module Samagotchi
2178
2247
  # replace). Runs under the event lock: it also adds the turn's note to
2179
2248
  # result.conversation, which the REPL keeps as-is.
2180
2249
  # A turn that ran out of iterations ends at its tool results, so a
2181
- # continue resumes from them rather than after a made-up reply.
2250
+ # continue resumes from them rather than after a made-up reply (and a
2251
+ # step-limit Continue from its run's start: ModelResult#run_start).
2182
2252
  def kept_messages(turn, result, seconds)
2183
2253
  conversation = result.conversation if result.conversation.is_a?(Array)
2184
2254
  # Nothing visible (no text) is a turn the model should know ended
@@ -2599,6 +2669,10 @@ module Samagotchi
2599
2669
  state_dir: -> { session_state_dir },
2600
2670
  scratch: -> { @scratch },
2601
2671
  setup: -> { SessionSetup.of(@session) },
2672
+ # The files whose text the built system prompt holds; [] until a
2673
+ # build and after a rebuild that drops them (SystemPrompt#prompt_files
2674
+ # returns [] itself: the || keeps a nil builder safe).
2675
+ prompt_files: -> { @prompt_builder&.prompt_files || [] },
2602
2676
  frontend: -> { interface }
2603
2677
  )
2604
2678
  end
@@ -19,7 +19,7 @@ module Samagotchi
19
19
  # describes them, nil when unset; models: {downcased id => HostModel}.
20
20
  HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
21
21
  :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
22
- :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
22
+ :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
23
23
  :models) do
24
24
  # Every field but name may be left out: nil, models {}.
25
25
  def initialize(**fields)
@@ -50,6 +50,7 @@ module Samagotchi
50
50
  vision: ConfigFile.vision_flag(value.call(:vision), where),
51
51
  remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
52
52
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
53
+ steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
53
54
  sampling: ConfigFile.sampling_map(value.call(:sampling), where),
54
55
  thinking: Thinking.level(value.call(:thinking), where),
55
56
  reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
@@ -170,6 +171,7 @@ module Samagotchi
170
171
  "profile" => profile, "first_token_timeout" => first_token_timeout,
171
172
  "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
172
173
  "reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
174
+ "steer_cut_after" => steer_cut_after,
173
175
  "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
174
176
  "llm_context_apply" => llm_context_apply&.to_s,
175
177
  "llm_context_budget_tokens" => llm_context_budget_tokens,
@@ -38,13 +38,14 @@ module Samagotchi
38
38
  # reasoning_history: the configured level (ReasoningHistory), nil when unset;
39
39
  # remote: the configured true/false (#remote?), nil when unset;
40
40
  # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
41
+ # steer_cut_after: the seconds a cut waits for its thinking (SteerCutAfter), nil when unset;
41
42
  # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
42
43
  # llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
43
44
  # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
44
45
  # models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
45
46
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
46
47
  :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
47
- :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
48
+ :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
48
49
  :models, keyword_init: true) do
49
50
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
50
51
  # raw-prompt loop.
@@ -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,9 +998,13 @@ 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"
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] }.compact
1007
+ elsif m[:role] == "model"
998
1008
  # `display` rides along so the stored conversation keeps it; the
999
1009
  # prompt formatter never reads it (AnswerDisplay).
1000
1010
  { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
@@ -1004,8 +1014,24 @@ module Samagotchi
1004
1014
  end
1005
1015
  end
1006
1016
 
1007
- # Standard multi-turn compliance: never pass prior raw thought blocks.
1008
- def prepare_conversation(messages) = sanitize_history(messages)
1017
+ # Standard multi-turn compliance: never pass prior raw thought blocks,
1018
+ # but a resumed run's (+keep_thought_from+, #thought_floor): the same
1019
+ # run going on after a step-limit Continue.
1020
+ def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
1021
+
1022
+ # Where a resumed run's thought blocks start: its start (+since+). Inside
1023
+ # one native run nothing re-sanitizes, so a user entry the run merged (a
1024
+ # steer, a delegate report, the Qwen recovery prompt) left the thoughts
1025
+ # before it in the prompt: they stay. Only a user or turn_start entry
1026
+ # added after the run ended (after its last model message: a !cmd's
1027
+ # output) starts them again past itself. nil without +since+.
1028
+ def thought_floor(messages, since)
1029
+ return nil if since.nil?
1030
+
1031
+ last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
1032
+ query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
1033
+ query && query > last_reply ? [query + 1, since].max : since
1034
+ end
1009
1035
 
1010
1036
  def duplicate_conversation(messages)
1011
1037
  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
@@ -68,12 +68,14 @@ module Samagotchi
68
68
 
69
69
  def provider = :chat
70
70
 
71
+ # +reasoning_since+ (ModelBackend#complete): the resumed run's start,
72
+ # the Run's own start then (#reasoning_boundary).
71
73
  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)
74
+ model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
73
75
  raise ArgumentError, "ChatLoop has no adapter" unless @adapter
74
76
 
75
77
  conversation = Array(messages).map(&:dup)
76
- Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
78
+ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
77
79
  .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
78
80
  rescue StandardError => e
79
81
  FailedTurn.attach(e, conversation && plain(conversation))
@@ -195,7 +197,8 @@ module Samagotchi
195
197
  # user message: a prompt, a steer, merged input, the chat template's
196
198
  # last query; or an entry that starts a turn, turn_start) and +since+
197
199
  # (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).
200
+ # a delegate report's wake -- sends none of the run before it; a
201
+ # step-limit Continue's Run starts where the run it resumes did).
199
202
  # @return [Integer, nil]
200
203
  def reasoning_boundary(conversation, since: nil, level: reasoning_history)
201
204
  case level
@@ -402,7 +405,9 @@ module Samagotchi
402
405
  # One turn's state: the conversation, the stream sink and tool
403
406
  # activity.
404
407
  class Run
405
- def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
408
+ # +start+: where the run a step-limit Continue resumes started (nil:
409
+ # this run starts at the conversation's end).
410
+ def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
406
411
  @loop = loop
407
412
  @conversation = conversation
408
413
  @on_stream_event = on_stream_event
@@ -410,8 +415,8 @@ module Samagotchi
410
415
  @model_name = model_name
411
416
  @pending_input = pending_input
412
417
  # Where this run's entries start: the reasoning it sends back
413
- # (ChatLoop#reasoning_boundary) is its own.
414
- @start = conversation.length
418
+ # (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
419
+ @start = start || conversation.length
415
420
  @tool_activity = []
416
421
  @empty_retry = EmptyAnswerRetry.new
417
422
  # 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)
@@ -10,10 +10,11 @@ module Samagotchi
10
10
  # downcased key. Each field is nil when unset: profile kept as written
11
11
  # (downcased; ModelProfile.resolve validates it), vision true/false,
12
12
  # sampling a request-field map, thinking a level, reasoning_history a
13
- # ReasoningHistory level, window_tokens, and the
13
+ # ReasoningHistory level, window_tokens, steer_cut_after whole seconds
14
+ # (0 = never, SteerCutAfter), and the
14
15
  # LLM context strategy, apply rule and budget (LLMContextStrategy). The
15
16
  # same per-model fields a hosts: entry carries (HostConfig).
16
- ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens,
17
+ ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens, :steer_cut_after,
17
18
  :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
18
19
  # Every field may be left out (nil).
19
20
  def initialize(**fields)
@@ -37,6 +38,7 @@ module Samagotchi
37
38
  thinking: Thinking.level(value.call(:thinking), where),
38
39
  reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
39
40
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
41
+ steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
40
42
  llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
41
43
  llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
42
44
  llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
@@ -19,7 +19,9 @@ module Samagotchi
19
19
  # (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
20
20
  # the answer text.
21
21
  # +messages_partial+ says whether +messages+ leaves out a running turn;
22
- # +model_name+ (the session's resolved model ref, live after /model) and
22
+ # +prompt_files+ returns the files whose text the built system prompt
23
+ # holds (SystemPrompt#prompt_files, [] before a build); +model_name+
24
+ # (the session's resolved model ref, live after /model) and
23
25
  # +state_dir+ are what ctx.sessions forks with, and +setup+ (the
24
26
  # session's own SessionSetup), which a fork copies; +model_key+ is the
25
27
  # model's memory overlay key (ctx.model, ctx.model_key).
@@ -28,8 +30,8 @@ module Samagotchi
28
30
  # +frontend+ returns the Engine's interface (:repl, :worker,
29
31
  # :non_interactive), which ctx.frontend names for plugins.
30
32
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
31
- :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :steer, :stop_turn, :stop_generation,
32
- :frontend, keyword_init: true)
33
+ :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
34
+ :steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
33
35
 
34
36
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
35
37
  # sent nothing usable. The message says why, for the user.
@@ -159,6 +161,17 @@ module Samagotchi
159
161
  # a plugin can say what its answer is about.
160
162
  def messages_partial? = !!@host.messages_partial&.call
161
163
 
164
+ # The files whose text the session's system prompt holds now, as
165
+ # absolute paths in prompt order (SystemPrompt#prompt_files): the
166
+ # AGENTS.md chain (the work tree top down to the cwd), then the
167
+ # appended files (append_system_prompt:, --append-system-prompt).
168
+ # [] before the first build and after one that drops them (a model
169
+ # or tools change). A Host without the member (a spec's) reads [].
170
+ # @return [Array<String>] frozen
171
+ def prompt_files
172
+ Array(@host.prompt_files&.call || []).freeze
173
+ end
174
+
162
175
  # Run +block+ as this plugin's handler for +event+: until it returns,
163
176
  # the helpers called on this thread act as the event's own. Nests (a
164
177
  # fire inside a handler gets its own event; the outer one is back
@@ -109,23 +109,96 @@ module Samagotchi
109
109
  # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
110
110
  # waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
111
111
  # done, when kill returns).
112
+ #
113
+ # The groups the group's processes started are stopped with it: a
114
+ # descendant that moved to a group of its own (GNU `timeout N cmd`
115
+ # without --foreground calls setpgid) is out of reach of a signal to
116
+ # +pgid+. They are found (#descendant_groups) before the TERM, while the
117
+ # group's processes still run, and again before the KILL; each is
118
+ # signalled too and must be gone as well. One that already lost its
119
+ # parent (reparented to init: a setsid'd `cmd &` whose shell exited) is
120
+ # not found, and so left running.
112
121
  # @param stopped [#call] true once it has stopped (default: the leader
113
122
  # is gone)
114
123
  # @param wait [#call] (seconds) between two checks (default: a sleep)
124
+ # @param walk [Boolean] also stop the descendants' groups (one ps each
125
+ # time, ~40 ms on macOS with ~850 processes)
115
126
  # @param signal_options [Hash] for #signal (leader:, quiet:)
116
127
  # @return [Boolean] true when it stopped within the grace (no KILL sent)
117
128
  # @raise [ArgumentError] unless #signalable?
118
129
  def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
119
- kill_wait: KILL_WAIT_SEC, **signal_options)
130
+ kill_wait: KILL_WAIT_SEC, walk: true, **signal_options)
120
131
  check!(pgid)
132
+ # The group's members only: a leader reaped by now may be another process.
133
+ extra = walk ? descendant_groups(pgid) : []
121
134
  signal(pgid, "TERM", **signal_options)
122
- return true if wait_for(stopped, grace, poll, wait)
135
+ extra.each { |group| signal(group, "TERM") }
136
+ # +stopped+ is asked until it says true once (it may reap: a second
137
+ # wait on a reaped pid raises ECHILD), then only the extra groups.
138
+ done = false
139
+ all_stopped = -> { (done ||= stopped.call ? true : false) && extra.none? { |group| group_alive?(group) } }
140
+ return true if wait_for(all_stopped, grace, poll, wait)
123
141
 
142
+ extra |= descendant_groups(pgid) if walk
124
143
  signal(pgid, "KILL", **signal_options)
125
- wait_for(stopped, kill_wait, poll, wait)
144
+ extra.each { |group| signal(group, "KILL") }
145
+ wait_for(all_stopped, kill_wait, poll, wait)
126
146
  false
127
147
  end
128
148
 
149
+ # Whether any process is still in the group +pgid+.
150
+ def group_alive?(pgid)
151
+ check!(pgid)
152
+ Process.kill(0, -pgid)
153
+ true
154
+ rescue Errno::ESRCH
155
+ false
156
+ rescue Errno::EPERM
157
+ true
158
+ end
159
+
160
+ # The process groups, other than +pgid+ and chi's own, of the
161
+ # descendants of the processes in the group +pgid+ (and of the process
162
+ # +pgid+ itself with +leader+: a leader chi started that left its
163
+ # group). Read from one `ps -A -o pid=,ppid=,pgid=`; [] when ps fails.
164
+ # Only members of the group are roots, never the bare pid +pgid+ without
165
+ # +leader+: a reaped leader's pid may name some other process by now.
166
+ # @return [Array<Integer>]
167
+ def descendant_groups(pgid, leader: false)
168
+ rows = process_table
169
+ children = rows.group_by { |_pid, ppid, _group| ppid }
170
+ queue = rows.select { |_pid, _ppid, group| group == pgid }.map(&:first)
171
+ queue << pgid if leader
172
+ seen = {}
173
+ groups = []
174
+ until queue.empty?
175
+ pid = queue.shift
176
+ next if seen[pid]
177
+
178
+ seen[pid] = true
179
+ Array(children[pid]).each do |child, _ppid, group|
180
+ groups << group unless group == pgid
181
+ queue << child
182
+ end
183
+ end
184
+ own = Process.getpgrp
185
+ groups.uniq.select { |group| signalable?(group) && group != own }
186
+ end
187
+
188
+ # Every process as [pid, ppid, pgid].
189
+ # @return [Array<Array(Integer, Integer, Integer)>]
190
+ def process_table
191
+ out, status = Open3.capture2("ps", "-A", "-o", "pid=,ppid=,pgid=", err: File::NULL)
192
+ return [] unless status.success?
193
+
194
+ out.lines.filter_map do |line|
195
+ fields = line.split.map { |field| Integer(field, exception: false) }
196
+ fields if fields.size == 3 && fields.all?
197
+ end
198
+ rescue SystemCallError, IOError
199
+ []
200
+ end
201
+
129
202
  # Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
130
203
  # @return [Boolean] whether it stopped
131
204
  def wait_for(stopped, seconds, poll, wait)