samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -10,13 +10,17 @@ require_relative "prompt_literal_guard"
|
|
|
10
10
|
require_relative "client"
|
|
11
11
|
require_relative "llm/errors"
|
|
12
12
|
require_relative "log"
|
|
13
|
+
require_relative "empty_answer_retry"
|
|
14
|
+
require_relative "thinking"
|
|
13
15
|
require_relative "hooks"
|
|
14
16
|
require_relative "pending_input_queue"
|
|
17
|
+
require_relative "steer"
|
|
15
18
|
require_relative "thought_stream_splitter"
|
|
16
19
|
require_relative "tools/builtins"
|
|
17
20
|
require_relative "muted_memories"
|
|
18
21
|
require_relative "tool_activity"
|
|
19
22
|
require_relative "tool_runner"
|
|
23
|
+
require_relative "answer_display"
|
|
20
24
|
|
|
21
25
|
module Samagotchi
|
|
22
26
|
# The KernelLoop drives the model ↔ tool interaction cycle.
|
|
@@ -173,6 +177,12 @@ module Samagotchi
|
|
|
173
177
|
# The turn's VisionContext (images: capability, files, limits), set by
|
|
174
178
|
# the Engine per turn; nil sends no images (placeholders instead).
|
|
175
179
|
attr_accessor :vision
|
|
180
|
+
# The turn's request parameters (SamplingSettings.for), set by the Engine
|
|
181
|
+
# per turn; empty or nil sends none.
|
|
182
|
+
attr_accessor :sampling
|
|
183
|
+
# The turn's thinking level (Thinking.resolve), set by the Engine per
|
|
184
|
+
# turn; its own accessor, since the native path sends @sampling as is.
|
|
185
|
+
attr_accessor :thinking
|
|
176
186
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
177
187
|
# list_sessions and send_note speak for; nil outside a session.
|
|
178
188
|
attr_accessor :peers
|
|
@@ -206,15 +216,22 @@ module Samagotchi
|
|
|
206
216
|
tool_activity = []
|
|
207
217
|
qwen_recovery_attempts = 0
|
|
208
218
|
qwen_partial_tool_call = nil
|
|
219
|
+
empty_retries = 0
|
|
220
|
+
empty_retry_limit = EmptyAnswerRetry.limit
|
|
221
|
+
@retry_generation = false
|
|
209
222
|
context_status = nil
|
|
210
223
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
224
|
+
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
225
|
+
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
226
|
+
# prompt starts with what the server already has cached.
|
|
227
|
+
prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
|
|
211
228
|
partial_assistant_buffer = +""
|
|
212
229
|
|
|
213
230
|
effective_max_iterations = @no_interrupt ? 1000 : max_iterations
|
|
214
231
|
effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
|
|
215
232
|
effective_max_iterations.times do |iteration_index|
|
|
216
233
|
inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
|
|
217
|
-
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
|
|
234
|
+
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
218
235
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
|
|
219
236
|
context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
|
|
220
237
|
context_status = emit_context_status_event(on_stream_event, prompt, iteration_index: iteration_index, state: context_state, window: context_window,
|
|
@@ -223,7 +240,7 @@ module Samagotchi
|
|
|
223
240
|
# The model's own copy, on the tail (the prompt cache keeps its
|
|
224
241
|
# prefix), then the prompt again with it.
|
|
225
242
|
conversation << line
|
|
226
|
-
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
|
|
243
|
+
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
227
244
|
end
|
|
228
245
|
emit_stream_event(
|
|
229
246
|
on_stream_event,
|
|
@@ -299,9 +316,9 @@ module Samagotchi
|
|
|
299
316
|
# Fire :after_generation hook (after LLM returns, before tool parse),
|
|
300
317
|
# with a read-only copy of the conversation as sent.
|
|
301
318
|
after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
|
|
302
|
-
messages: conversation.map(&:dup).freeze }
|
|
319
|
+
messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
|
|
303
320
|
fire_hook(:after_generation, after_gen_event) if @hooks
|
|
304
|
-
conversation << { role: "model", content: response }
|
|
321
|
+
conversation << { role: "model", content: prefill + response.to_s }
|
|
305
322
|
|
|
306
323
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
307
324
|
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
@@ -322,7 +339,25 @@ module Samagotchi
|
|
|
322
339
|
|
|
323
340
|
pending_tool_calls = false
|
|
324
341
|
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
325
|
-
|
|
342
|
+
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
343
|
+
retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
|
|
344
|
+
# The empty generation goes (its thinking would be sent again and
|
|
345
|
+
# prime the same loop); an empty answer that will be retried is no
|
|
346
|
+
# answer site, so a plugin's steer joins the retry.
|
|
347
|
+
conversation.pop if retry_empty
|
|
348
|
+
unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
|
|
349
|
+
answer: retry_empty ? nil : answer)
|
|
350
|
+
if retry_empty
|
|
351
|
+
empty_retries += 1
|
|
352
|
+
@retry_generation = true
|
|
353
|
+
emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
|
|
354
|
+
attempt: empty_retries, of: empty_retry_limit,
|
|
355
|
+
thinking_chars: thinking_chars(response.to_s, streamed_thinking))
|
|
356
|
+
conversation << TurnNote.empty_retry
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
# The Engine's TurnNote.empty says it all: the spent nudge goes.
|
|
360
|
+
drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
|
|
326
361
|
break
|
|
327
362
|
end
|
|
328
363
|
# Queued steering keeps the turn going: loop again so the model
|
|
@@ -338,6 +373,7 @@ module Samagotchi
|
|
|
338
373
|
image_counts = []
|
|
339
374
|
shown_params = []
|
|
340
375
|
shown_labels = []
|
|
376
|
+
diffs = []
|
|
341
377
|
results = calls.map.with_index do |call, call_index|
|
|
342
378
|
run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
|
|
343
379
|
call_count: calls.length, on_stream_event: on_stream_event,
|
|
@@ -347,6 +383,7 @@ module Samagotchi
|
|
|
347
383
|
image_counts << Array(run[:images]).size
|
|
348
384
|
shown_params << run[:shown_params]
|
|
349
385
|
shown_labels << run[:shown_label]
|
|
386
|
+
diffs << run[:diff]
|
|
350
387
|
run[:output]
|
|
351
388
|
end.join("\n\n---\n\n")
|
|
352
389
|
emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
|
|
@@ -360,6 +397,9 @@ module Samagotchi
|
|
|
360
397
|
# a built-in), for the web's reload; the prompt never reads it.
|
|
361
398
|
tool_response[:tool_params] = shown_params if shown_params.any?
|
|
362
399
|
tool_response[:tool_labels] = shown_labels if shown_labels.any?
|
|
400
|
+
# What each edit/write changed (nil for other calls), in call order,
|
|
401
|
+
# for the web's reload; the prompt never reads it.
|
|
402
|
+
tool_response[:tool_diffs] = diffs if diffs.any?
|
|
363
403
|
conversation << tool_response
|
|
364
404
|
pending_tool_calls = true
|
|
365
405
|
rescue Client::RequestCancelled => e
|
|
@@ -437,6 +477,8 @@ module Samagotchi
|
|
|
437
477
|
# append them as ONE merged user message at the conversation tail and emit
|
|
438
478
|
# :pending_input_merged. Tail-append only: head mutation would invalidate
|
|
439
479
|
# the server-side prefix KV cache. Returns true when a message was injected.
|
|
480
|
+
# A plugin's steers (Steer) follow the user's message, each its own
|
|
481
|
+
# message; after an answer the Engine's drain has already dropped them.
|
|
440
482
|
# After a cancel the input stays queued: it runs as the next turn instead
|
|
441
483
|
# of dying with this one. +answer+ (a proc, called only on a merge) is the
|
|
442
484
|
# answer the merge follows: the UIs show it, the turn summary has only the
|
|
@@ -445,24 +487,16 @@ module Samagotchi
|
|
|
445
487
|
return false unless pending_input
|
|
446
488
|
return false if cancel_controller&.cancelled?
|
|
447
489
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
rescue StandardError
|
|
451
|
-
nil
|
|
452
|
-
end
|
|
453
|
-
return false if lines.nil? || lines.empty?
|
|
454
|
-
|
|
455
|
-
content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
|
|
456
|
-
return false if content.empty?
|
|
490
|
+
merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
|
|
491
|
+
return false if merge.empty?
|
|
457
492
|
|
|
458
493
|
answer = answer.call.to_s if answer
|
|
459
|
-
conversation
|
|
494
|
+
conversation.concat(merge.messages)
|
|
460
495
|
emit_stream_event(
|
|
461
496
|
on_stream_event,
|
|
462
497
|
type: :pending_input_merged,
|
|
463
498
|
iteration: iteration,
|
|
464
|
-
|
|
465
|
-
content: content,
|
|
499
|
+
**merge.event_fields,
|
|
466
500
|
answer: answer.to_s.strip.empty? ? nil : answer
|
|
467
501
|
)
|
|
468
502
|
true
|
|
@@ -524,6 +558,9 @@ module Samagotchi
|
|
|
524
558
|
kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
|
|
525
559
|
resolved_model_name = completion_model_name(model_name)
|
|
526
560
|
kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
|
|
561
|
+
sampling = @retry_generation ? EmptyAnswerRetry.sampling(@sampling) : @sampling
|
|
562
|
+
@retry_generation = false
|
|
563
|
+
kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
|
|
527
564
|
kwargs
|
|
528
565
|
end
|
|
529
566
|
|
|
@@ -804,14 +841,6 @@ module Samagotchi
|
|
|
804
841
|
end
|
|
805
842
|
end
|
|
806
843
|
|
|
807
|
-
# Parse tool calls from raw model output using the active profile's
|
|
808
|
-
# ToolCallParser strategy (Gemma 4 or Qwen 3.6).
|
|
809
|
-
# Thought content is intentionally left intact while a tool-call turn is in
|
|
810
|
-
# progress to preserve same-turn reasoning context between tool calls.
|
|
811
|
-
def parse_tool_calls(text)
|
|
812
|
-
parser.parse(text.to_s)
|
|
813
|
-
end
|
|
814
|
-
|
|
815
844
|
public
|
|
816
845
|
|
|
817
846
|
# Public wrapper so other loops (e.g. the chat loop) can strip
|
|
@@ -862,7 +891,9 @@ module Samagotchi
|
|
|
862
891
|
def sanitize_history(messages)
|
|
863
892
|
messages.map do |m|
|
|
864
893
|
if m[:role] == "model"
|
|
865
|
-
|
|
894
|
+
# `display` rides along so the stored conversation keeps it; the
|
|
895
|
+
# prompt formatter never reads it (AnswerDisplay).
|
|
896
|
+
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
|
|
866
897
|
else
|
|
867
898
|
m.dup
|
|
868
899
|
end
|
|
@@ -882,6 +913,12 @@ module Samagotchi
|
|
|
882
913
|
messages.map(&:dup)
|
|
883
914
|
end
|
|
884
915
|
|
|
916
|
+
def drop_last_empty_retry!(conversation)
|
|
917
|
+
nudge = TurnNote.empty_retry
|
|
918
|
+
index = conversation.rindex { |entry| entry[:kind] == nudge[:kind] && entry[:content] == nudge[:content] }
|
|
919
|
+
conversation.delete_at(index) if index
|
|
920
|
+
end
|
|
921
|
+
|
|
885
922
|
def last_model_content(conversation)
|
|
886
923
|
message = conversation.reverse.find { |entry| entry[:role] == "model" }
|
|
887
924
|
message ? message[:content].to_s : ""
|
|
@@ -942,76 +979,18 @@ module Samagotchi
|
|
|
942
979
|
}
|
|
943
980
|
end
|
|
944
981
|
|
|
982
|
+
# ask_user_question: validate the call, then hand the payload to the
|
|
983
|
+
# Engine's question flow (question_handler, which blocks until the user
|
|
984
|
+
# answers). Without one (headless), the payload as JSON so the model sees
|
|
985
|
+
# the options and can ask in plain text.
|
|
945
986
|
def handle_ask_user_question(call)
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
|
|
950
|
-
# Fallback for case where raw was String like '["a","b"]' but lenient returned [] due to edge parse, try raw string of params
|
|
951
|
-
if options.empty? && raw_opts.is_a?(String)
|
|
952
|
-
options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts.to_s)
|
|
953
|
-
end
|
|
954
|
-
header = call[:header].to_s.strip
|
|
955
|
-
header = nil if header.empty?
|
|
956
|
-
multi = call[:multi_select]
|
|
957
|
-
free = call[:allow_freeform]
|
|
958
|
-
# Normalize booleans from string forms (Gemma passes "true"/"false" as strings)
|
|
959
|
-
multi = normalize_ask_bool(multi)
|
|
960
|
-
free = normalize_ask_bool(free)
|
|
961
|
-
|
|
962
|
-
if question.empty?
|
|
963
|
-
return "Error: ask_user_question requires 'question'"
|
|
964
|
-
end
|
|
965
|
-
# Dumb-model tolerant: salvage single-option parse glitches, but still require at least 1
|
|
966
|
-
if options.size < 1
|
|
967
|
-
alt = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(call[:content].to_s) if call[:content]
|
|
968
|
-
options = alt unless alt.empty?
|
|
969
|
-
end
|
|
970
|
-
if options.empty?
|
|
971
|
-
return "Error: ask_user_question requires 2-8 options (got 0). Provide e.g. options=[\"Cats\",\"Dogs\"]"
|
|
972
|
-
end
|
|
973
|
-
if options.size == 1
|
|
974
|
-
# Allow single-option salvage for dumb models (will still render, user can answer or provide freeform)
|
|
975
|
-
elsif options.size < 2 || options.size > 8
|
|
976
|
-
return "Error: ask_user_question requires 2-8 options (got #{options.size}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
|
|
977
|
-
end
|
|
978
|
-
|
|
979
|
-
# If an Engine-level blocking handler is registered (TUI/Web), delegate
|
|
980
|
-
# there (Engine sets question_handler). Otherwise fall back to a
|
|
981
|
-
# non-blocking JSON preview so the model can still see a structured response.
|
|
982
|
-
handler = @question_handler
|
|
983
|
-
|
|
984
|
-
payload = {
|
|
985
|
-
question: question,
|
|
986
|
-
options: options,
|
|
987
|
-
header: header,
|
|
988
|
-
multi_select: !!multi,
|
|
989
|
-
allow_freeform: !!free
|
|
990
|
-
}.compact
|
|
991
|
-
|
|
992
|
-
if handler
|
|
993
|
-
begin
|
|
994
|
-
result = handler.call(payload)
|
|
995
|
-
return result.to_s
|
|
996
|
-
rescue => e
|
|
997
|
-
return "Error: ask_user_question handler failed: #{e.message}"
|
|
998
|
-
end
|
|
999
|
-
end
|
|
1000
|
-
|
|
1001
|
-
# Headless fallback: return JSON so model sees structured options and can
|
|
1002
|
-
# fallback to plain text qualification.
|
|
1003
|
-
JSON.pretty_generate(payload)
|
|
1004
|
-
end
|
|
1005
|
-
|
|
1006
|
-
def normalize_ask_bool(v)
|
|
1007
|
-
return nil if v.nil?
|
|
1008
|
-
return v if v == true || v == false
|
|
1009
|
-
|
|
1010
|
-
s = v.to_s.strip.downcase
|
|
1011
|
-
return true if %w[1 true yes on].include?(s)
|
|
1012
|
-
return false if %w[0 false no off].include?(s)
|
|
987
|
+
payload = Samagotchi::Tools::AskUserQuestion.validate(call)
|
|
988
|
+
return payload if payload.is_a?(String)
|
|
989
|
+
return JSON.pretty_generate(payload) unless @question_handler
|
|
1013
990
|
|
|
1014
|
-
|
|
991
|
+
@question_handler.call(payload).to_s
|
|
992
|
+
rescue => e
|
|
993
|
+
"Error: ask_user_question handler failed: #{e.message}"
|
|
1015
994
|
end
|
|
1016
995
|
end
|
|
1017
996
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "socket"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# Which chi versions the running processes run, for `chi update`: session
|
|
11
|
+
# workers (their bridge.json sidecar names the version since chi update exists; an
|
|
12
|
+
# older one names none) and a `chi web` on its port (/api/info). Read-only:
|
|
13
|
+
# a dead worker's sidecar is left for the next client to clean up.
|
|
14
|
+
module LiveVersions
|
|
15
|
+
PROBE_TIMEOUT = 0.2
|
|
16
|
+
WEB_TIMEOUT = 0.5
|
|
17
|
+
|
|
18
|
+
# version is nil for a sidecar written before sidecars carried one.
|
|
19
|
+
Worker = Struct.new(:session_id, :version, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @return [Array<Worker>] the workers whose Bridge answers, by session id
|
|
24
|
+
def workers(state_dir: Session.default_state_dir)
|
|
25
|
+
Dir[File.join(state_dir, "*", "bridge.json")].sort.filter_map do |sidecar|
|
|
26
|
+
data = JSON.parse(File.read(sidecar))
|
|
27
|
+
next unless data.is_a?(Hash) && listening?(data["port"].to_i)
|
|
28
|
+
|
|
29
|
+
Worker.new(session_id: File.basename(File.dirname(sidecar)), version: data["version"])
|
|
30
|
+
rescue JSON::ParserError, SystemCallError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The live workers on another version than +version+ (unknown counts).
|
|
36
|
+
def stale_workers(version = VERSION, state_dir: Session.default_state_dir)
|
|
37
|
+
workers(state_dir: state_dir).reject { |w| w.version == version }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The version a chi web on host:port runs, or nil when nothing (or not
|
|
41
|
+
# chi web) answers.
|
|
42
|
+
def web_version(host, port, timeout: WEB_TIMEOUT)
|
|
43
|
+
response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
|
|
44
|
+
info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
|
|
45
|
+
info.is_a?(Hash) && info["app"] == "chi-web" ? info["version"].to_s : nil
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def listening?(port, host: "127.0.0.1")
|
|
51
|
+
return false unless port.positive?
|
|
52
|
+
|
|
53
|
+
Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
|
|
54
|
+
true
|
|
55
|
+
rescue StandardError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module LLM
|
|
7
|
+
# A host's API key: the environment variable its api_key_env: names,
|
|
8
|
+
# sent as `Authorization: Bearer <key>` on every request LLM::HTTP makes
|
|
9
|
+
# for the host (llama.cpp started with --api-key, or a provider). The
|
|
10
|
+
# key itself never goes into a message.
|
|
11
|
+
ApiKey = Data.define(:env_name, :host, :env) do
|
|
12
|
+
# nil for a host without api_key_env: its requests carry no header.
|
|
13
|
+
def self.for(env_name, host:, env: ENV)
|
|
14
|
+
name = env_name.to_s.strip
|
|
15
|
+
name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# What to try after a 401/403 from a host that has no api_key_env.
|
|
19
|
+
def self.missing_hint(host)
|
|
20
|
+
"the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sets the header, or raises AuthError when the variable is not set.
|
|
24
|
+
def authorize(request)
|
|
25
|
+
key = env[env_name].to_s
|
|
26
|
+
raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
|
|
27
|
+
|
|
28
|
+
request["Authorization"] = "Bearer #{key}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# What to try after a 401/403 with the key sent.
|
|
32
|
+
def hint = "check #{env_name} (the API key for host #{host})"
|
|
33
|
+
|
|
34
|
+
# Without the environment: it holds this key and every other secret.
|
|
35
|
+
def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
|
|
36
|
+
alias_method :to_s, :inspect
|
|
37
|
+
|
|
38
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -8,12 +8,16 @@ require_relative "usage"
|
|
|
8
8
|
require_relative "openai_chat"
|
|
9
9
|
require_relative "native_tool_normalizer"
|
|
10
10
|
require_relative "../kernel_loop"
|
|
11
|
+
require_relative "../answer_display"
|
|
11
12
|
require_relative "../context_window"
|
|
12
13
|
require_relative "../context_note"
|
|
13
14
|
require_relative "../tool_runner"
|
|
14
15
|
require_relative "../tool_declarations"
|
|
15
16
|
require_relative "../vision_context"
|
|
16
17
|
require_relative "../log"
|
|
18
|
+
require_relative "../empty_answer_retry"
|
|
19
|
+
require_relative "../turn_note"
|
|
20
|
+
require_relative "../thinking"
|
|
17
21
|
|
|
18
22
|
module Samagotchi
|
|
19
23
|
module LLM
|
|
@@ -131,6 +135,39 @@ module Samagotchi
|
|
|
131
135
|
@kernel.respond_to?(:vision) ? @kernel.vision : nil
|
|
132
136
|
end
|
|
133
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
|
|
142
|
+
|
|
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
|
|
147
|
+
|
|
148
|
+
# The request fields the thinking level adds (Thinking.chat_fields);
|
|
149
|
+
# none for a model whose host refused them (#thinking_refused!).
|
|
150
|
+
def thinking_fields(model = nil)
|
|
151
|
+
return {} if model && (@thinking_refused ||= Set.new).include?(model)
|
|
152
|
+
|
|
153
|
+
Thinking.chat_fields(thinking)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The host refused +model+'s thinking fields: leave them out from now on.
|
|
157
|
+
def thinking_refused!(model)
|
|
158
|
+
(@thinking_refused ||= Set.new) << model
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One generation's options: the thinking fields under the sampling
|
|
162
|
+
# (a sampling key wins, chat_template_kwargs merges per sub-key), the
|
|
163
|
+
# empty-answer retry's temperature on top, then every null dropped at
|
|
164
|
+
# any depth (a sampling null means "don't send it").
|
|
165
|
+
def request_options(retry_generation: false, model: nil)
|
|
166
|
+
options = deep_merge(thinking_fields(model), sampling)
|
|
167
|
+
options = EmptyAnswerRetry.sampling(options) if retry_generation
|
|
168
|
+
deep_compact(options)
|
|
169
|
+
end
|
|
170
|
+
|
|
134
171
|
def strip_model_thought(text)
|
|
135
172
|
@kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
|
|
136
173
|
end
|
|
@@ -172,7 +209,7 @@ module Samagotchi
|
|
|
172
209
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
173
210
|
# refs of a user message or a tool result, and a plugin tool result's
|
|
174
211
|
# tool_params and tool_labels (the live row's params line and label,
|
|
175
|
-
# never sent back).
|
|
212
|
+
# never sent back), and an edit/write result's tool_diffs (never sent).
|
|
176
213
|
def plain(conversation)
|
|
177
214
|
conversation.map do |entry|
|
|
178
215
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -183,6 +220,8 @@ module Samagotchi
|
|
|
183
220
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
184
221
|
message[:tool_params] = entry[:tool_params] if entry[:tool_params]
|
|
185
222
|
message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
|
|
223
|
+
message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
|
|
224
|
+
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
186
225
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
187
226
|
message
|
|
188
227
|
end
|
|
@@ -190,6 +229,18 @@ module Samagotchi
|
|
|
190
229
|
|
|
191
230
|
private
|
|
192
231
|
|
|
232
|
+
def deep_merge(base, over)
|
|
233
|
+
base.merge(over) { |_key, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def deep_compact(hash)
|
|
237
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
238
|
+
next if value.nil?
|
|
239
|
+
|
|
240
|
+
out[key] = value.is_a?(Hash) ? deep_compact(value) : value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
193
244
|
# Ids of the calls whose assistant turn is followed by a tool message
|
|
194
245
|
# for every one of them (before the next non-tool message).
|
|
195
246
|
def paired_call_ids(conversation)
|
|
@@ -268,6 +319,8 @@ module Samagotchi
|
|
|
268
319
|
# usage: the text, and each image's estimate (not its base64).
|
|
269
320
|
@prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
|
|
270
321
|
@image_tokens = ImagePlan.estimated_tokens(conversation)
|
|
322
|
+
@empty_retries = 0
|
|
323
|
+
@empty_retry_limit = EmptyAnswerRetry.limit
|
|
271
324
|
end
|
|
272
325
|
|
|
273
326
|
EMPTY_ANSWER = "(the model returned an empty answer)"
|
|
@@ -288,7 +341,12 @@ module Samagotchi
|
|
|
288
341
|
# Kept before a merge too: the model answers the merged line
|
|
289
342
|
# knowing what it just said.
|
|
290
343
|
@conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
|
|
291
|
-
|
|
344
|
+
retry_empty = last_text.empty? && retry_empty_answer?(iteration, response)
|
|
345
|
+
# An empty answer that will be retried is no answer site: a
|
|
346
|
+
# plugin's steer joins the retry instead of being dropped, and
|
|
347
|
+
# queued input (a user's line, a steer) goes in place of the nudge.
|
|
348
|
+
next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
|
|
349
|
+
next if retry_empty && nudge_empty_answer(iteration, response)
|
|
292
350
|
|
|
293
351
|
# Shown, not saved: an empty answer (content "" + stop, seen from
|
|
294
352
|
# a remote host) would otherwise end the turn with nothing.
|
|
@@ -310,6 +368,31 @@ module Samagotchi
|
|
|
310
368
|
|
|
311
369
|
private
|
|
312
370
|
|
|
371
|
+
# A retry is left, and the answer wasn't cut short by a full context
|
|
372
|
+
# (a length stop while thinking is retried: a thinking loop cut by
|
|
373
|
+
# the provider's output cap, not a full window).
|
|
374
|
+
def retry_empty_answer?(iteration, response)
|
|
375
|
+
return false if @empty_retries >= @empty_retry_limit || @cancel_controller&.cancelled?
|
|
376
|
+
|
|
377
|
+
if response.finish_reason.to_s == "length" &&
|
|
378
|
+
EmptyAnswerRetry.context_full?(response.usage&.total_tokens, @window&.tokens)
|
|
379
|
+
Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
|
|
380
|
+
return false
|
|
381
|
+
end
|
|
382
|
+
true
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# The hidden nudge before the next generation (EmptyAnswerRetry),
|
|
386
|
+
# which runs at the retry temperature. Returns true.
|
|
387
|
+
def nudge_empty_answer(iteration, response)
|
|
388
|
+
@empty_retries += 1
|
|
389
|
+
@retry_generation = true
|
|
390
|
+
emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
|
|
391
|
+
finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
|
|
392
|
+
@conversation << TurnNote.empty_retry
|
|
393
|
+
true
|
|
394
|
+
end
|
|
395
|
+
|
|
313
396
|
# The host's reasoning, kept on the model message as +thinking+ for
|
|
314
397
|
# the web turn view's reload (the whole of it, as the live view
|
|
315
398
|
# shows). Only saved: #assistant_message builds the wire message from
|
|
@@ -322,14 +405,40 @@ module Samagotchi
|
|
|
322
405
|
# One streamed request. Returns [response, nil], or [reason, partial
|
|
323
406
|
# text] when it was cancelled.
|
|
324
407
|
def generate(iteration)
|
|
325
|
-
window = @loop.context_window(@model_name)
|
|
408
|
+
window = @window = @loop.context_window(@model_name)
|
|
409
|
+
retry_generation = @retry_generation
|
|
410
|
+
@retry_generation = false
|
|
326
411
|
emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
|
|
327
412
|
context_window_source: window&.source)
|
|
328
413
|
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
329
414
|
streamed = +""
|
|
330
|
-
response =
|
|
415
|
+
response = begin
|
|
416
|
+
request(iteration, retry_generation, streamed)
|
|
417
|
+
rescue BadRequest => e
|
|
418
|
+
raise unless thinking_refused?(e)
|
|
419
|
+
|
|
420
|
+
# Once per model: asked again without the thinking fields.
|
|
421
|
+
@loop.thinking_refused!(@model_name)
|
|
422
|
+
emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
|
|
423
|
+
request(iteration, retry_generation, streamed)
|
|
424
|
+
end
|
|
425
|
+
record_context_status(response.usage, window)
|
|
426
|
+
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
427
|
+
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
428
|
+
requested_model: @model_name, finish_reason: response.finish_reason)
|
|
429
|
+
dump_response(response, iteration)
|
|
430
|
+
@loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
|
|
431
|
+
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
432
|
+
[response, nil]
|
|
433
|
+
rescue RequestCancelled => e
|
|
434
|
+
[e.reason, streamed]
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def request(iteration, retry_generation, streamed)
|
|
438
|
+
@loop.adapter.chat(
|
|
331
439
|
messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
|
|
332
440
|
cancel_controller: @cancel_controller, session_id: @loop.session_id,
|
|
441
|
+
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
333
442
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
334
443
|
streamed << content
|
|
335
444
|
emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
@@ -337,16 +446,13 @@ module Samagotchi
|
|
|
337
446
|
},
|
|
338
447
|
on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
|
|
339
448
|
)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
[response, nil]
|
|
348
|
-
rescue RequestCancelled => e
|
|
349
|
-
[e.reason, streamed]
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# A 400 about reasoning, for a request that carried thinking fields
|
|
452
|
+
# (not a missing-tools, image or context error).
|
|
453
|
+
def thinking_refused?(error)
|
|
454
|
+
error.reasoning_refused? && !error.is_a?(VisionUnsupported) && !error.tools_unsupported? &&
|
|
455
|
+
!error.context_overflow? && !@loop.thinking_fields(@model_name).empty?
|
|
350
456
|
end
|
|
351
457
|
|
|
352
458
|
# The status line's value from the server's counts for this request
|
|
@@ -385,31 +491,28 @@ module Samagotchi
|
|
|
385
491
|
# A plugin tool's params line, for the web's reload; never sent.
|
|
386
492
|
entry[:tool_params] = run[:shown_params] if run[:shown_params]
|
|
387
493
|
entry[:tool_labels] = run[:shown_label] if run[:shown_label]
|
|
494
|
+
# What an edit/write changed, for the web's reload; never sent.
|
|
495
|
+
entry[:tool_diffs] = run[:diff] if run[:diff]
|
|
388
496
|
@conversation << entry
|
|
389
497
|
end
|
|
390
498
|
emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
|
|
391
499
|
end
|
|
392
500
|
|
|
393
|
-
# Queued steering joins the conversation as one user message
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
501
|
+
# Queued steering joins the conversation as one user message, a
|
|
502
|
+
# plugin's steers each as its own after it (Steer). Returns true when
|
|
503
|
+
# there was any. After a cancel it stays queued, so it runs as the
|
|
504
|
+
# next turn instead of dying with this one. +answer+ is the answer the
|
|
505
|
+
# merge follows, for the UIs; given, the drain is told it is the
|
|
506
|
+
# after-answer site (plugin steers are dropped there).
|
|
397
507
|
def inject_pending_input(iteration, answer: nil)
|
|
398
508
|
return false unless @pending_input
|
|
399
509
|
return false if @cancel_controller&.cancelled?
|
|
400
510
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
rescue StandardError
|
|
404
|
-
nil
|
|
405
|
-
end
|
|
406
|
-
return false if lines.nil? || lines.empty?
|
|
407
|
-
|
|
408
|
-
content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
|
|
409
|
-
return false if content.empty?
|
|
511
|
+
merge = Steer.merge(Steer.drain(@pending_input, at_answer: !answer.nil?))
|
|
512
|
+
return false if merge.empty?
|
|
410
513
|
|
|
411
|
-
@conversation
|
|
412
|
-
emit(type: :pending_input_merged, iteration: iteration,
|
|
514
|
+
@conversation.concat(merge.messages)
|
|
515
|
+
emit(type: :pending_input_merged, iteration: iteration, **merge.event_fields,
|
|
413
516
|
answer: answer.to_s.empty? ? nil : answer)
|
|
414
517
|
true
|
|
415
518
|
end
|