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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- 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
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
352
|
-
retry_empty =
|
|
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
|
|
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
|
-
@
|
|
397
|
-
|
|
398
|
-
|
|
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 @
|
|
378
|
+
if @empty_retry.left?
|
|
414
379
|
return :retry if inject_pending_input(iteration)
|
|
415
380
|
|
|
416
|
-
@
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
#
|
|
521
|
-
# (
|
|
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
|
|
518
|
+
return unless usage.source == :server
|
|
524
519
|
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
547
|
+
@tool_activity.concat(ToolResponse.activities(runs))
|
|
561
548
|
end
|
|
562
549
|
|
|
563
|
-
# Queued
|
|
564
|
-
#
|
|
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
|
-
|
|
571
|
-
|
|
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: "",
|
|
591
|
-
cancellation_reason: reason, tool_activity: @tool_activity,
|
|
592
|
-
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,
|
|
597
|
-
tool_activity: @tool_activity,
|
|
598
|
-
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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, :
|
|
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:,
|
|
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:
|
|
48
|
-
#
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
22
|
-
kernel_result = @kernel.run(
|
|
20
|
+
@kernel.run(
|
|
23
21
|
messages,
|
|
24
22
|
max_iterations: max_iterations,
|
|
25
|
-
on_stream_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.
|
|
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
|
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -4,8 +4,8 @@ require_relative "../token_usage"
|
|
|
4
4
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module LLM
|
|
7
|
-
# A
|
|
8
|
-
#
|
|
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.
|
|
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,
|