samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- 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 +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- 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 +358 -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/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- 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 +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- 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/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -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 +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -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 +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- 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 +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- 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 +11 -0
- metadata +20 -1
|
@@ -15,14 +15,15 @@ module Samagotchi
|
|
|
15
15
|
takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# What a hook can do beyond reading its event: the Engine's
|
|
18
|
+
# What a hook can do beyond reading its event: the Engine's
|
|
19
19
|
# callables, each given the hook's label. +notify+ takes
|
|
20
20
|
# (text:, level:, hook:) and shows one line to the user; +ask_user+
|
|
21
21
|
# takes (question:, options:, header:, allow_freeform:, hook:) and
|
|
22
22
|
# returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
23
|
-
# cancels the running turn (true when it did)
|
|
24
|
-
#
|
|
25
|
-
|
|
23
|
+
# cancels the running turn (true when it did); +steer+ takes (text:,
|
|
24
|
+
# hook:) and puts the text into the running turn (Engine#steer, true
|
|
25
|
+
# when queued). A registry without one gives hooks no-op helpers.
|
|
26
|
+
Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, keyword_init: true)
|
|
26
27
|
|
|
27
28
|
# A thread-safe registry for named hook callbacks.
|
|
28
29
|
#
|
|
@@ -172,6 +173,12 @@ module Samagotchi
|
|
|
172
173
|
# event[:stop_turn].call(reason) -> true when the turn was cancelled;
|
|
173
174
|
# in a before_tool_call event it also denies the call; from
|
|
174
175
|
# after_turn / session_end it does nothing (false)
|
|
176
|
+
# event[:steer].call(text) -> true when the text was queued for the
|
|
177
|
+
# running turn's next boundary (its own user message, source: the
|
|
178
|
+
# hook's bundle); from after_turn / session_end false
|
|
179
|
+
# and after_turn's fire site adds one more (AnswerDisplay):
|
|
180
|
+
# event[:present].call { |text| new_text } -> the answer's display
|
|
181
|
+
# text after the call (chained in hook order), nil without an answer
|
|
175
182
|
#
|
|
176
183
|
# @param name [Symbol] the hook name to fire
|
|
177
184
|
# @param event [Hash] the event payload (may be mutated by hooks)
|
|
@@ -191,6 +198,13 @@ module Samagotchi
|
|
|
191
198
|
end
|
|
192
199
|
end
|
|
193
200
|
|
|
201
|
+
# Whether a #fire of +name+ would call any hook.
|
|
202
|
+
# @param name [Symbol]
|
|
203
|
+
# @return [Boolean]
|
|
204
|
+
def any?(name)
|
|
205
|
+
!ordered_procs(name).empty?
|
|
206
|
+
end
|
|
207
|
+
|
|
194
208
|
# Like #fire, and yields the event after each hook (a raising one
|
|
195
209
|
# too), so the caller can fold what that hook did before the next one
|
|
196
210
|
# runs (the guardrail gate keeps a deny sticky this way).
|
|
@@ -235,7 +249,7 @@ module Samagotchi
|
|
|
235
249
|
end
|
|
236
250
|
end
|
|
237
251
|
|
|
238
|
-
# The
|
|
252
|
+
# The helpers, once per fire; a fire site's own keys stay.
|
|
239
253
|
def with_runtime(event)
|
|
240
254
|
return unless event.is_a?(Hash)
|
|
241
255
|
|
|
@@ -255,6 +269,11 @@ module Samagotchi
|
|
|
255
269
|
end
|
|
256
270
|
@runtime&.stop_turn&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
|
|
257
271
|
}
|
|
272
|
+
event[:steer] ||= lambda { |text|
|
|
273
|
+
next false if TURN_OVER_EVENTS.include?(event[:type])
|
|
274
|
+
|
|
275
|
+
@runtime&.steer&.call(text: text.to_s, hook: event[:hook]) ? true : false
|
|
276
|
+
}
|
|
258
277
|
end
|
|
259
278
|
end
|
|
260
279
|
end
|
|
@@ -28,9 +28,10 @@ module Samagotchi
|
|
|
28
28
|
# come from it); api_key_env: the variable holding the host's API key;
|
|
29
29
|
# profile: the configured prompt profile name, if any;
|
|
30
30
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
31
|
-
# vision: the configured true/false (VisionSupport), nil when unset
|
|
31
|
+
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
32
|
+
# sampling: the configured request parameters (SamplingSettings), nil when unset.
|
|
32
33
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
33
|
-
:first_token_timeout, :vision, keyword_init: true) do
|
|
34
|
+
:first_token_timeout, :vision, :sampling, keyword_init: true) do
|
|
34
35
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
35
36
|
# raw-prompt loop.
|
|
36
37
|
def chat? = api == :openai
|
|
@@ -86,7 +87,7 @@ module Samagotchi
|
|
|
86
87
|
entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
|
|
87
88
|
api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
|
|
88
89
|
profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
|
|
89
|
-
vision: cfg[:vision])
|
|
90
|
+
vision: cfg[:vision], sampling: cfg[:sampling])
|
|
90
91
|
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
91
92
|
first_token_timeout: entry.first_token_limit, name: entry.name)
|
|
92
93
|
@entries[entry.name] = entry
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
9
|
require_relative "output_formatter"
|
|
10
|
+
require_relative "steer"
|
|
10
11
|
require_relative "recap_store"
|
|
11
12
|
require_relative "log"
|
|
12
13
|
|
|
@@ -60,6 +61,9 @@ module Samagotchi
|
|
|
60
61
|
|
|
61
62
|
case message["role"]
|
|
62
63
|
when "user"
|
|
64
|
+
# A plugin's steer is a prod to the model, not what the user said.
|
|
65
|
+
next nil if Steer.steer?(message)
|
|
66
|
+
|
|
63
67
|
# An image is a line naming it (refs only, never its bytes).
|
|
64
68
|
[message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
|
|
65
69
|
when "model", "assistant"
|
|
@@ -411,7 +415,7 @@ module Samagotchi
|
|
|
411
415
|
parsed = safe_parse(@engine.messages_json_for_recap)
|
|
412
416
|
return :too_short if parsed.nil?
|
|
413
417
|
|
|
414
|
-
user_turns = parsed.count { |message|
|
|
418
|
+
user_turns = parsed.count { |message| Steer.prompt?(message) }
|
|
415
419
|
if parsed.empty? || user_turns < @min_user_turns
|
|
416
420
|
drop_stale(parsed)
|
|
417
421
|
return :too_short
|
|
@@ -10,13 +10,16 @@ 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"
|
|
13
14
|
require_relative "hooks"
|
|
14
15
|
require_relative "pending_input_queue"
|
|
16
|
+
require_relative "steer"
|
|
15
17
|
require_relative "thought_stream_splitter"
|
|
16
18
|
require_relative "tools/builtins"
|
|
17
19
|
require_relative "muted_memories"
|
|
18
20
|
require_relative "tool_activity"
|
|
19
21
|
require_relative "tool_runner"
|
|
22
|
+
require_relative "answer_display"
|
|
20
23
|
|
|
21
24
|
module Samagotchi
|
|
22
25
|
# The KernelLoop drives the model ↔ tool interaction cycle.
|
|
@@ -173,6 +176,9 @@ module Samagotchi
|
|
|
173
176
|
# The turn's VisionContext (images: capability, files, limits), set by
|
|
174
177
|
# the Engine per turn; nil sends no images (placeholders instead).
|
|
175
178
|
attr_accessor :vision
|
|
179
|
+
# The turn's request parameters (SamplingSettings.for), set by the Engine
|
|
180
|
+
# per turn; empty or nil sends none.
|
|
181
|
+
attr_accessor :sampling
|
|
176
182
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
177
183
|
# list_sessions and send_note speak for; nil outside a session.
|
|
178
184
|
attr_accessor :peers
|
|
@@ -206,6 +212,9 @@ module Samagotchi
|
|
|
206
212
|
tool_activity = []
|
|
207
213
|
qwen_recovery_attempts = 0
|
|
208
214
|
qwen_partial_tool_call = nil
|
|
215
|
+
empty_retries = 0
|
|
216
|
+
empty_retry_limit = EmptyAnswerRetry.limit
|
|
217
|
+
@retry_generation = false
|
|
209
218
|
context_status = nil
|
|
210
219
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
211
220
|
partial_assistant_buffer = +""
|
|
@@ -299,7 +308,7 @@ module Samagotchi
|
|
|
299
308
|
# Fire :after_generation hook (after LLM returns, before tool parse),
|
|
300
309
|
# with a read-only copy of the conversation as sent.
|
|
301
310
|
after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
|
|
302
|
-
messages: conversation.map(&:dup).freeze }
|
|
311
|
+
messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
|
|
303
312
|
fire_hook(:after_generation, after_gen_event) if @hooks
|
|
304
313
|
conversation << { role: "model", content: response }
|
|
305
314
|
|
|
@@ -322,7 +331,25 @@ module Samagotchi
|
|
|
322
331
|
|
|
323
332
|
pending_tool_calls = false
|
|
324
333
|
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
325
|
-
|
|
334
|
+
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
335
|
+
retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
|
|
336
|
+
# The empty generation goes (its thinking would be sent again and
|
|
337
|
+
# prime the same loop); an empty answer that will be retried is no
|
|
338
|
+
# answer site, so a plugin's steer joins the retry.
|
|
339
|
+
conversation.pop if retry_empty
|
|
340
|
+
unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
|
|
341
|
+
answer: retry_empty ? nil : answer)
|
|
342
|
+
if retry_empty
|
|
343
|
+
empty_retries += 1
|
|
344
|
+
@retry_generation = true
|
|
345
|
+
emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
|
|
346
|
+
attempt: empty_retries, of: empty_retry_limit,
|
|
347
|
+
thinking_chars: thinking_chars(response.to_s, streamed_thinking))
|
|
348
|
+
conversation << TurnNote.empty_retry
|
|
349
|
+
next
|
|
350
|
+
end
|
|
351
|
+
# The Engine's TurnNote.empty says it all: the spent nudge goes.
|
|
352
|
+
drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
|
|
326
353
|
break
|
|
327
354
|
end
|
|
328
355
|
# Queued steering keeps the turn going: loop again so the model
|
|
@@ -437,6 +464,8 @@ module Samagotchi
|
|
|
437
464
|
# append them as ONE merged user message at the conversation tail and emit
|
|
438
465
|
# :pending_input_merged. Tail-append only: head mutation would invalidate
|
|
439
466
|
# the server-side prefix KV cache. Returns true when a message was injected.
|
|
467
|
+
# A plugin's steers (Steer) follow the user's message, each its own
|
|
468
|
+
# message; after an answer the Engine's drain has already dropped them.
|
|
440
469
|
# After a cancel the input stays queued: it runs as the next turn instead
|
|
441
470
|
# of dying with this one. +answer+ (a proc, called only on a merge) is the
|
|
442
471
|
# answer the merge follows: the UIs show it, the turn summary has only the
|
|
@@ -445,24 +474,16 @@ module Samagotchi
|
|
|
445
474
|
return false unless pending_input
|
|
446
475
|
return false if cancel_controller&.cancelled?
|
|
447
476
|
|
|
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?
|
|
477
|
+
merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
|
|
478
|
+
return false if merge.empty?
|
|
457
479
|
|
|
458
480
|
answer = answer.call.to_s if answer
|
|
459
|
-
conversation
|
|
481
|
+
conversation.concat(merge.messages)
|
|
460
482
|
emit_stream_event(
|
|
461
483
|
on_stream_event,
|
|
462
484
|
type: :pending_input_merged,
|
|
463
485
|
iteration: iteration,
|
|
464
|
-
|
|
465
|
-
content: content,
|
|
486
|
+
**merge.event_fields,
|
|
466
487
|
answer: answer.to_s.strip.empty? ? nil : answer
|
|
467
488
|
)
|
|
468
489
|
true
|
|
@@ -524,6 +545,9 @@ module Samagotchi
|
|
|
524
545
|
kwargs[:n_predict] = n_predict if n_predict && client_supports_keyword?(:n_predict)
|
|
525
546
|
resolved_model_name = completion_model_name(model_name)
|
|
526
547
|
kwargs[:model] = resolved_model_name if resolved_model_name && client_supports_keyword?(:model)
|
|
548
|
+
sampling = @retry_generation ? EmptyAnswerRetry.sampling(@sampling) : @sampling
|
|
549
|
+
@retry_generation = false
|
|
550
|
+
kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
|
|
527
551
|
kwargs
|
|
528
552
|
end
|
|
529
553
|
|
|
@@ -862,7 +886,9 @@ module Samagotchi
|
|
|
862
886
|
def sanitize_history(messages)
|
|
863
887
|
messages.map do |m|
|
|
864
888
|
if m[:role] == "model"
|
|
865
|
-
|
|
889
|
+
# `display` rides along so the stored conversation keeps it; the
|
|
890
|
+
# prompt formatter never reads it (AnswerDisplay).
|
|
891
|
+
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
|
|
866
892
|
else
|
|
867
893
|
m.dup
|
|
868
894
|
end
|
|
@@ -882,6 +908,12 @@ module Samagotchi
|
|
|
882
908
|
messages.map(&:dup)
|
|
883
909
|
end
|
|
884
910
|
|
|
911
|
+
def drop_last_empty_retry!(conversation)
|
|
912
|
+
nudge = TurnNote.empty_retry
|
|
913
|
+
index = conversation.rindex { |entry| entry[:kind] == nudge[:kind] && entry[:content] == nudge[:content] }
|
|
914
|
+
conversation.delete_at(index) if index
|
|
915
|
+
end
|
|
916
|
+
|
|
885
917
|
def last_model_content(conversation)
|
|
886
918
|
message = conversation.reverse.find { |entry| entry[:role] == "model" }
|
|
887
919
|
message ? message[:content].to_s : ""
|
|
@@ -8,12 +8,15 @@ 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"
|
|
17
20
|
|
|
18
21
|
module Samagotchi
|
|
19
22
|
module LLM
|
|
@@ -131,6 +134,11 @@ module Samagotchi
|
|
|
131
134
|
@kernel.respond_to?(:vision) ? @kernel.vision : nil
|
|
132
135
|
end
|
|
133
136
|
|
|
137
|
+
# The turn's request parameters (the Engine sets them on the kernel).
|
|
138
|
+
def sampling
|
|
139
|
+
@kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
|
|
140
|
+
end
|
|
141
|
+
|
|
134
142
|
def strip_model_thought(text)
|
|
135
143
|
@kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
|
|
136
144
|
end
|
|
@@ -183,6 +191,7 @@ module Samagotchi
|
|
|
183
191
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
184
192
|
message[:tool_params] = entry[:tool_params] if entry[:tool_params]
|
|
185
193
|
message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
|
|
194
|
+
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
186
195
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
187
196
|
message
|
|
188
197
|
end
|
|
@@ -268,6 +277,8 @@ module Samagotchi
|
|
|
268
277
|
# usage: the text, and each image's estimate (not its base64).
|
|
269
278
|
@prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
|
|
270
279
|
@image_tokens = ImagePlan.estimated_tokens(conversation)
|
|
280
|
+
@empty_retries = 0
|
|
281
|
+
@empty_retry_limit = EmptyAnswerRetry.limit
|
|
271
282
|
end
|
|
272
283
|
|
|
273
284
|
EMPTY_ANSWER = "(the model returned an empty answer)"
|
|
@@ -288,7 +299,12 @@ module Samagotchi
|
|
|
288
299
|
# Kept before a merge too: the model answers the merged line
|
|
289
300
|
# knowing what it just said.
|
|
290
301
|
@conversation << with_thinking({ role: "model", content: last_text }, response) unless last_text.empty?
|
|
291
|
-
|
|
302
|
+
retry_empty = last_text.empty? && retry_empty_answer?(iteration, response)
|
|
303
|
+
# An empty answer that will be retried is no answer site: a
|
|
304
|
+
# plugin's steer joins the retry instead of being dropped, and
|
|
305
|
+
# queued input (a user's line, a steer) goes in place of the nudge.
|
|
306
|
+
next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
|
|
307
|
+
next if retry_empty && nudge_empty_answer(iteration, response)
|
|
292
308
|
|
|
293
309
|
# Shown, not saved: an empty answer (content "" + stop, seen from
|
|
294
310
|
# a remote host) would otherwise end the turn with nothing.
|
|
@@ -310,6 +326,31 @@ module Samagotchi
|
|
|
310
326
|
|
|
311
327
|
private
|
|
312
328
|
|
|
329
|
+
# A retry is left, and the answer wasn't cut short by a full context
|
|
330
|
+
# (a length stop while thinking is retried: a thinking loop cut by
|
|
331
|
+
# the provider's output cap, not a full window).
|
|
332
|
+
def retry_empty_answer?(iteration, response)
|
|
333
|
+
return false if @empty_retries >= @empty_retry_limit || @cancel_controller&.cancelled?
|
|
334
|
+
|
|
335
|
+
if response.finish_reason.to_s == "length" &&
|
|
336
|
+
EmptyAnswerRetry.context_full?(response.usage&.total_tokens, @window&.tokens)
|
|
337
|
+
Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
|
|
338
|
+
return false
|
|
339
|
+
end
|
|
340
|
+
true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The hidden nudge before the next generation (EmptyAnswerRetry),
|
|
344
|
+
# which runs at the retry temperature. Returns true.
|
|
345
|
+
def nudge_empty_answer(iteration, response)
|
|
346
|
+
@empty_retries += 1
|
|
347
|
+
@retry_generation = true
|
|
348
|
+
emit(type: :empty_answer_retry, iteration: iteration, attempt: @empty_retries, of: @empty_retry_limit,
|
|
349
|
+
finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
|
|
350
|
+
@conversation << TurnNote.empty_retry
|
|
351
|
+
true
|
|
352
|
+
end
|
|
353
|
+
|
|
313
354
|
# The host's reasoning, kept on the model message as +thinking+ for
|
|
314
355
|
# the web turn view's reload (the whole of it, as the live view
|
|
315
356
|
# shows). Only saved: #assistant_message builds the wire message from
|
|
@@ -322,14 +363,17 @@ module Samagotchi
|
|
|
322
363
|
# One streamed request. Returns [response, nil], or [reason, partial
|
|
323
364
|
# text] when it was cancelled.
|
|
324
365
|
def generate(iteration)
|
|
325
|
-
window = @loop.context_window(@model_name)
|
|
366
|
+
window = @window = @loop.context_window(@model_name)
|
|
367
|
+
options = @loop.sampling
|
|
368
|
+
options = EmptyAnswerRetry.sampling(options) if @retry_generation
|
|
369
|
+
@retry_generation = false
|
|
326
370
|
emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
|
|
327
371
|
context_window_source: window&.source)
|
|
328
372
|
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
329
373
|
streamed = +""
|
|
330
374
|
response = @loop.adapter.chat(
|
|
331
375
|
messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
|
|
332
|
-
cancel_controller: @cancel_controller, session_id: @loop.session_id,
|
|
376
|
+
cancel_controller: @cancel_controller, session_id: @loop.session_id, options: options,
|
|
333
377
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
334
378
|
streamed << content
|
|
335
379
|
emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
@@ -340,10 +384,10 @@ module Samagotchi
|
|
|
340
384
|
record_context_status(response.usage, window)
|
|
341
385
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
342
386
|
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
343
|
-
requested_model: @model_name)
|
|
387
|
+
requested_model: @model_name, finish_reason: response.finish_reason)
|
|
344
388
|
dump_response(response, iteration)
|
|
345
389
|
@loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
|
|
346
|
-
messages: @conversation.map(&:dup).freeze })
|
|
390
|
+
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
347
391
|
[response, nil]
|
|
348
392
|
rescue RequestCancelled => e
|
|
349
393
|
[e.reason, streamed]
|
|
@@ -390,26 +434,21 @@ module Samagotchi
|
|
|
390
434
|
emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
|
|
391
435
|
end
|
|
392
436
|
|
|
393
|
-
# Queued steering joins the conversation as one user message
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
437
|
+
# Queued steering joins the conversation as one user message, a
|
|
438
|
+
# plugin's steers each as its own after it (Steer). Returns true when
|
|
439
|
+
# there was any. After a cancel it stays queued, so it runs as the
|
|
440
|
+
# next turn instead of dying with this one. +answer+ is the answer the
|
|
441
|
+
# merge follows, for the UIs; given, the drain is told it is the
|
|
442
|
+
# after-answer site (plugin steers are dropped there).
|
|
397
443
|
def inject_pending_input(iteration, answer: nil)
|
|
398
444
|
return false unless @pending_input
|
|
399
445
|
return false if @cancel_controller&.cancelled?
|
|
400
446
|
|
|
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?
|
|
447
|
+
merge = Steer.merge(Steer.drain(@pending_input, at_answer: !answer.nil?))
|
|
448
|
+
return false if merge.empty?
|
|
410
449
|
|
|
411
|
-
@conversation
|
|
412
|
-
emit(type: :pending_input_merged, iteration: iteration,
|
|
450
|
+
@conversation.concat(merge.messages)
|
|
451
|
+
emit(type: :pending_input_merged, iteration: iteration, **merge.event_fields,
|
|
413
452
|
answer: answer.to_s.empty? ? nil : answer)
|
|
414
453
|
true
|
|
415
454
|
end
|
|
@@ -19,7 +19,8 @@ module Samagotchi
|
|
|
19
19
|
# (#kind): the UIs print one line per kind, and #retryable? says whether
|
|
20
20
|
# asking again could help.
|
|
21
21
|
#
|
|
22
|
-
# ConnectionError no answer (refused, reset, timeout); RetryExhausted
|
|
22
|
+
# ConnectionError no answer (refused, reset, timeout); RetryExhausted,
|
|
23
|
+
# ConnectionRefused
|
|
23
24
|
# RateLimited 429, with retry_after when the server says
|
|
24
25
|
# ServerError 5xx or a server's error event mid-stream
|
|
25
26
|
# AuthError 401/403, or an API key variable that is not set
|
|
@@ -98,12 +99,16 @@ module Samagotchi
|
|
|
98
99
|
class BadRequest < ProviderError
|
|
99
100
|
TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
# @param hint [String, nil] what to try, added to the summary
|
|
103
|
+
def initialize(message = nil, context_overflow: false, tools_unsupported: false, hint: nil, **options)
|
|
102
104
|
@context_overflow = context_overflow
|
|
103
105
|
@tools_unsupported = tools_unsupported
|
|
106
|
+
@hint = hint
|
|
104
107
|
super(message, **options)
|
|
105
108
|
end
|
|
106
109
|
|
|
110
|
+
attr_reader :hint
|
|
111
|
+
|
|
107
112
|
# The prompt is larger than the model's context window.
|
|
108
113
|
def context_overflow? = @context_overflow
|
|
109
114
|
|
|
@@ -119,7 +124,7 @@ module Samagotchi
|
|
|
119
124
|
elsif tools_unsupported?
|
|
120
125
|
"host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
|
|
121
126
|
else
|
|
122
|
-
"host #{host} rejected the request: #{detail}"
|
|
127
|
+
"host #{host} rejected the request: #{detail}#{"; #{hint}" if hint}"
|
|
123
128
|
end
|
|
124
129
|
end
|
|
125
130
|
|
|
@@ -174,6 +179,19 @@ module Samagotchi
|
|
|
174
179
|
def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
|
|
175
180
|
end
|
|
176
181
|
|
|
182
|
+
# A refused connection: nothing listens at the host's address. Not
|
|
183
|
+
# retried (a server that isn't running rarely starts within the backoff).
|
|
184
|
+
class ConnectionRefused < ConnectionError
|
|
185
|
+
attr_reader :address
|
|
186
|
+
|
|
187
|
+
def initialize(host:, address: nil, attempts: 1)
|
|
188
|
+
@address = address
|
|
189
|
+
super("#{host}: connection refused#{" at #{address}" if address}", host: host, retryable: false, attempts: attempts)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
|
|
193
|
+
end
|
|
194
|
+
|
|
177
195
|
# A stream that showed nothing (no text, reasoning or tool call) within
|
|
178
196
|
# the host's first-token limit. A queued request can stay open for many
|
|
179
197
|
# minutes on keep-alive comments alone, which reset the read timeout.
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -176,15 +176,18 @@ module Samagotchi
|
|
|
176
176
|
private :stream_attempt
|
|
177
177
|
|
|
178
178
|
# Send +request+ and return the response with its body read.
|
|
179
|
-
# @param retries [Boolean] false: one attempt, network errors raised as
|
|
179
|
+
# @param retries [Boolean] false: one attempt, network errors raised as
|
|
180
|
+
# is (Net::HTTP's own silent retry of an idempotent GET included)
|
|
180
181
|
# @param check_status [Boolean] false: return an error response instead
|
|
181
182
|
# of raising its ProviderError
|
|
183
|
+
# @raise [RequestCancelled] when +cancel_controller+ cancels (with or
|
|
184
|
+
# without retries)
|
|
182
185
|
def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
|
|
183
186
|
cancel_controller: nil, log_fields: {})
|
|
184
187
|
identify(request)
|
|
185
188
|
current = new_attempt_state(uri, request, log_fields, stream: false)
|
|
186
189
|
attempt = lambda do |state|
|
|
187
|
-
start(uri, open_timeout: open_timeout, read_timeout: read_timeout) do |http|
|
|
190
|
+
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
|
|
188
191
|
state[:http] = http
|
|
189
192
|
http.request(request).tap do |response|
|
|
190
193
|
state[:status] = response.code.to_i
|
|
@@ -193,9 +196,15 @@ module Samagotchi
|
|
|
193
196
|
end
|
|
194
197
|
end
|
|
195
198
|
logged(current) do
|
|
196
|
-
next
|
|
199
|
+
next with_retries(cancel_controller, nil, nil, current, &attempt) if retries
|
|
197
200
|
|
|
198
|
-
|
|
201
|
+
cancellable(cancel_controller, current) do
|
|
202
|
+
attempt.call(current.merge!(attempts: 1))
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
raise RequestCancelled.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
|
|
205
|
+
|
|
206
|
+
raise
|
|
207
|
+
end
|
|
199
208
|
end
|
|
200
209
|
end
|
|
201
210
|
|
|
@@ -205,8 +214,9 @@ module Samagotchi
|
|
|
205
214
|
request["User-Agent"] = Samagotchi::USER_AGENT
|
|
206
215
|
end
|
|
207
216
|
|
|
208
|
-
def start(uri, open_timeout: nil, read_timeout: nil, &block)
|
|
217
|
+
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
|
|
209
218
|
options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
|
|
219
|
+
options[:max_retries] = max_retries if max_retries
|
|
210
220
|
options[:use_ssl] = true if uri.scheme == "https"
|
|
211
221
|
Net::HTTP.start(uri.host, uri.port, **options, &block)
|
|
212
222
|
end
|
|
@@ -215,10 +225,29 @@ module Samagotchi
|
|
|
215
225
|
# errors. The block gets a hash to put the attempt's Net::HTTP in, so a
|
|
216
226
|
# cancel can reach its socket.
|
|
217
227
|
def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
|
|
228
|
+
cancellable(cancel_controller, current) do
|
|
229
|
+
retrying(cancel_controller, on_retry, on_network_error, current) { |state| yield(state) }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Runs the block with +cancel_controller+ able to abort it: a cancel
|
|
234
|
+
# closes the socket in current[:http] (or raises RequestCancelled in
|
|
235
|
+
# this thread while there is none).
|
|
236
|
+
def cancellable(cancel_controller, current)
|
|
218
237
|
requesting_thread = Thread.current
|
|
219
238
|
listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
|
|
220
239
|
raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
|
|
221
240
|
|
|
241
|
+
yield
|
|
242
|
+
ensure
|
|
243
|
+
# A cancel can call the listener after remove_listener returned (the
|
|
244
|
+
# controller calls listeners outside its lock); :done keeps it from
|
|
245
|
+
# raising into this thread once the request is over.
|
|
246
|
+
current[:mutex].synchronize { current[:done] = true }
|
|
247
|
+
cancel_controller&.remove_listener(listener_id)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def retrying(cancel_controller, on_retry, on_network_error, current)
|
|
222
251
|
attempts = 0
|
|
223
252
|
loop do
|
|
224
253
|
attempts += 1
|
|
@@ -243,6 +272,12 @@ module Samagotchi
|
|
|
243
272
|
raise unless self.class.network_error?(e)
|
|
244
273
|
|
|
245
274
|
on_network_error&.call(e)
|
|
275
|
+
# Nothing is listening: asking again rarely helps, so it fails
|
|
276
|
+
# at once and says what to check.
|
|
277
|
+
if e.is_a?(Errno::ECONNREFUSED)
|
|
278
|
+
raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
|
|
279
|
+
end
|
|
280
|
+
|
|
246
281
|
delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
|
|
247
282
|
raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
|
|
248
283
|
|
|
@@ -251,12 +286,6 @@ module Samagotchi
|
|
|
251
286
|
current.delete(:http)
|
|
252
287
|
end
|
|
253
288
|
end
|
|
254
|
-
ensure
|
|
255
|
-
# A cancel can call the listener after remove_listener returned (the
|
|
256
|
-
# controller calls listeners outside its lock); :done keeps it from
|
|
257
|
-
# raising into this thread once the request is over.
|
|
258
|
-
current[:mutex].synchronize { current[:done] = true }
|
|
259
|
-
cancel_controller&.remove_listener(listener_id)
|
|
260
289
|
end
|
|
261
290
|
|
|
262
291
|
# Runs one attempt (the block) under the first-token limit: a watchdog
|
|
@@ -302,7 +331,7 @@ module Samagotchi
|
|
|
302
331
|
# closes, status, timings) and what its log line says.
|
|
303
332
|
def new_attempt_state(uri, request, log_fields, stream:)
|
|
304
333
|
purpose = log_fields[:purpose]&.to_s
|
|
305
|
-
{ mutex: Mutex.new, started_at: monotonic_now, stream: stream,
|
|
334
|
+
{ mutex: Mutex.new, started_at: monotonic_now, stream: stream, address: "#{uri.host}:#{uri.port}",
|
|
306
335
|
level: LOGGED_PURPOSES.include?(purpose) ? :info : :debug,
|
|
307
336
|
log: { host: @label, method: request.method, url: log_url(uri),
|
|
308
337
|
**log_fields.compact } }
|
|
@@ -326,7 +355,7 @@ module Samagotchi
|
|
|
326
355
|
ms: elapsed_ms(current), attempts: retried(current))
|
|
327
356
|
result
|
|
328
357
|
rescue RequestCancelled => e
|
|
329
|
-
Log.
|
|
358
|
+
Log.public_send(current[:level], :http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
|
|
330
359
|
raise
|
|
331
360
|
rescue StandardError => e
|
|
332
361
|
failure_level = current[:level] == :info ? :error : :debug
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "../config"
|
|
6
|
+
require_relative "../sampling_settings"
|
|
6
7
|
require_relative "errors"
|
|
7
8
|
require_relative "http"
|
|
8
9
|
require_relative "usage"
|
|
@@ -111,9 +112,9 @@ module Samagotchi
|
|
|
111
112
|
# @return [ChatResponse]
|
|
112
113
|
def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
|
|
113
114
|
session_id: nil)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
log_fields = { model: model, purpose: @purpose }
|
|
115
|
+
body = request_body(messages, tools, model, options)
|
|
116
|
+
request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
|
|
117
|
+
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body) }
|
|
117
118
|
return chat_once(request, cancel_controller, log_fields) unless @stream
|
|
118
119
|
|
|
119
120
|
assembly = Assembly.new
|
|
@@ -209,7 +210,14 @@ module Samagotchi
|
|
|
209
210
|
body[:tools] = tools
|
|
210
211
|
body[:tool_choice] = "auto"
|
|
211
212
|
end
|
|
212
|
-
|
|
213
|
+
# A configured value replaces chi's own (temperature); nil drops it.
|
|
214
|
+
body.merge(options || {}).compact
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The body's fields beyond the conversation and the stream, for the
|
|
218
|
+
# log line: "temperature=0.6 presence_penalty=1.5".
|
|
219
|
+
def sampling_summary(body)
|
|
220
|
+
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
|
|
213
221
|
end
|
|
214
222
|
|
|
215
223
|
# A String stays a String; an Array of parts passes as given.
|