samagotchi 0.11.0 → 0.12.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +25 -1
  6. data/docs/desktop.md +24 -2
  7. data/lib/samagotchi/bridge.rb +4 -4
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  10. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  11. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  12. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  13. data/lib/samagotchi/engine.rb +445 -482
  14. data/lib/samagotchi/host_registry.rb +26 -2
  15. data/lib/samagotchi/idle_reminders.rb +13 -36
  16. data/lib/samagotchi/kernel_loop.rb +82 -120
  17. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  18. data/lib/samagotchi/llm/model_result.rb +14 -20
  19. data/lib/samagotchi/llm/native_backend.rb +3 -21
  20. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  21. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  22. data/lib/samagotchi/llm/usage.rb +2 -57
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  24. data/lib/samagotchi/model_catalog.rb +101 -0
  25. data/lib/samagotchi/model_profile.rb +1 -1
  26. data/lib/samagotchi/models_command.rb +112 -0
  27. data/lib/samagotchi/reminder_queue.rb +73 -0
  28. data/lib/samagotchi/reminder_store.rb +3 -3
  29. data/lib/samagotchi/self_report.rb +8 -1
  30. data/lib/samagotchi/send_command.rb +1 -1
  31. data/lib/samagotchi/session_commands.rb +5 -5
  32. data/lib/samagotchi/session_manager.rb +2 -6
  33. data/lib/samagotchi/terminal_ui.rb +8 -28
  34. data/lib/samagotchi/thinking.rb +1 -1
  35. data/lib/samagotchi/tool_runner.rb +4 -6
  36. data/lib/samagotchi/tools/builtins.rb +1 -1
  37. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  38. data/lib/samagotchi/turn_flow.rb +4 -4
  39. data/lib/samagotchi/turn_state.rb +126 -0
  40. data/lib/samagotchi/used_memories.rb +88 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +4 -4
  43. data/lib/samagotchi/web/app.rb +14 -33
  44. data/lib/samagotchi/web/session_hub.rb +0 -4
  45. data/lib/samagotchi/web/session_summary.rb +6 -6
  46. data/lib/samagotchi/worker.rb +1 -1
  47. metadata +8 -1
@@ -229,7 +229,14 @@ module Samagotchi
229
229
  # Returns { host_name => { host:, port:, transport:, models: [LLM::ModelInfo], error: nil|String } }
230
230
  # Also populates the model index. Unless forced, a host's list is reused
231
231
  # for its TTL (60s; 10 minutes for a remote host).
232
- def list_all_models(force: true)
232
+ #
233
+ # With +wait+ (seconds), answers by one shared deadline: a host still
234
+ # listing then gets the error "no answer in S s" and its thread is left
235
+ # running (only for a short-lived process: `chi models`). Such a partial
236
+ # listing is not stored as the cache or the model index, so a later
237
+ # resolve doesn't miss the slow host's ids; finished hosts' lists are
238
+ # kept for their TTL as usual.
239
+ def list_all_models(force: true, wait: nil)
233
240
  results = {}
234
241
  results_mutex = Mutex.new
235
242
  threads = @entries.map do |name, entry|
@@ -247,7 +254,24 @@ module Samagotchi
247
254
  results_mutex.synchronize { results[name] = data }
248
255
  end
249
256
  end
250
- threads.each { |thread| thread.join if thread.is_a?(Thread) }
257
+ if wait
258
+ deadline = @clock.call + wait
259
+ threads.each { |thread| thread.join([deadline - @clock.call, 0].max) if thread.is_a?(Thread) }
260
+ partial = false
261
+ # a new hash: the threads keep writing into +results+ (their closure's)
262
+ snapshot = results_mutex.synchronize do
263
+ @entries.each_with_object({}) do |(name, entry), acc|
264
+ acc[name] = results.fetch(name) do
265
+ partial = true
266
+ { host: entry.host, port: entry.port, transport: entry.transport, models: [],
267
+ error: "no answer in #{wait == wait.to_i ? wait.to_i : wait} s" }
268
+ end
269
+ end
270
+ end
271
+ return snapshot if partial
272
+ else
273
+ threads.each { |thread| thread.join if thread.is_a?(Thread) }
274
+ end
251
275
 
252
276
  # Model index: model_id downcased -> the hosts listing it, in hosts:
253
277
  # order (not the order the threads answered in).
@@ -1,64 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
- require_relative "reminder_store"
3
+ require_relative "reminder_queue"
6
4
 
7
5
  module Samagotchi
8
6
  # Idle job for periodic reminders — polled by the shared IdleScheduler
9
7
  # (one background thread for the whole idle layer).
10
8
  #
11
9
  # Two delivery paths cooperate:
12
- # 1. Pull-based: Engine#collect_due_reminders
13
- # reads ReminderStore#due_reminders at turn start → injects
14
- # [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
10
+ # 1. Every turn injects what is due (ReminderQueue#inject!, from
11
+ # Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
15
12
  # 2. Synthetic turns: when this job's tick finds due reminders while
16
- # idle, it latches @due_reminder_name and fires @auto_turn_callback
17
- # (the TUI/SessionManager queue a synthetic turn from the latch).
13
+ # idle, it fires @auto_turn_callback, which queues their names
14
+ # (Engine#note_due_reminders) for the worker's or the REPL's
15
+ # reminder turn. While names are queued the tick waits; whoever
16
+ # clears them (a turn's injection, the reminder turn's consumer)
17
+ # re-arms it. The queue is the only "already due" state.
18
18
  class IdleReminders
19
19
  DEFAULT_MIN_INACTIVITY_SECONDS = 60.0 # Minimum idle time before checking for reminders
20
20
 
21
21
  attr_reader :inactivity
22
22
 
23
23
  def initialize(engine:, inactivity: DEFAULT_MIN_INACTIVITY_SECONDS,
24
- reminder_store: nil,
24
+ queue: nil,
25
25
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
26
26
  callback: nil)
27
27
  raise ArgumentError, "IdleReminders requires an engine" unless engine
28
28
 
29
29
  @engine = engine
30
30
  @inactivity = inactivity
31
- @reminder_store = reminder_store || engine.instance_variable_get(:@reminder_store)
31
+ @queue = queue
32
32
  @clock = clock
33
33
  @auto_turn_callback = callback
34
-
35
- @mutex = Monitor.new
36
- @due_reminder_name = nil
37
- end
38
-
39
- # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
40
- # Thread-safe. Returns all due reminders, not just one.
41
- # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
42
- def due_reminders
43
- @reminder_store&.due_reminders || []
44
- end
45
- # Get the names of all due reminders. Called by the background thread to
46
- # determine which reminders need a synthetic turn.
47
- # @return [Array<String>] reminder names that are due
48
- def due_reminder_names
49
- due_reminders.map { |r| r[:name] }
50
- end
51
-
52
- # Clear the pending due reminder (after it has been delivered).
53
- # Called by Engine after injecting the reminder into the system prompt.
54
- def clear_due
55
- @mutex.synchronize { @due_reminder_name = nil }
56
34
  end
57
35
 
58
36
  # One detector step, called by the shared IdleScheduler. Public so specs
59
37
  # can drive it deterministically.
60
38
  def tick
61
- return if @due_reminder_name # already due, waiting for engine to deliver
39
+ return if @queue&.pending? # already queued, waiting for a turn to deliver it
62
40
  return unless should_check?
63
41
 
64
42
  check_due_reminders
@@ -78,14 +56,13 @@ module Samagotchi
78
56
  end
79
57
 
80
58
  def check_due_reminders
81
- return unless @reminder_store
82
- due_names = @reminder_store.due_reminders.map { |r| r[:name] }
59
+ return unless @queue
60
+ due_names = @queue.due.map { |r| r[:name] }
83
61
  return if due_names.empty?
84
62
  # Signal the engine to create a synthetic turn via callback.
85
63
  # The callback is responsible for triggering a turn (e.g. SessionManager
86
64
  # writes a file, TerminalUI queues input). The engine's run_turn or
87
65
  # REPL injection point then picks up and delivers the reminders.
88
- @mutex.synchronize { @due_reminder_name = due_names.first }
89
66
  if @auto_turn_callback
90
67
  @auto_turn_callback.call(due_names)
91
68
  end
@@ -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