samagotchi 0.10.1 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
@@ -9,13 +9,16 @@ require_relative "openai_chat"
9
9
  require_relative "native_tool_normalizer"
10
10
  require_relative "../kernel_loop"
11
11
  require_relative "../answer_display"
12
+ require_relative "../context_status"
12
13
  require_relative "../context_window"
13
14
  require_relative "../context_note"
14
15
  require_relative "../tool_runner"
16
+ require_relative "../tool_response"
15
17
  require_relative "../tool_declarations"
16
18
  require_relative "../vision_context"
17
19
  require_relative "../log"
18
20
  require_relative "../empty_answer_retry"
21
+ require_relative "../steer"
19
22
  require_relative "../turn_note"
20
23
  require_relative "../thinking"
21
24
 
@@ -74,10 +77,8 @@ module Samagotchi
74
77
  end
75
78
  end
76
79
 
77
- # The kernel's tools; the built-ins for a kernel without a registry.
78
- def tools
79
- @kernel.respond_to?(:tools) ? @kernel.tools : Tools::Builtins.default
80
- end
80
+ # The kernel's tools.
81
+ def tools = @kernel.tools
81
82
 
82
83
  # engine-format conversation -> OpenAI wire messages. Model turns are
83
84
  # thought-stripped and carry their tool_calls; tool responses go as tool
@@ -130,20 +131,15 @@ module Samagotchi
130
131
 
131
132
  TOOL_IMAGES_TEXT = "[images from tool results]"
132
133
 
133
- # The turn's VisionContext (the Engine sets it on the kernel), or nil.
134
- def vision
135
- @kernel.respond_to?(:vision) ? @kernel.vision : nil
136
- end
134
+ # The turn's VisionContext (the Engine sets the kernel's
135
+ # turn_settings), or nil.
136
+ def vision = @kernel.turn_settings.vision
137
137
 
138
- # The turn's request parameters (the Engine sets them on the kernel).
139
- def sampling
140
- @kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
141
- end
138
+ # The turn's request parameters (the kernel's turn_settings).
139
+ def sampling = @kernel.turn_settings.sampling || {}
142
140
 
143
- # The turn's thinking level (the Engine sets it on the kernel).
144
- def thinking
145
- (@kernel.respond_to?(:thinking) && @kernel.thinking) || Thinking::DEFAULT
146
- end
141
+ # The turn's thinking level (the kernel's turn_settings).
142
+ def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
147
143
 
148
144
  # The request fields the thinking level adds (Thinking.chat_fields);
149
145
  # none for a model whose host refused them (#thinking_refused!).
@@ -168,9 +164,7 @@ module Samagotchi
168
164
  deep_compact(options)
169
165
  end
170
166
 
171
- def strip_model_thought(text)
172
- @kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
173
- end
167
+ def strip_model_thought(text) = @kernel.strip_model_thought(text)
174
168
 
175
169
  def tool_runner
176
170
  @tool_runner ||= ToolRunner.new(@kernel)
@@ -181,25 +175,15 @@ module Samagotchi
181
175
  # llama.cpp answers /props), then the host's model list, config, env
182
176
  # and the default. A remote provider has no /props to probe.
183
177
  def context_window(model)
184
- remote = @adapter.respond_to?(:remote?) && @adapter.remote?
185
- client = @kernel.client if !remote && @kernel.respond_to?(:client)
178
+ client = @adapter.remote? ? nil : @kernel.client
186
179
  ContextWindow.resolve(client: client, model: model, adapter: @adapter)
187
180
  rescue StandardError
188
181
  nil
189
182
  end
190
183
 
191
- # The status line's context value for +used_tokens+ of +window_tokens+
192
- # (KernelLoop#context_display), or nil.
193
- def context_display(used_tokens:, window_tokens:)
194
- return nil unless @kernel.respond_to?(:context_display)
195
-
196
- @kernel.context_display(used_tokens: used_tokens, window_tokens: window_tokens)
197
- end
198
-
199
184
  # A hook failing must not break the turn (as in ToolRunner).
200
185
  def fire_hook(name, event)
201
- hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
202
- hooks&.fire(name, event)
186
+ @kernel.hooks&.fire(name, event)
203
187
  rescue StandardError
204
188
  nil
205
189
  end
@@ -218,9 +202,7 @@ module Samagotchi
218
202
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
219
203
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
220
204
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
221
- message[:tool_params] = entry[:tool_params] if entry[:tool_params]
222
- message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
223
- message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
205
+ ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
224
206
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
225
207
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
226
208
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
@@ -304,7 +286,7 @@ module Samagotchi
304
286
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
305
287
  end
306
288
 
307
- # One turn's state: the conversation, the stream sink, usage and tool
289
+ # One turn's state: the conversation, the stream sink and tool
308
290
  # activity.
309
291
  class Run
310
292
  def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
@@ -314,14 +296,9 @@ module Samagotchi
314
296
  @cancel_controller = cancel_controller
315
297
  @model_name = model_name
316
298
  @pending_input = pending_input
317
- @usage = UsageCollector.new
318
299
  @tool_activity = []
319
- # What an estimate counts as the prompt when the server reports no
320
- # usage: the text, and each image's estimate (not its base64).
321
- @prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
322
- @image_tokens = ImagePlan.estimated_tokens(conversation)
323
- @empty_retries = 0
324
- @empty_retry_limit = EmptyAnswerRetry.limit
300
+ @empty_retry = EmptyAnswerRetry.new
301
+ @context = ContextStatus.new(conversation: conversation)
325
302
  end
326
303
 
327
304
  EMPTY_ANSWER = "(the model returned an empty answer)"
@@ -346,10 +323,15 @@ module Samagotchi
346
323
 
347
324
  last_text = @loop.strip_model_thought(response.text)
348
325
  if response.tool_calls.empty?
326
+ # Only whitespace is no answer either (as in the native loop).
327
+ empty = last_text.strip.empty?
349
328
  # Kept before a merge too: the model answers the merged line
350
329
  # knowing what it just said.
351
- @conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
352
- retry_empty = last_text.empty? && retry_empty_answer?(iteration, response)
330
+ @conversation << with_thinking({ role: "model", content: last_text }, response) unless empty
331
+ retry_empty = empty &&
332
+ @empty_retry.retry_empty?(iteration: iteration, cancelled: @cancel_controller&.cancelled?,
333
+ finish_reason: response.finish_reason,
334
+ used_tokens: response.usage&.total_tokens, window_tokens: @window&.tokens)
353
335
  # An empty answer that will be retried is no answer site: a
354
336
  # plugin's steer joins the retry instead of being dropped, and
355
337
  # queued input (a user's line, a steer) goes in place of the nudge.
@@ -358,7 +340,7 @@ module Samagotchi
358
340
 
359
341
  # Shown, not saved: an empty answer (content "" + stop, seen from
360
342
  # a remote host) would otherwise end the turn with nothing.
361
- last_text = EMPTY_ANSWER if last_text.empty?
343
+ last_text = EMPTY_ANSWER if empty
362
344
  exhausted = false
363
345
  break
364
346
  end
@@ -376,29 +358,12 @@ module Samagotchi
376
358
 
377
359
  private
378
360
 
379
- # A retry is left, and the answer wasn't cut short by a full context
380
- # (a length stop while thinking is retried: a thinking loop cut by
381
- # the provider's output cap, not a full window).
382
- def retry_empty_answer?(iteration, response)
383
- return false if @empty_retries >= @empty_retry_limit || @cancel_controller&.cancelled?
384
-
385
- if response.finish_reason.to_s == "length" &&
386
- EmptyAnswerRetry.context_full?(response.usage&.total_tokens, @window&.tokens)
387
- Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
388
- return false
389
- end
390
- true
391
- end
392
-
393
361
  # The hidden nudge before the next generation (EmptyAnswerRetry),
394
362
  # which runs at the retry temperature. Returns true.
395
363
  def nudge_empty_answer(iteration, response)
396
- @empty_retries += 1
397
- @retry_generation = true
398
- emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
399
- finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
400
- @conversation << TurnNote.empty_retry
401
- true
364
+ @empty_retry.nudge!(@conversation, TurnNote.empty_retry,
365
+ emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
366
+ thinking_chars: response.reasoning.to_s.length)
402
367
  end
403
368
 
404
369
  # A generation a plugin cut (stop_generation) is an empty answer made
@@ -410,19 +375,15 @@ module Samagotchi
410
375
  def after_cut(iteration, response)
411
376
  return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
412
377
 
413
- if @empty_retries < @empty_retry_limit
378
+ if @empty_retry.left?
414
379
  return :retry if inject_pending_input(iteration)
415
380
 
416
- @empty_retries += 1
417
- @retry_generation = true
418
- emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
419
- finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length,
420
- stopped_by: response.cut[:by])
421
- @conversation << TurnNote.cut_retry(response.cut[:by], response.cut[:reason])
381
+ @empty_retry.nudge!(@conversation, TurnNote.cut_retry(response.cut[:by], response.cut[:reason]),
382
+ emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
383
+ thinking_chars: response.reasoning.to_s.length, stopped_by: response.cut[:by])
422
384
  return :retry
423
385
  end
424
- index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
425
- @conversation.delete_at(index) if index
386
+ @empty_retry.drop_nudge!(@conversation)
426
387
  @cancel_controller.cancel!(:hook, response.cut)
427
388
  canceled(iteration, :hook)
428
389
  end
@@ -440,8 +401,8 @@ module Samagotchi
440
401
  # text] when it was cancelled.
441
402
  def generate(iteration)
442
403
  window = @window = @loop.context_window(@model_name)
443
- retry_generation = @retry_generation
444
- @retry_generation = false
404
+ observe_context(iteration, window)
405
+ retry_generation = @empty_retry.take_sampling!
445
406
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
446
407
  context_window_source: window&.source)
447
408
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
@@ -517,13 +478,49 @@ module Samagotchi
517
478
  !error.context_overflow? && !@loop.thinking_fields(@model_name).empty?
518
479
  end
519
480
 
520
- # The status line's value from the server's counts for this request
521
- # (prompt + answer); without them the last value stays.
481
+ # Before a request, as the native loop: estimate how full the window
482
+ # is (the server's last count plus what the turn appended since),
483
+ # emit :context_status on a bucket change, and put the model's own
484
+ # line on the tail on a rise that asks it for a change. The estimate
485
+ # counts the conversation's text and tool calls, not the tool
486
+ # schemas (the server's count does).
487
+ def observe_context(iteration, window)
488
+ return unless window
489
+
490
+ @request_image_tokens = ImagePlan.estimated_tokens(@conversation)
491
+ event = @context.observe(prompt_chars, iteration_index: iteration - 1, window: window,
492
+ image_tokens: @request_image_tokens)
493
+ emit(type: :context_status, iteration: iteration, **event) if event
494
+ if (line = @context.take_guidance)
495
+ @conversation << line
496
+ end
497
+ @request_chars = prompt_chars
498
+ end
499
+
500
+ # The conversation's text as the request carries it: contents (a
501
+ # parts list's text parts) and the tool calls' names and arguments.
502
+ def prompt_chars
503
+ @conversation.sum do |entry|
504
+ content = entry[:content]
505
+ chars = if content.is_a?(Array)
506
+ content.sum { |part| part.is_a?(Hash) ? (part[:text] || part["text"]).to_s.length : 0 }
507
+ else
508
+ content.to_s.length
509
+ end
510
+ chars + Array(entry[:tool_calls]).sum { |call| call[:name].to_s.length + call[:arguments].to_json.length }
511
+ end
512
+ end
513
+
514
+ # The server's counts for this request (prompt + answer): the status
515
+ # line's value, and where the next estimate starts. Without them the
516
+ # estimate stays.
522
517
  def record_context_status(usage, window)
523
- return unless usage.source == :server && window
518
+ return unless usage.source == :server
524
519
 
525
- display = @loop.context_display(used_tokens: usage.total_tokens, window_tokens: window.tokens)
526
- @context_status = display if display
520
+ counts = @context.capture({ "usage" => { "prompt_tokens" => usage.prompt_tokens,
521
+ "completion_tokens" => usage.completion_tokens } })
522
+ @context.generation_done(counts, prompt_chars: @request_chars.to_i, image_tokens: @request_image_tokens.to_i,
523
+ window: window)
527
524
  end
528
525
 
529
526
  # The model's answer at debug level, as the native loop dumps its
@@ -539,44 +536,22 @@ module Samagotchi
539
536
  served_model: response.model, tool_calls: calls.empty? ? nil : calls.join(","))
540
537
  end
541
538
 
539
+ # Each call's result goes on the conversation as it finishes, paired
540
+ # with its call (ToolResponse.single: the capped output).
542
541
  def dispatch(tool_calls, iteration, cap)
543
- emit(type: :tool_dispatch_started, iteration: iteration, call_count: tool_calls.length)
544
- tool_calls.each_with_index do |tool_call, index|
545
- call = NativeToolNormalizer.normalize(tool_call)
546
- run = @loop.tool_runner.run(call, iteration: iteration, call_index: index + 1, call_count: tool_calls.length,
547
- on_stream_event: @on_stream_event, max_tool_output_chars: cap)
548
- @tool_activity << run[:activity] if run[:activity]
549
- # The chat loop feeds the model the capped output (native feeds
550
- # the full one; D-P2-6).
551
- entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call.id }
552
- entry[:images] = run[:images] if run[:images]&.any?
553
- # A plugin tool's params line, for the web's reload; never sent.
554
- entry[:tool_params] = run[:shown_params] if run[:shown_params]
555
- entry[:tool_labels] = run[:shown_label] if run[:shown_label]
556
- # What an edit/write changed, for the web's reload; never sent.
557
- entry[:tool_diffs] = run[:diff] if run[:diff]
558
- @conversation << entry
542
+ calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
543
+ runs = ToolResponse.run_batch(@loop.tool_runner, calls, iteration: iteration, emit: method(:emit),
544
+ on_stream_event: @on_stream_event, cap: cap) do |run, index|
545
+ @conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id)
559
546
  end
560
- emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
547
+ @tool_activity.concat(ToolResponse.activities(runs))
561
548
  end
562
549
 
563
- # Queued steering joins the conversation as one user message, a
564
- # plugin's steers each as its own after it (Steer). Returns true when
565
- # there was any. After a cancel it stays queued, so it runs as the
566
- # next turn instead of dying with this one. +answer+ is the answer the
567
- # merge follows, for the UIs; given, the drain is told it is the
568
- # after-answer site (plugin steers are dropped there).
550
+ # Queued input at an iteration boundary (Steer.inject!). Returns true
551
+ # when there was any.
569
552
  def inject_pending_input(iteration, answer: nil)
570
- return false unless @pending_input
571
- return false if @cancel_controller&.cancelled?
572
-
573
- merge = Steer.merge(Steer.drain(@pending_input, at_answer: !answer.nil?))
574
- return false if merge.empty?
575
-
576
- @conversation.concat(merge.messages)
577
- emit(type: :pending_input_merged, iteration: iteration, **merge.event_fields,
578
- answer: answer.to_s.empty? ? nil : answer)
579
- true
553
+ Steer.inject!(@conversation, @pending_input, iteration: iteration, emit: method(:emit),
554
+ cancel_controller: @cancel_controller, answer: answer)
580
555
  end
581
556
 
582
557
  # The text streamed before the cancel stays, marked [interrupted],
@@ -587,23 +562,18 @@ module Samagotchi
587
562
  @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
588
563
  conversation = @loop.plain(@conversation)
589
564
  conversation.last[:interrupted] = true unless visible.empty?
590
- ModelResult.new(text: "", provider: :chat, conversation: conversation, canceled: true,
591
- cancellation_reason: reason, tool_activity: @tool_activity, usage: usage,
592
- context_status: @context_status)
565
+ ModelResult.new(text: "", conversation: conversation, canceled: true,
566
+ cancellation_reason: reason, tool_activity: @tool_activity,
567
+ context_status: @context.display)
593
568
  end
594
569
 
595
570
  def result(text, exhausted:)
596
- ModelResult.new(text: text, provider: :chat, conversation: @loop.plain(@conversation), exhausted: exhausted,
597
- tool_activity: @tool_activity, usage: usage, empty_answer: text == EMPTY_ANSWER,
598
- context_status: @context_status)
599
- end
600
-
601
- def usage
602
- @usage.usage(prompt_text: @prompt_text, extra_prompt_tokens: @image_tokens)
571
+ ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
572
+ tool_activity: @tool_activity, empty_answer: text == EMPTY_ANSWER,
573
+ context_status: @context.display)
603
574
  end
604
575
 
605
576
  def emit(event)
606
- @usage.observe(event)
607
577
  @on_stream_event&.call(event)
608
578
  rescue StandardError
609
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.
@@ -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
@@ -4,8 +4,8 @@ require_relative "../token_usage"
4
4
 
5
5
  module Samagotchi
6
6
  module LLM
7
- # A turn's token counts, never nil: what the server reported (:server),
8
- # else a chars/4 estimate (:estimate), else zeros (:none).
7
+ # A response's token counts, never nil: what the server reported
8
+ # (:server), else zeros (:none).
9
9
  Usage = Data.define(:prompt_tokens, :completion_tokens, :source) do
10
10
  # @return [Usage, nil] nil when the payload carries no counts
11
11
  def self.from_payload(payload)
@@ -16,64 +16,9 @@ module Samagotchi
16
16
  source: :server)
17
17
  end
18
18
 
19
- # +extra_prompt_tokens+: what the prompt holds besides text (images).
20
- def self.estimate(prompt_text:, completion_text:, extra_prompt_tokens: 0)
21
- new(prompt_tokens: TokenUsage.estimate(prompt_text.to_s) + extra_prompt_tokens.to_i,
22
- completion_tokens: TokenUsage.estimate(completion_text.to_s),
23
- source: :estimate)
24
- end
25
-
26
19
  def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
27
20
 
28
21
  def total_tokens = prompt_tokens + completion_tokens
29
22
  end
30
-
31
- # Builds a turn's Usage from its stream events, the way SessionMetrics
32
- # counts them: server counts are cumulative per request, so each
33
- # generation keeps its highest; the prompt grows across a turn's
34
- # generations, so the last generation's prompt counts, and completions
35
- # add up. Without server counts, the streamed text is estimated.
36
- class UsageCollector
37
- def initialize
38
- @generations = []
39
- end
40
-
41
- def observe(event)
42
- case event[:type]
43
- when :generation_started
44
- @generations << { prompt: 0, completion: 0, server: false, text: +"" }
45
- when :generation_chunk
46
- @generations << { prompt: 0, completion: 0, server: false, text: +"" } if @generations.empty?
47
- record(@generations.last, event)
48
- end
49
- end
50
-
51
- # @param prompt_text [String, nil] what an estimate counts as the prompt
52
- # @param extra_prompt_tokens [Integer] estimated non-text prompt tokens
53
- def usage(prompt_text: nil, extra_prompt_tokens: 0)
54
- server = @generations.select { |generation| generation[:server] }
55
- unless server.empty?
56
- return Usage.new(prompt_tokens: server.last[:prompt], completion_tokens: server.sum { |g| g[:completion] },
57
- source: :server)
58
- end
59
-
60
- text = @generations.sum("") { |generation| generation[:text] }
61
- return Usage.none if text.empty?
62
-
63
- Usage.estimate(prompt_text: prompt_text, completion_text: text, extra_prompt_tokens: extra_prompt_tokens)
64
- end
65
-
66
- private
67
-
68
- def record(generation, event)
69
- counts = Usage.from_payload(event[:payload])
70
- if counts
71
- generation[:server] = true
72
- generation[:prompt] = [generation[:prompt], counts.prompt_tokens].max
73
- generation[:completion] = [generation[:completion], counts.completion_tokens].max
74
- end
75
- generation[:text] << event[:content].to_s
76
- end
77
- end
78
23
  end
79
24
  end
@@ -365,7 +365,7 @@ module Samagotchi
365
365
  version: manifest.version,
366
366
  source_path: @source,
367
367
  hooks: hooks_for_provenance,
368
- trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
368
+ trust_level: manifest.trust_level,
369
369
  source_commit: source_commit,
370
370
  hooks_files: hooks_files_for_provenance,
371
371
  guardrails_files: guardrail_files_for_provenance,