samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -22,6 +22,8 @@ require_relative "tool_activity"
22
22
  require_relative "tool_runner"
23
23
  require_relative "tool_response"
24
24
  require_relative "answer_display"
25
+ require_relative "llm/model_result"
26
+ require_relative "llm/turn_settings"
25
27
 
26
28
  module Samagotchi
27
29
  # The KernelLoop drives the model ↔ tool interaction cycle.
@@ -37,30 +39,12 @@ module Samagotchi
37
39
  # - Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
38
40
  # - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
39
41
  class KernelLoop
40
- Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
41
- def exhausted?
42
- exhausted
43
- end
44
-
45
- def pending_tool_calls?
46
- pending_tool_calls
47
- end
48
-
49
- def resumable?
50
- exhausted? && pending_tool_calls?
51
- end
52
-
53
- def canceled?
54
- canceled
55
- end
56
- end
57
-
58
42
  # The built-in tool classes (Tools::Builtins registers them).
59
43
  TOOLS = Tools::Builtins::CLASSES
60
44
 
61
45
  # What a tool handler (call, kctx) gets from the kernel: the reminder
62
46
  # store, the peers, the model key, and the memory read and ask-user
63
- # flows that need its state.
47
+ # flows that need its state (read through the kernel's public readers).
64
48
  class ToolContext
65
49
  def initialize(kernel)
66
50
  @kernel = kernel
@@ -69,8 +53,44 @@ module Samagotchi
69
53
  def reminder_store = @kernel.reminder_store
70
54
  def peers = @kernel.peers
71
55
  def model_key = @kernel.model_key
72
- def muted_memory_read(call) = @kernel.__send__(:muted_memory_read, Tools::MemoryRead, call)
73
- def ask_user_question(call) = @kernel.__send__(:handle_ask_user_question, call)
56
+
57
+ # memory_read with the session's mutes applied: a blank name (the index)
58
+ # loses the muted memories' lines; a muted name in a comma list is
59
+ # refused with its own error line and the rest is read as usual.
60
+ def muted_memory_read(call)
61
+ muted = Array(@kernel.muted_memory_names)
62
+ content = call[:content].to_s
63
+ read = lambda do |names|
64
+ Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @kernel.model_key_fallback)
65
+ end
66
+ return read.call(content) if muted.empty?
67
+ return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
68
+
69
+ names = Tools::MemoryRead.parse_names(content)
70
+ refused, allowed = names.partition { |name| MutedMemories.muted?(name, muted) }
71
+ return read.call(content) if refused.empty?
72
+
73
+ errors = refused.map { |name| "Error: memory '#{name}' is muted for this session" }
74
+ return errors.join("\n") if allowed.empty?
75
+
76
+ [read.call(allowed.join(",")), *errors].join(Tools::MemoryRead::SEPARATOR)
77
+ end
78
+
79
+ # ask_user_question: validate the call, then hand the payload to the
80
+ # Engine's question flow (the kernel's question_handler, which blocks
81
+ # until the user answers). Without one (headless), the payload as JSON
82
+ # so the model sees the options and can ask in plain text.
83
+ def ask_user_question(call)
84
+ payload = Tools::AskUserQuestion.validate(call)
85
+ return payload if payload.is_a?(String)
86
+
87
+ handler = @kernel.question_handler
88
+ return JSON.pretty_generate(payload) unless handler
89
+
90
+ handler.call(payload).to_s
91
+ rescue StandardError => e
92
+ "Error: ask_user_question handler failed: #{e.message}"
93
+ end
74
94
  end
75
95
 
76
96
  DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
@@ -83,11 +103,12 @@ module Samagotchi
83
103
  @client = client || Client.new
84
104
  @tools = tools || Tools::Builtins.default
85
105
  resolved_model_name = ModelProfile.required_model_name(model_name)
86
- # The resolved model id actually used for this run (per-run override wins
106
+ # The turn's settings (LLM::TurnSettings). Its model name is the
107
+ # resolved model id actually used for this run (per-run override wins
87
108
  # over the config alias); on every debug dump so we can see exactly
88
- # which model each request went to. The Engine sets it per turn too:
109
+ # which model each request went to. The Engine sets them per turn:
89
110
  # the chat loop dispatches tools here without going through #run.
90
- @current_model_name = resolved_model_name
111
+ @turn_settings = LLM::TurnSettings.none.with(model_name: resolved_model_name)
91
112
  @profile = profile ? ModelProfile.normalize(profile) : ModelProfile.from_model_name(resolved_model_name)
92
113
  # Where @profile came from, as /stats shows it (a Resolution's label
93
114
  # once the Engine resolves one, see #use_profile!).
@@ -98,24 +119,25 @@ module Samagotchi
98
119
  @model_key_fallback = nil
99
120
  end
100
121
 
101
- # @return [ReminderStore, nil] the reminder store for inspection (used by
102
- # Engine to share the same store with the KernelLoop when TerminalUI
103
- # creates both).
104
- attr_reader :reminder_store
122
+ # @return [ReminderStore, nil] the reminder store the reminder tools
123
+ # write; the Engine's own (it hands it to a kernel it was given).
124
+ attr_accessor :reminder_store
105
125
 
106
126
  # @return [ModelProfile] the active prompt profile
107
127
  attr_reader :profile
108
128
 
109
129
  # @return [Samagotchi::Hooks::Registry, nil] hooks registry shared with Engine.
110
130
  # Engine owns the registry; KernelLoop only fires events. Accessor allows
111
- # Engine to propagate its registry to an externally-created kernel (TUI path).
131
+ # Engine to hand its registry to a kernel it is given (specs).
112
132
  attr_accessor :hooks
113
133
  # @return [Tools::Registry] the tools #dispatch runs. The Engine sets
114
- # its own on a kernel built before it (the REPL's), as with hooks.
134
+ # its own on a kernel it is given, as with hooks.
115
135
  attr_accessor :tools
116
136
  attr_accessor :client
117
137
  attr_accessor :model_key
118
- attr_accessor :current_model_name
138
+ # @return [String, nil] the key whose overlay memory_read takes when
139
+ # #model_key has none (#sync_model_key!)
140
+ attr_reader :model_key_fallback
119
141
  # @return [Array<String>, nil] the session's muted memories (normalized
120
142
  # names, see MutedMemories); memory_read refuses them. The Engine sets it.
121
143
  attr_accessor :muted_memory_names
@@ -125,15 +147,11 @@ module Samagotchi
125
147
  # The Guardrails::Gate ToolRunner asks before each call; the Engine sets
126
148
  # it (nil: ToolRunner's own, hooks only).
127
149
  attr_accessor :guardrail_gate
128
- # The turn's VisionContext (images: capability, files, limits), set by
129
- # the Engine per turn; nil sends no images (placeholders instead).
130
- attr_accessor :vision
131
- # The turn's request parameters (SamplingSettings.for), set by the Engine
132
- # per turn; empty or nil sends none.
133
- attr_accessor :sampling
134
- # The turn's thinking level (Thinking.resolve), set by the Engine per
135
- # turn; its own accessor, since the native path sends @sampling as is.
136
- attr_accessor :thinking
150
+ # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
151
+ # model name, set by the Engine per turn (#run sets the model name
152
+ # too); the thinking level is its own field, since the native path
153
+ # sends the sampling as is.
154
+ attr_accessor :turn_settings
137
155
  # Tools::Peers (or the Engine's live view of it): the session
138
156
  # list_sessions and send_note speak for; nil outside a session.
139
157
  attr_accessor :peers
@@ -142,7 +160,7 @@ module Samagotchi
142
160
  # resumable conversation state when execution stops at max_iterations.
143
161
  #
144
162
  # @param messages [Array<Hash>] conversation so far ({role:, content:});
145
- # a stopped run resumes from its Result#conversation
163
+ # a stopped run resumes from its result's #conversation
146
164
  # @param max_iterations [Integer] safety cap on tool-call rounds
147
165
  # @param on_stream_event [Proc, nil] optional callback for generation events
148
166
  # @param cancel_controller [CancellationController, nil] optional cancellation source
@@ -155,16 +173,16 @@ module Samagotchi
155
173
  # steering mid-stream); drained lines merge into ONE user message appended
156
174
  # at the conversation tail (prefix KV cache preserved) and a
157
175
  # :pending_input_merged stream event is emitted.
158
- # @return [Result] final visible response with continuation metadata
176
+ # @return [LLM::ModelResult] final visible response with continuation metadata
159
177
  def run(messages, max_iterations: 100, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
160
- @current_model_name = completion_model_name(model_name)
178
+ @turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
161
179
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
162
180
  pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
163
181
 
164
182
  max_iterations.times do |iteration_index|
165
183
  turn.iteration = iteration_index + 1
166
184
  outcome = iterate(turn)
167
- return outcome if outcome.is_a?(Result)
185
+ return outcome if outcome.is_a?(LLM::ModelResult)
168
186
  break if outcome == :answer
169
187
  rescue Client::RequestCancelled => e
170
188
  emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
@@ -220,15 +238,15 @@ module Samagotchi
220
238
  # Qwen with thinking off: an empty thought after the cue, so the model
221
239
  # answers at once. Kept in the turn's model messages, so each tool-loop
222
240
  # prompt starts with what the server already has cached.
223
- prefill: Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill,
224
- pending_tool_calls: false, model_name: @current_model_name, pending_input: pending_input,
241
+ prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
242
+ pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
225
243
  on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
226
244
  emit: ->(event) { emit_stream_event(on_stream_event, event) }
227
245
  )
228
246
  end
229
247
 
230
248
  # One iteration. Returns :next, :answer (the turn ends), or the turn's
231
- # Result (it ended cancelled).
249
+ # result (it ended cancelled).
232
250
  def iterate(turn)
233
251
  inject_pending_input!(turn)
234
252
  request = prepare_request(turn)
@@ -264,7 +282,7 @@ module Samagotchi
264
282
  end
265
283
 
266
284
  def format_prompt(turn)
267
- Prompt.format_with_images(turn.conversation, profile: @profile, vision: @vision, prefill: turn.prefill)
285
+ Prompt.format_with_images(turn.conversation, profile: @profile, vision: @turn_settings.vision, prefill: turn.prefill)
268
286
  end
269
287
 
270
288
  # One streamed request, under the generation's own controller: a
@@ -290,7 +308,7 @@ module Samagotchi
290
308
  generation_controller: generation_controller,
291
309
  cancel_controller: turn.cancel_controller,
292
310
  model_name: turn.model_name,
293
- sampling: turn.empty_retry.request_sampling(@sampling),
311
+ sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
294
312
  on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
295
313
  on_retry: lambda { |retry_event|
296
314
  # The retry streams from the start: its counts replace these.
@@ -357,7 +375,7 @@ module Samagotchi
357
375
  # with its own nudge while the retry budget lasts (queued input goes in
358
376
  # place of the nudge), else the turn ends as cancelled (hook), with
359
377
  # nothing salvaged and without the spent nudge. A Stop that came right
360
- # after the cut is a plain cancel. Returns :next or the turn's Result.
378
+ # after the cut is a plain cancel. Returns :next or the turn's result.
361
379
  def after_cut(turn, generation)
362
380
  cut = generation.cut
363
381
  raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
@@ -429,20 +447,19 @@ module Samagotchi
429
447
  :next
430
448
  end
431
449
 
432
- # The turn's Result. It is exhausted only when it stopped on tool
433
- # results it never answered; then the answer shows only its text.
450
+ # The turn's result. It is exhausted only when it stopped on tool
451
+ # results it never answered (so exhausted? alone means resumable?);
452
+ # then the answer shows only its text.
434
453
  def finish(turn)
435
454
  exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
436
455
  output = strip_thought_blocks(last_model_content(turn.conversation))
437
456
  output = parser.strip_tool_calls(output) if exhausted
438
- Result.new(
439
- output: PromptLiteralGuard.restore(output, profile: @profile),
457
+ LLM::ModelResult.new(
458
+ text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
440
459
  conversation: duplicate_conversation(turn.conversation),
441
460
  exhausted: exhausted,
442
461
  pending_tool_calls: turn.pending_tool_calls,
443
462
  tool_activity: turn.tool_activity,
444
- canceled: false,
445
- cancellation_reason: nil,
446
463
  context_status: turn.context.display
447
464
  )
448
465
  end
@@ -458,26 +475,6 @@ module Samagotchi
458
475
  cancel_controller: turn.cancel_controller, answer: answer)
459
476
  end
460
477
 
461
- # memory_read with the session's mutes applied: a blank name (the index)
462
- # loses the muted memories' lines; a muted name in a comma list is
463
- # refused with its own error line and the rest is read as usual.
464
- def muted_memory_read(tool, call)
465
- muted = Array(@muted_memory_names)
466
- content = call[:content].to_s
467
- read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key, fallback_model_key: @model_key_fallback) }
468
- return read.call(content) if muted.empty?
469
- return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
470
-
471
- names = Tools::MemoryRead.parse_names(content)
472
- refused, allowed = names.partition { |name| MutedMemories.muted?(name, muted) }
473
- return read.call(content) if refused.empty?
474
-
475
- errors = refused.map { |name| "Error: memory '#{name}' is muted for this session" }
476
- return errors.join("\n") if allowed.empty?
477
-
478
- [read.call(allowed.join(",")), *errors].join(Tools::MemoryRead::SEPARATOR)
479
- end
480
-
481
478
  def emit_stream_event(callback, event)
482
479
  callback&.call(event)
483
480
  rescue StandardError
@@ -549,14 +546,14 @@ module Samagotchi
549
546
  # Only a request with images names them: a text-only call is unchanged.
550
547
  kwargs[:images] = images unless images.empty?
551
548
  kwargs[:on_chunk] = on_chunk if on_chunk
552
- kwargs[:on_retry] = on_retry if on_retry && client_supports_keyword?(:on_retry)
553
- kwargs[:cancel_controller] = cancel_controller if cancel_controller && client_supports_keyword?(:cancel_controller)
554
- kwargs[:stop] = @profile.stop_sequences if client_supports_keyword?(:stop)
549
+ kwargs[:on_retry] = on_retry if on_retry
550
+ kwargs[:cancel_controller] = cancel_controller if cancel_controller
551
+ kwargs[:stop] = @profile.stop_sequences
555
552
  n_predict = completion_n_predict
556
- kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
553
+ kwargs[:n_predict] = n_predict if n_predict
557
554
  resolved_model_name = completion_model_name(model_name)
558
- kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
559
- kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
555
+ kwargs[:model] = resolved_model_name if resolved_model_name
556
+ kwargs[:sampling] = sampling if sampling && !sampling.empty?
560
557
  kwargs
561
558
  end
562
559
 
@@ -569,19 +566,6 @@ module Samagotchi
569
566
  ModelProfile.required_model_name(override)
570
567
  end
571
568
 
572
- def client_supports_keyword?(keyword)
573
- @client_complete_keyword_support ||= {}
574
- return @client_complete_keyword_support[keyword] if @client_complete_keyword_support.key?(keyword)
575
-
576
- @client_complete_keyword_support[keyword] = begin
577
- parameters = @client.method(:complete).parameters
578
- parameters.any? { |kind, name| (kind == :key || kind == :keyreq) && name == keyword } ||
579
- parameters.any? { |kind, _name| kind == :keyrest }
580
- rescue StandardError
581
- false
582
- end
583
- end
584
-
585
569
  def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
586
570
  partial = partial_assistant_text.to_s.strip
587
571
  conversation = duplicate_conversation(conversation)
@@ -592,11 +576,9 @@ module Samagotchi
592
576
  unless partial.empty?
593
577
  conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
594
578
  end
595
- Result.new(
596
- output: "",
579
+ LLM::ModelResult.new(
580
+ text: "",
597
581
  conversation: conversation,
598
- exhausted: false,
599
- pending_tool_calls: false,
600
582
  tool_activity: tool_activity,
601
583
  canceled: true,
602
584
  cancellation_reason: reason
@@ -608,7 +590,7 @@ module Samagotchi
608
590
  def dump_log(event, payload, **fields)
609
591
  return unless Log.level?(:debug)
610
592
 
611
- Log.debug(:model, event, payload: payload, model: @current_model_name, **fields)
593
+ Log.debug(:model, event, payload: payload, model: @turn_settings.model_name, **fields)
612
594
  end
613
595
 
614
596
  # Estimate context usage for this iteration's prompt (ContextStatus#observe)
@@ -633,12 +615,6 @@ module Samagotchi
633
615
  strip_thought_blocks(text)
634
616
  end
635
617
 
636
- # The status line's context value ({est_pct:, bucket:}) for +used_tokens+
637
- # of +window_tokens+; nil without both, or with context.status off.
638
- def context_display(used_tokens:, window_tokens:)
639
- ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
640
- end
641
-
642
618
  # Per-profile parse strategy. Rebuilt when the active profile changes
643
619
  # (the profile may be re-inferred per run when not explicitly pinned).
644
620
  # Public so other loops (and specs) can reach the active profile's parser.
@@ -743,19 +719,5 @@ module Samagotchi
743
719
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
744
720
  }
745
721
  end
746
-
747
- # ask_user_question: validate the call, then hand the payload to the
748
- # Engine's question flow (question_handler, which blocks until the user
749
- # answers). Without one (headless), the payload as JSON so the model sees
750
- # the options and can ask in plain text.
751
- def handle_ask_user_question(call)
752
- payload = Samagotchi::Tools::AskUserQuestion.validate(call)
753
- return payload if payload.is_a?(String)
754
- return JSON.pretty_generate(payload) unless @question_handler
755
-
756
- @question_handler.call(payload).to_s
757
- rescue => e
758
- "Error: ask_user_question handler failed: #{e.message}"
759
- end
760
722
  end
761
723
  end
@@ -77,10 +77,8 @@ module Samagotchi
77
77
  end
78
78
  end
79
79
 
80
- # The kernel's tools; the built-ins for a kernel without a registry.
81
- def tools
82
- @kernel.respond_to?(:tools) ? @kernel.tools : Tools::Builtins.default
83
- end
80
+ # The kernel's tools.
81
+ def tools = @kernel.tools
84
82
 
85
83
  # engine-format conversation -> OpenAI wire messages. Model turns are
86
84
  # thought-stripped and carry their tool_calls; tool responses go as tool
@@ -133,20 +131,15 @@ module Samagotchi
133
131
 
134
132
  TOOL_IMAGES_TEXT = "[images from tool results]"
135
133
 
136
- # The turn's VisionContext (the Engine sets it on the kernel), or nil.
137
- def vision
138
- @kernel.respond_to?(:vision) ? @kernel.vision : nil
139
- end
134
+ # The turn's VisionContext (the Engine sets the kernel's
135
+ # turn_settings), or nil.
136
+ def vision = @kernel.turn_settings.vision
140
137
 
141
- # The turn's request parameters (the Engine sets them on the kernel).
142
- def sampling
143
- @kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
144
- end
138
+ # The turn's request parameters (the kernel's turn_settings).
139
+ def sampling = @kernel.turn_settings.sampling || {}
145
140
 
146
- # The turn's thinking level (the Engine sets it on the kernel).
147
- def thinking
148
- (@kernel.respond_to?(:thinking) && @kernel.thinking) || Thinking::DEFAULT
149
- end
141
+ # The turn's thinking level (the kernel's turn_settings).
142
+ def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
150
143
 
151
144
  # The request fields the thinking level adds (Thinking.chat_fields);
152
145
  # none for a model whose host refused them (#thinking_refused!).
@@ -171,9 +164,7 @@ module Samagotchi
171
164
  deep_compact(options)
172
165
  end
173
166
 
174
- def strip_model_thought(text)
175
- @kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
176
- end
167
+ def strip_model_thought(text) = @kernel.strip_model_thought(text)
177
168
 
178
169
  def tool_runner
179
170
  @tool_runner ||= ToolRunner.new(@kernel)
@@ -184,8 +175,7 @@ module Samagotchi
184
175
  # llama.cpp answers /props), then the host's model list, config, env
185
176
  # and the default. A remote provider has no /props to probe.
186
177
  def context_window(model)
187
- remote = @adapter.respond_to?(:remote?) && @adapter.remote?
188
- client = @kernel.client if !remote && @kernel.respond_to?(:client)
178
+ client = @adapter.remote? ? nil : @kernel.client
189
179
  ContextWindow.resolve(client: client, model: model, adapter: @adapter)
190
180
  rescue StandardError
191
181
  nil
@@ -193,8 +183,7 @@ module Samagotchi
193
183
 
194
184
  # A hook failing must not break the turn (as in ToolRunner).
195
185
  def fire_hook(name, event)
196
- hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
197
- hooks&.fire(name, event)
186
+ @kernel.hooks&.fire(name, event)
198
187
  rescue StandardError
199
188
  nil
200
189
  end
@@ -297,7 +286,7 @@ module Samagotchi
297
286
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
298
287
  end
299
288
 
300
- # One turn's state: the conversation, the stream sink, usage and tool
289
+ # One turn's state: the conversation, the stream sink and tool
301
290
  # activity.
302
291
  class Run
303
292
  def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
@@ -307,12 +296,7 @@ module Samagotchi
307
296
  @cancel_controller = cancel_controller
308
297
  @model_name = model_name
309
298
  @pending_input = pending_input
310
- @usage = UsageCollector.new
311
299
  @tool_activity = []
312
- # What an estimate counts as the prompt when the server reports no
313
- # usage: the text, and each image's estimate (not its base64).
314
- @prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
315
- @image_tokens = ImagePlan.estimated_tokens(conversation)
316
300
  @empty_retry = EmptyAnswerRetry.new
317
301
  @context = ContextStatus.new(conversation: conversation)
318
302
  end
@@ -578,23 +562,18 @@ module Samagotchi
578
562
  @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
579
563
  conversation = @loop.plain(@conversation)
580
564
  conversation.last[:interrupted] = true unless visible.empty?
581
- ModelResult.new(text: "", provider: :chat, conversation: conversation, canceled: true,
582
- cancellation_reason: reason, tool_activity: @tool_activity, usage: usage,
565
+ ModelResult.new(text: "", conversation: conversation, canceled: true,
566
+ cancellation_reason: reason, tool_activity: @tool_activity,
583
567
  context_status: @context.display)
584
568
  end
585
569
 
586
570
  def result(text, exhausted:)
587
- ModelResult.new(text: text, provider: :chat, conversation: @loop.plain(@conversation), exhausted: exhausted,
588
- tool_activity: @tool_activity, usage: usage, empty_answer: text == EMPTY_ANSWER,
571
+ ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
572
+ tool_activity: @tool_activity, empty_answer: text == EMPTY_ANSWER,
589
573
  context_status: @context.display)
590
574
  end
591
575
 
592
- def usage
593
- @usage.usage(prompt_text: @prompt_text, extra_prompt_tokens: @image_tokens)
594
- end
595
-
596
576
  def emit(event)
597
- @usage.observe(event)
598
577
  @on_stream_event&.call(event)
599
578
  rescue StandardError
600
579
  nil
@@ -3,29 +3,20 @@
3
3
  module Samagotchi
4
4
  module LLM
5
5
  # Normalized result returned by every ModelBackend downstream of the agentic
6
- # loop. The loop lives INSIDE the backend (Option A), so raw provider tool
7
- # calls are never surfaced — `tool_calls` is always `nil` downstream.
8
- #
9
- # It deliberately mirrors the read surface Engine#run_turn and the renderers
10
- # (TerminalUI / Bridge) currently consume off KernelLoop::Result, so wiring
11
- # the seam in does not require changes to those consumers:
12
- # .conversation, .canceled?, .cancellation_reason, .output, .exhausted?
13
- # plus the native loop's .tool_activity, .context_status and
14
- # .pending_tool_calls, which the interactive renderer needs.
6
+ # loop (the loop lives inside the backend). What Engine#run_turn and the
7
+ # renderers (TerminalUI / Bridge) read: .output (= .text, and .to_s, which
8
+ # is how the :turn_completed `result:` reaches JSON), .conversation,
9
+ # .canceled?, .cancellation_reason, .exhausted?, .resumable?,
10
+ # .empty_answer?, plus the native loop's .tool_activity, .context_status
11
+ # and .pending_tool_calls, which the interactive renderer needs.
15
12
  class ModelResult
16
- attr_reader :text, :tool_calls, :provider, :usage, :metadata,
17
- :conversation, :canceled, :cancellation_reason, :exhausted,
13
+ attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
18
14
  :tool_activity, :context_status, :pending_tool_calls
19
15
 
20
- def initialize(text:, tool_calls: nil, provider: nil, usage: nil, metadata: nil,
21
- conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
16
+ def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
22
17
  tool_activity: [], context_status: nil, pending_tool_calls: false, empty_answer: false)
23
18
  @empty_answer = empty_answer
24
19
  @text = text
25
- @tool_calls = tool_calls
26
- @provider = provider
27
- @usage = usage
28
- @metadata = metadata
29
20
  @conversation = conversation
30
21
  @canceled = canceled
31
22
  @cancellation_reason = cancellation_reason
@@ -44,10 +35,13 @@ module Samagotchi
44
35
  !!canceled
45
36
  end
46
37
 
47
- # The turn ended with nothing visible: the text shown is a placeholder,
48
- # the conversation holds no reply.
38
+ # The turn ended with nothing visible: no text, or the chat loop's
39
+ # placeholder (empty_answer:). A cancelled turn or one that can be
40
+ # continued is not an empty answer.
49
41
  def empty_answer?
50
- !!@empty_answer
42
+ return false if canceled? || resumable?
43
+
44
+ !!@empty_answer || text.to_s.strip.empty?
51
45
  end
52
46
 
53
47
  def exhausted?
@@ -2,12 +2,11 @@
2
2
 
3
3
  require_relative "backend"
4
4
  require_relative "model_result"
5
- require_relative "usage"
6
5
 
7
6
  module Samagotchi
8
7
  module LLM
9
8
  # The raw-prompt loop as a backend: KernelLoop#run (llama.cpp /completion,
10
- # Gemma/Qwen prompt formats) with its result wrapped in a ModelResult, so
9
+ # Gemma/Qwen prompt formats), which returns a ModelResult itself, so
11
10
  # Engine and the TUI call every backend the same way.
12
11
  class NativeBackend < ModelBackend
13
12
  def initialize(kernel:)
@@ -18,32 +17,15 @@ module Samagotchi
18
17
 
19
18
  def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
20
19
  model_name: nil, max_tool_output_chars: nil, pending_input: nil)
21
- usage = UsageCollector.new
22
- kernel_result = @kernel.run(
20
+ @kernel.run(
23
21
  messages,
24
22
  max_iterations: max_iterations,
25
- on_stream_event: lambda { |event|
26
- usage.observe(event)
27
- on_stream_event&.call(event)
28
- },
23
+ on_stream_event: ->(event) { on_stream_event&.call(event) },
29
24
  cancel_controller: cancel_controller,
30
25
  model_name: model_name,
31
26
  max_tool_output_chars: max_tool_output_chars,
32
27
  pending_input: pending_input
33
28
  )
34
- ModelResult.new(
35
- text: kernel_result.output.to_s,
36
- tool_calls: nil,
37
- provider: :native,
38
- usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
39
- conversation: kernel_result.conversation,
40
- canceled: kernel_result.canceled?,
41
- cancellation_reason: kernel_result.cancellation_reason,
42
- exhausted: kernel_result.exhausted,
43
- tool_activity: kernel_result.tool_activity,
44
- context_status: kernel_result.context_status,
45
- pending_tool_calls: kernel_result.pending_tool_calls
46
- )
47
29
  end
48
30
  end
49
31
  end
@@ -56,7 +56,7 @@ module Samagotchi
56
56
  # @param entry [HostRegistry::HostEntry]
57
57
  def self.for(entry, **options)
58
58
  new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
59
- remote: entry.respond_to?(:remote?) && entry.remote?, **options)
59
+ remote: entry.remote?, **options)
60
60
  end
61
61
 
62
62
  # Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
@@ -245,12 +245,19 @@ module Samagotchi
245
245
  function = call["function"] || {}
246
246
  ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
247
247
  end
248
- ChatResponse.new(text: message["content"].to_s,
248
+ ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
249
249
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
250
250
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
251
251
  finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
252
252
  end
253
253
 
254
+ # Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
255
+ # writes after </think> at the start of the answer
256
+ # (https://github.com/incoai/splash/issues/254). Remove once Splash
257
+ # strips it; llama.cpp already does. Streamed, it applies to the chunks
258
+ # until the first text.
259
+ def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
260
+
254
261
  def self.served_model(payload)
255
262
  model = payload.is_a?(Hash) ? payload["model"] : nil
256
263
  model.is_a?(String) && !model.strip.empty? ? model : nil
@@ -372,6 +379,7 @@ module Samagotchi
372
379
  @finish_reason = choice["finish_reason"] if choice["finish_reason"]
373
380
  delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
374
381
  content = delta["content"].to_s
382
+ content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
375
383
  reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
376
384
  @text << content
377
385
  @reasoning << reasoning
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../thinking"
4
+
5
+ module Samagotchi
6
+ module LLM
7
+ # A turn's settings on the kernel, read by both loops (KernelLoop,
8
+ # ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
9
+ # images, placeholders instead), its request parameters
10
+ # (SamplingSettings.for; empty sends none), its thinking level
11
+ # (Thinking.resolve) and the bare model id the turn runs on (every
12
+ # debug dump is tagged with it). The Engine sets them once per turn,
13
+ # in Engine#generate, after the model's host is synced.
14
+ TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name) do
15
+ # No vision, no sampling, the default thinking, no model name.
16
+ def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil)
17
+ end
18
+ end
19
+ end