samagotchi 0.64.0 → 0.66.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 +88 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +20 -3
- data/docs/cli.md +36 -2
- data/docs/configuration.md +177 -8
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +19 -4
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -2
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +13 -5
- data/lib/samagotchi/engine.rb +151 -11
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +21 -3
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +111 -20
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +38 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +21 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +2 -0
- metadata +17 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -31,6 +31,7 @@ require_relative "llm/backend"
|
|
|
31
31
|
require_relative "llm/openai_chat"
|
|
32
32
|
require_relative "llm/turn_settings"
|
|
33
33
|
require_relative "session"
|
|
34
|
+
require_relative "toolkit"
|
|
34
35
|
require_relative "answer_tail"
|
|
35
36
|
require_relative "archive_store"
|
|
36
37
|
require_relative "session_observer"
|
|
@@ -184,6 +185,9 @@ module Samagotchi
|
|
|
184
185
|
# plugins' tool sets from chi.replace_tools until the turn thread
|
|
185
186
|
# applies them (#apply_staged_tools!). Built before the plugins load.
|
|
186
187
|
@plugin_tasks = build_plugin_tasks
|
|
188
|
+
# The toolkits plugins declare (chi.toolkit), beside the built-in and
|
|
189
|
+
# config ones (#toolkit_kits).
|
|
190
|
+
@plugin_kits = []
|
|
187
191
|
@lifecycle_mutex = Mutex.new
|
|
188
192
|
@shut_down = false
|
|
189
193
|
load_plugins if plugins
|
|
@@ -224,6 +228,7 @@ module Samagotchi
|
|
|
224
228
|
# and after a reminder turn), so the idle layer's clock is identical
|
|
225
229
|
# across UIs.
|
|
226
230
|
@turn_state.restart_clock!
|
|
231
|
+
@side_client_mutex = Mutex.new
|
|
227
232
|
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
228
233
|
session_target: -> { session_model_recap_target },
|
|
229
234
|
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
@@ -299,7 +304,7 @@ module Samagotchi
|
|
|
299
304
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
300
305
|
@hooks.runtime = hook_runtime
|
|
301
306
|
# The loop follows the effective model's host (its api:): the raw-prompt
|
|
302
|
-
# NativeBackend, or the chat backend for openai
|
|
307
|
+
# NativeBackend, or the chat backend for chat hosts (openai, anthropic).
|
|
303
308
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
304
309
|
end
|
|
305
310
|
private :wire_kernel
|
|
@@ -405,6 +410,10 @@ module Samagotchi
|
|
|
405
410
|
# for #cut_for_steer.
|
|
406
411
|
def input_epoch = @steer_cut.input_epoch
|
|
407
412
|
|
|
413
|
+
# The running generation's phase (SteerCut#generation_snapshot): what
|
|
414
|
+
# Bridge#snapshot puts in current_turn.generation.
|
|
415
|
+
def generation_snapshot = @steer_cut.generation_snapshot
|
|
416
|
+
|
|
408
417
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
409
418
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
410
419
|
# any thread; a turn that never begins keeps it for the next one, unless
|
|
@@ -1082,7 +1091,7 @@ module Samagotchi
|
|
|
1082
1091
|
@last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
|
|
1083
1092
|
end
|
|
1084
1093
|
|
|
1085
|
-
# The effective model is on a chat host (api: openai), whose loop uses
|
|
1094
|
+
# The effective model is on a chat host (api: openai or anthropic), whose loop uses
|
|
1086
1095
|
# no prompt profile.
|
|
1087
1096
|
def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
|
|
1088
1097
|
|
|
@@ -1290,6 +1299,7 @@ module Samagotchi
|
|
|
1290
1299
|
llm_context: llm_context_summary,
|
|
1291
1300
|
thinking: thinking_explained&.summary,
|
|
1292
1301
|
reasoning_history: reasoning_history_explained&.summary,
|
|
1302
|
+
toolkit: toolkit_summary,
|
|
1293
1303
|
recap_enabled: !@recap.nil?,
|
|
1294
1304
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1295
1305
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1558,11 +1568,12 @@ module Samagotchi
|
|
|
1558
1568
|
# (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
|
|
1559
1569
|
# does leaves the line out; the others, the log among them, show it. An
|
|
1560
1570
|
# unknown value is dropped, so the line shows everywhere.
|
|
1561
|
-
|
|
1571
|
+
# +extra+: more fields for the UIs (a toolkit change's summary).
|
|
1572
|
+
def hook_notify(text, level, hook, fallback_for: nil, extra: {})
|
|
1562
1573
|
level = (level || :info).to_sym
|
|
1563
1574
|
level = :info unless %i[info warn].include?(level)
|
|
1564
1575
|
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
|
|
1565
|
-
fallback_for: notice_fallback(fallback_for, hook) }.compact
|
|
1576
|
+
fallback_for: notice_fallback(fallback_for, hook) }.compact.merge(extra)
|
|
1566
1577
|
return @extension_load.hold(notice) && nil if @extension_load.loading?
|
|
1567
1578
|
|
|
1568
1579
|
in_turn, sink = @turn_state.in_turn_sink
|
|
@@ -2014,8 +2025,11 @@ module Samagotchi
|
|
|
2014
2025
|
# at its first request).
|
|
2015
2026
|
start_init_tasks!
|
|
2016
2027
|
await_init_tasks(turn.controller, turn.on_event)
|
|
2017
|
-
# Plugins' tool sets that changed since the last turn
|
|
2028
|
+
# Plugins' tool sets that changed since the last turn, then the
|
|
2029
|
+
# session's toolkit (a /toolkit change since the last turn): what the
|
|
2030
|
+
# prompt built next declares, unchanged until the next turn's start.
|
|
2018
2031
|
apply_staged_tools!
|
|
2032
|
+
apply_toolkit!(session, turn.on_event)
|
|
2019
2033
|
# A Stop during the wait above: the turn ends here,
|
|
2020
2034
|
# before the hooks run or a reminder is used up.
|
|
2021
2035
|
return false if turn.controller.cancelled?
|
|
@@ -2285,7 +2299,7 @@ module Samagotchi
|
|
|
2285
2299
|
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
2286
2300
|
# turn's note stays: it says why that turn ended.
|
|
2287
2301
|
if result.empty_answer?
|
|
2288
|
-
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
|
|
2302
|
+
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
|
|
2289
2303
|
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
2290
2304
|
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
2291
2305
|
conversation << note if conversation
|
|
@@ -2482,6 +2496,15 @@ module Samagotchi
|
|
|
2482
2496
|
nil
|
|
2483
2497
|
end
|
|
2484
2498
|
|
|
2499
|
+
# The host refused a sampling field (the empty-answer retry's
|
|
2500
|
+
# temperature on a model that takes none) and the chat loop asked again
|
|
2501
|
+
# without them, as it will for the model from now on: a log line.
|
|
2502
|
+
def sampling_refused(event)
|
|
2503
|
+
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
2504
|
+
rescue StandardError
|
|
2505
|
+
nil
|
|
2506
|
+
end
|
|
2507
|
+
|
|
2485
2508
|
# The host refused the model's reasoning sent back (reasoning_history)
|
|
2486
2509
|
# and the chat loop asked the step again without it, as it will from
|
|
2487
2510
|
# now on: logged, and said once per session and host.
|
|
@@ -2549,7 +2572,7 @@ module Samagotchi
|
|
|
2549
2572
|
context_status: result.context_status&.dup
|
|
2550
2573
|
}
|
|
2551
2574
|
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
2552
|
-
summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
|
|
2575
|
+
summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
|
|
2553
2576
|
summary
|
|
2554
2577
|
end
|
|
2555
2578
|
|
|
@@ -2652,7 +2675,8 @@ module Samagotchi
|
|
|
2652
2675
|
init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
|
|
2653
2676
|
add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
|
|
2654
2677
|
quiet: quiet, timeout: timeout, failed: failed, &block)
|
|
2655
|
-
}
|
|
2678
|
+
},
|
|
2679
|
+
toolkits: @plugin_kits
|
|
2656
2680
|
)
|
|
2657
2681
|
# What plugins show while they load (an MCP server that didn't
|
|
2658
2682
|
# start) waits for the first turn, beside the load warnings: no UI
|
|
@@ -2675,6 +2699,101 @@ module Samagotchi
|
|
|
2675
2699
|
end
|
|
2676
2700
|
public :apply_staged_tools!
|
|
2677
2701
|
|
|
2702
|
+
# The kits a session's toolkit resolves against: the built-ins, config's
|
|
2703
|
+
# toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
|
|
2704
|
+
# process, like the plugins': an edit to toolkit.kits never changes a
|
|
2705
|
+
# live session's tools mid-session (a new worker reads it).
|
|
2706
|
+
# @param errors [Array<String>] gets a line per config kit left out
|
|
2707
|
+
# @return [Hash{String => Toolkit::Kit}]
|
|
2708
|
+
def toolkit_kits(errors = [])
|
|
2709
|
+
@config_kits = Toolkit.config_kits unless defined?(@config_kits)
|
|
2710
|
+
Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
|
|
2711
|
+
end
|
|
2712
|
+
|
|
2713
|
+
# The session's toolkit resolved (Toolkit::Resolved): what its next
|
|
2714
|
+
# turn declares. A plain full toolkit is nil, the Registry's "every tool".
|
|
2715
|
+
def resolved_toolkit(session, errors = [])
|
|
2716
|
+
resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
|
|
2717
|
+
resolved.everything? ? nil : resolved
|
|
2718
|
+
end
|
|
2719
|
+
|
|
2720
|
+
# The tools this session registered (Tools::Registry), declared or not.
|
|
2721
|
+
attr_reader :tools
|
|
2722
|
+
|
|
2723
|
+
# +toolkit+ (the session's by default) as its next turn would declare
|
|
2724
|
+
# it (/toolkit, the UIs' chip).
|
|
2725
|
+
# @return [Toolkit::Explained]
|
|
2726
|
+
def toolkit_explained(toolkit = nil)
|
|
2727
|
+
toolkit ||= Toolkit.of(@session&.toolkit)
|
|
2728
|
+
resolved = toolkit.resolve(toolkit_kits)
|
|
2729
|
+
filter = resolved.everything? ? nil : resolved
|
|
2730
|
+
Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: @tools.entries(toolkit: filter),
|
|
2731
|
+
left_out: @tools.left_out_names(toolkit: filter), pending: filter != @tools.toolkit)
|
|
2732
|
+
end
|
|
2733
|
+
|
|
2734
|
+
# The session's toolkit for the UIs (the snapshot, command_ran): nil on
|
|
2735
|
+
# full, or with no session.
|
|
2736
|
+
def toolkit_summary
|
|
2737
|
+
@session && toolkit_explained.summary
|
|
2738
|
+
end
|
|
2739
|
+
public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
|
|
2740
|
+
|
|
2741
|
+
# A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
|
|
2742
|
+
# when it has no model reply yet and no turn runs (D3: no confirm from
|
|
2743
|
+
# plugins), and tell the user as /toolkit would; it applies at the next
|
|
2744
|
+
# turn's start.
|
|
2745
|
+
# @return [Boolean] whether it was staged
|
|
2746
|
+
# @raise [ArgumentError] an unknown toolkit
|
|
2747
|
+
def use_toolkit(name)
|
|
2748
|
+
raise ArgumentError, "no toolkit named #{name}" unless toolkit_kits.key?(name)
|
|
2749
|
+
return false if @session.nil? || turn_running? || history?
|
|
2750
|
+
|
|
2751
|
+
toolkit = Toolkit.new(name: name)
|
|
2752
|
+
return true if @session.toolkit == toolkit
|
|
2753
|
+
|
|
2754
|
+
@session.toolkit = toolkit
|
|
2755
|
+
@session.save(state_dir: session_state_dir)
|
|
2756
|
+
hook_notify("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: toolkit_summary })
|
|
2757
|
+
true
|
|
2758
|
+
end
|
|
2759
|
+
public :use_toolkit
|
|
2760
|
+
|
|
2761
|
+
# Resolve the session's toolkit into the Registry, on the turn thread at
|
|
2762
|
+
# a turn's start (before its prompt is built), and build the prompts
|
|
2763
|
+
# again when what it declares changed. A name that doesn't resolve (a
|
|
2764
|
+
# typo, an uninstalled bundle) declares everything for now and says so
|
|
2765
|
+
# once; the saved toolkit stays as it is.
|
|
2766
|
+
# @return [Boolean] whether the declared tools changed
|
|
2767
|
+
def apply_toolkit!(session, on_event = nil)
|
|
2768
|
+
errors = []
|
|
2769
|
+
resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
|
|
2770
|
+
warn_toolkit(session, resolved, errors, on_event)
|
|
2771
|
+
resolved = nil if resolved.everything?
|
|
2772
|
+
return false if resolved == @tools.toolkit
|
|
2773
|
+
|
|
2774
|
+
@tools.toolkit = resolved
|
|
2775
|
+
@prompt_builder&.reset!
|
|
2776
|
+
true
|
|
2777
|
+
end
|
|
2778
|
+
private :apply_toolkit!
|
|
2779
|
+
|
|
2780
|
+
# The toolkit's load warnings, each once per process: a name that
|
|
2781
|
+
# didn't resolve, config kits left out.
|
|
2782
|
+
def warn_toolkit(session, resolved, errors, on_event)
|
|
2783
|
+
lines = errors.dup
|
|
2784
|
+
if resolved.fallback
|
|
2785
|
+
lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
|
|
2786
|
+
"until it resolves"
|
|
2787
|
+
end
|
|
2788
|
+
@toolkit_warned ||= []
|
|
2789
|
+
(lines - @toolkit_warned).each do |line|
|
|
2790
|
+
@toolkit_warned << line
|
|
2791
|
+
Log.warn(:config, "toolkit_unresolved", message: line)
|
|
2792
|
+
emit_event(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
|
|
2793
|
+
end
|
|
2794
|
+
end
|
|
2795
|
+
private :warn_toolkit
|
|
2796
|
+
|
|
2678
2797
|
# The tools changed (a plugin's chi.tools_changed!): the system prompts,
|
|
2679
2798
|
# which declare them, are built again on the next turn.
|
|
2680
2799
|
def tools_changed!
|
|
@@ -2701,6 +2820,9 @@ module Samagotchi
|
|
|
2701
2820
|
steer: ->(text, label) { steer(text, source: hook_source(label)) },
|
|
2702
2821
|
stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
|
|
2703
2822
|
stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
|
|
2823
|
+
toolkit: -> { @session && Toolkit.of(@session.toolkit).name },
|
|
2824
|
+
declared_tools: -> { toolkit_explained.declared.map(&:name) },
|
|
2825
|
+
use_toolkit: ->(name) { use_toolkit(name) },
|
|
2704
2826
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
|
|
2705
2827
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2706
2828
|
},
|
|
@@ -2734,19 +2856,36 @@ module Samagotchi
|
|
|
2734
2856
|
|
|
2735
2857
|
# ctx.ask_model's request: the session's current model on its host, as
|
|
2736
2858
|
# a turn resolves them (a /model switch counts), through its own
|
|
2737
|
-
# IdleClient, so it shares nothing with the turn's backend.
|
|
2859
|
+
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
2860
|
+
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
2861
|
+
# refused thinking-off, holds for the next request); the session's id
|
|
2862
|
+
# goes with the request.
|
|
2738
2863
|
# @return [String] the answer
|
|
2739
2864
|
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
|
|
2740
2865
|
target = session_model_recap_target
|
|
2741
|
-
client =
|
|
2866
|
+
client = side_model_client(target, timeout)
|
|
2742
2867
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2743
|
-
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2868
|
+
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
|
|
2744
2869
|
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
2745
2870
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
2746
2871
|
answer.text
|
|
2747
2872
|
end
|
|
2748
2873
|
private :ask_side_model
|
|
2749
2874
|
|
|
2875
|
+
# The IdleClient for +target+ at +timeout+: the last one when both are
|
|
2876
|
+
# the same, else a new one.
|
|
2877
|
+
def side_model_client(target, timeout)
|
|
2878
|
+
@side_client_mutex.synchronize do
|
|
2879
|
+
key = [target, timeout]
|
|
2880
|
+
unless @side_client && @side_client_key == key
|
|
2881
|
+
@side_client = IdleClient.for(target, timeout: timeout)
|
|
2882
|
+
@side_client_key = key
|
|
2883
|
+
end
|
|
2884
|
+
@side_client
|
|
2885
|
+
end
|
|
2886
|
+
end
|
|
2887
|
+
private :side_model_client
|
|
2888
|
+
|
|
2750
2889
|
# The session's current model as a recap target: its host's OpenAI API
|
|
2751
2890
|
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
2752
2891
|
# and bare model name, as a turn resolves them.
|
|
@@ -2813,6 +2952,7 @@ module Samagotchi
|
|
|
2813
2952
|
# reasoning sent back: a notice, not an event of its own.
|
|
2814
2953
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2815
2954
|
next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
2955
|
+
next sampling_refused(event) if event[:type] == :sampling_refused
|
|
2816
2956
|
|
|
2817
2957
|
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2818
2958
|
emit_event(on_event, event)
|
|
@@ -14,6 +14,12 @@ module Samagotchi
|
|
|
14
14
|
# A thinking delta this recent means the model is still thinking.
|
|
15
15
|
FRESH_SECONDS = 2.0
|
|
16
16
|
|
|
17
|
+
# What a page joining mid-generation needs for its delivery line
|
|
18
|
+
# (Bridge#snapshot's current_turn.generation): whether it has streamed
|
|
19
|
+
# only thinking so far (thinking, no text, no tool call; freshness
|
|
20
|
+
# aside) and its thinking's #age (nil before any thinking).
|
|
21
|
+
Snapshot = Data.define(:thinking_only, :age)
|
|
22
|
+
|
|
17
23
|
# @param clock [#call] monotonic seconds
|
|
18
24
|
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
25
|
@clock = clock
|
|
@@ -71,6 +77,17 @@ module Samagotchi
|
|
|
71
77
|
@mutex.synchronize { @running && @thinking_since ? @clock.call - @thinking_since : nil }
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
# The running generation as a Snapshot, its age rounded to a tenth;
|
|
81
|
+
# nil with none running.
|
|
82
|
+
def snapshot
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
next nil unless @running
|
|
85
|
+
|
|
86
|
+
age = @thinking_since && (@clock.call - @thinking_since).round(1)
|
|
87
|
+
Snapshot.new(thinking_only: !@thinking_since.nil? && !@text && !@tool_call, age: age)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
74
91
|
private
|
|
75
92
|
|
|
76
93
|
def clear_seen
|
|
@@ -4,6 +4,7 @@ require "uri"
|
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "host_model"
|
|
6
6
|
require_relative "llm_context_strategy"
|
|
7
|
+
require_relative "llm/dialect"
|
|
7
8
|
require_relative "reasoning_history"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
@@ -15,12 +16,20 @@ module Samagotchi
|
|
|
15
16
|
#
|
|
16
17
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
17
18
|
# url: when given); url: the configured url, nil when host/port; transport,
|
|
18
|
-
# api: symbols, nil when unset; the
|
|
19
|
-
#
|
|
20
|
-
|
|
19
|
+
# api: symbols, nil when unset; auth: the header the API key goes in
|
|
20
|
+
# (LLM::ApiKey::SCHEMES), nil when unset; dialect: an api: anthropic
|
|
21
|
+
# host's LLM::Dialect name, nil when unset; the rest as
|
|
22
|
+
# HostRegistry::HostEntry describes them, nil when unset; models:
|
|
23
|
+
# {downcased id => HostModel}.
|
|
24
|
+
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :auth, :dialect, :profile,
|
|
21
25
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
22
26
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
23
27
|
:models) do
|
|
28
|
+
# An api: anthropic host without url/host talks to Anthropic's own API.
|
|
29
|
+
ANTHROPIC_URL = "https://api.anthropic.com"
|
|
30
|
+
# auth: as written => the LLM::ApiKey scheme.
|
|
31
|
+
AUTH_SCHEMES = { "bearer" => :bearer, "x-api-key" => :x_api_key }.freeze
|
|
32
|
+
|
|
24
33
|
# Every field but name may be left out: nil, models {}.
|
|
25
34
|
def initialize(**fields)
|
|
26
35
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
@@ -69,8 +78,17 @@ module Samagotchi
|
|
|
69
78
|
return invalid(name, "api_key_env must be an environment variable name")
|
|
70
79
|
end
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
auth = value.call(:auth).to_s.strip.downcase
|
|
82
|
+
fields[:auth] = AUTH_SCHEMES[auth] unless auth.empty?
|
|
83
|
+
return invalid(name, "auth must be one of #{AUTH_SCHEMES.keys.join(", ")}") if !auth.empty? && fields[:auth].nil?
|
|
84
|
+
|
|
85
|
+
if fields[:auth] && fields[:api_key_env].nil?
|
|
86
|
+
ConfigFile.warn_once "Warning: #{where}: auth has no effect without api_key_env (no key is sent)"
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
|
|
90
|
+
location = parse_location(name, value, api: kinds[:api]) or return nil
|
|
91
|
+
fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kinds[:api])
|
|
74
92
|
new(name: name.downcase, **location, **kinds, **fields)
|
|
75
93
|
rescue StandardError => e
|
|
76
94
|
# One entry that trips a reader is dropped on its own, and said so;
|
|
@@ -99,11 +117,13 @@ module Samagotchi
|
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
# host/port/scheme/url from url:, or from host: and port:; nil (warned)
|
|
102
|
-
# when they are wrong.
|
|
103
|
-
|
|
120
|
+
# when they are wrong. An api: anthropic entry with neither has
|
|
121
|
+
# ANTHROPIC_URL.
|
|
122
|
+
def self.parse_location(name, value, api: nil)
|
|
104
123
|
host = value.call(:host)
|
|
105
124
|
port = value.call(:port)
|
|
106
125
|
url = value.call(:url).to_s.strip
|
|
126
|
+
url = ANTHROPIC_URL if url.empty? && api == :anthropic && host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
107
127
|
scheme = "http"
|
|
108
128
|
unless url.empty?
|
|
109
129
|
return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
@@ -168,6 +188,7 @@ module Samagotchi
|
|
|
168
188
|
def to_env_h
|
|
169
189
|
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
170
190
|
location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
|
|
191
|
+
"auth" => (AUTH_SCHEMES.key(auth) if auth), "dialect" => dialect,
|
|
171
192
|
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
172
193
|
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
173
194
|
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
@@ -6,7 +6,8 @@ require_relative "log"
|
|
|
6
6
|
require_relative "model_ref"
|
|
7
7
|
require_relative "client"
|
|
8
8
|
require_relative "model_list_store"
|
|
9
|
-
require_relative "llm/
|
|
9
|
+
require_relative "llm/adapters"
|
|
10
|
+
require_relative "llm/dialect"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# HostRegistry manages multiple model hosts (llama.cpp / mlx / oMLX) and
|
|
@@ -30,6 +31,8 @@ module Samagotchi
|
|
|
30
31
|
|
|
31
32
|
# url: the configured url, when the entry has one (host, port and scheme
|
|
32
33
|
# come from it); api_key_env: the variable holding the host's API key;
|
|
34
|
+
# auth: the header it goes in (LLM::ApiKey::SCHEMES), nil for the api's own default;
|
|
35
|
+
# dialect: an api: anthropic host's LLM::Dialect name (#dialect_profile), nil when unset;
|
|
33
36
|
# profile: the configured prompt profile name, if any;
|
|
34
37
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
35
38
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
@@ -43,13 +46,13 @@ module Samagotchi
|
|
|
43
46
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
44
47
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
45
48
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
46
|
-
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :
|
|
47
|
-
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
49
|
+
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
|
|
50
|
+
:profile, :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
48
51
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
49
52
|
:models, keyword_init: true) do
|
|
50
|
-
# Talks
|
|
51
|
-
# raw-prompt loop.
|
|
52
|
-
def chat? = api
|
|
53
|
+
# Talks a chat API through an adapter (LLM::Adapters: the chat loop);
|
|
54
|
+
# nil and raw apis use the raw-prompt loop.
|
|
55
|
+
def chat? = LLM::Adapters.chat_api?(api)
|
|
53
56
|
|
|
54
57
|
# The server root, e.g. for llama.cpp's own endpoints and recap.
|
|
55
58
|
def root_url = "#{scheme || "http"}://#{host}:#{port}"
|
|
@@ -58,6 +61,25 @@ module Samagotchi
|
|
|
58
61
|
# configured, else the root's /v1.
|
|
59
62
|
def openai_base_url = url || "#{root_url}/v1"
|
|
60
63
|
|
|
64
|
+
# The API base the host's chat adapter talks to (LLM::Adapters): for
|
|
65
|
+
# api: openai the OpenAI-compatible base; for api: anthropic the API
|
|
66
|
+
# root without /v1 (as ANTHROPIC_BASE_URL; a url that ends in /v1
|
|
67
|
+
# loses it, so requests never go to /v1/v1).
|
|
68
|
+
def api_base_url
|
|
69
|
+
return openai_base_url unless api&.to_sym == :anthropic
|
|
70
|
+
|
|
71
|
+
(url || root_url).chomp("/").delete_suffix("/v1")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The header the host's key goes in: auth: when set, else the api's
|
|
75
|
+
# own (for api: anthropic its dialect's, x-api-key unless the dialect
|
|
76
|
+
# says otherwise; Bearer for every other api).
|
|
77
|
+
def auth_scheme = auth || (api&.to_sym == :anthropic ? dialect_profile.auth : :bearer)
|
|
78
|
+
|
|
79
|
+
# The LLM::Dialect an api: anthropic host's requests follow (STANDARD
|
|
80
|
+
# when it names none).
|
|
81
|
+
def dialect_profile = LLM::Dialect.for(dialect)
|
|
82
|
+
|
|
61
83
|
# A provider on the network rather than a local server: hosts.<name>.remote
|
|
62
84
|
# when set, else by its address (HostRegistry.remote_address?). An API
|
|
63
85
|
# key doesn't decide: a llama.cpp on the LAN can have one. A remote
|
|
@@ -106,7 +128,7 @@ module Samagotchi
|
|
|
106
128
|
entry = HostEntry.new(**fields)
|
|
107
129
|
entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
|
|
108
130
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
109
|
-
api_key_env: entry.api_key_env, env: env)
|
|
131
|
+
api_key_env: entry.api_key_env, auth: entry.auth_scheme, env: env)
|
|
110
132
|
@entries[entry.name] = entry
|
|
111
133
|
end
|
|
112
134
|
# Fallback single entry (should already be synthesized by hosts_config, but guard)
|
|
@@ -217,12 +239,13 @@ module Samagotchi
|
|
|
217
239
|
end
|
|
218
240
|
|
|
219
241
|
# The chat adapter for a host (one per host, so its cached model list
|
|
220
|
-
# serves the context window)
|
|
242
|
+
# serves the context window), as LLM::Adapters builds it for the host's
|
|
243
|
+
# api. Only chat hosts use it for turns.
|
|
221
244
|
# @return [LLM::OpenAIChat]
|
|
222
245
|
def adapter_for(entry)
|
|
223
246
|
@mutex.synchronize do
|
|
224
|
-
@adapters[entry.name] ||= LLM::
|
|
225
|
-
|
|
247
|
+
@adapters[entry.name] ||= LLM::Adapters.for(entry, models_ttl: entry.models_ttl,
|
|
248
|
+
first_token_timeout: entry.first_token_limit)
|
|
226
249
|
end
|
|
227
250
|
end
|
|
228
251
|
|
|
@@ -390,7 +413,7 @@ module Samagotchi
|
|
|
390
413
|
# the registry).
|
|
391
414
|
def inspect
|
|
392
415
|
hosts = @entries.values.map do |entry|
|
|
393
|
-
parts = [entry.
|
|
416
|
+
parts = [entry.api_base_url]
|
|
394
417
|
parts << "key=#{entry.api_key_env}" if entry.api_key_env
|
|
395
418
|
parts << "models=#{entry.models.values.map(&:id).join(",")}" unless entry.models.empty?
|
|
396
419
|
"#{entry.name}(#{parts.join(" ")})"
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "llm/
|
|
5
|
-
require_relative "thinking"
|
|
4
|
+
require_relative "llm/adapters"
|
|
6
5
|
require_relative "log"
|
|
7
6
|
|
|
8
7
|
module Samagotchi
|
|
9
8
|
# Standalone summarizer for the idle session-recap feature.
|
|
10
9
|
#
|
|
11
10
|
# Asks an OpenAI-compatible /chat/completions endpoint (the local llama.cpp,
|
|
12
|
-
# or a recap host's) through its own
|
|
11
|
+
# or a recap host's) through its own adapter (LLM::Adapters.build, by the
|
|
12
|
+
# target's api; OpenAIChat today): one plain request, no
|
|
13
13
|
# tools, no retries, its own timeout. It is NEVER wired into the Engine's
|
|
14
14
|
# kernel/backend, so it shares no engine concurrency. The Engine's idle
|
|
15
15
|
# detector only snapshots session.messages and calls #summarize; this object
|
|
@@ -38,6 +38,11 @@ module Samagotchi
|
|
|
38
38
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
39
39
|
# Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
|
|
40
40
|
MAX_TOKENS = 512
|
|
41
|
+
# The least max_tokens a side request gets when its model refused
|
|
42
|
+
# thinking off and the adapter asks for a little thinking instead (an
|
|
43
|
+
# always-thinking model on a Messages host): the thinking counts in the
|
|
44
|
+
# limit, so 512 could all go to it and leave no answer.
|
|
45
|
+
THINKING_MAX_TOKENS = 4096
|
|
41
46
|
|
|
42
47
|
# @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
|
|
43
48
|
# @param api_key_env [String, nil] the variable holding the host's key
|
|
@@ -46,19 +51,26 @@ module Samagotchi
|
|
|
46
51
|
# summarize thread can't outlive the recap attempt by minutes.
|
|
47
52
|
# @param purpose [String] what the requests are for, in the HTTP log
|
|
48
53
|
# lines and error messages ("recap", "broadcast")
|
|
49
|
-
|
|
54
|
+
# @param api [Symbol] the wire to talk (LLM::Adapters; IdleTarget#api)
|
|
55
|
+
# @param auth [Symbol, nil] the header the key goes in (IdleTarget#auth)
|
|
56
|
+
# @param dialect [String, nil] a Messages host's dialect (IdleTarget#dialect)
|
|
57
|
+
def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap",
|
|
58
|
+
api: :openai, auth: nil, dialect: nil)
|
|
50
59
|
@model = model
|
|
51
60
|
# A recap is best-effort: one short attempt, no retries. The idle job
|
|
52
|
-
# tries again after the next activity, never on its own.
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
# tries again after the next activity, never on its own. Only the
|
|
62
|
+
# Messages adapter takes a dialect.
|
|
63
|
+
@chat = LLM::Adapters.build(api: api, base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
|
|
64
|
+
auth: auth, stream: false, retries: false, timeout: timeout, env: env, purpose: purpose,
|
|
65
|
+
**(dialect ? { dialect: dialect } : {}))
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
# A client for +target+ (an IdleTarget: a recap's, ctx.ask_model's, a
|
|
58
|
-
# broadcast's triage model).
|
|
69
|
+
# broadcast's triage model), on its api's adapter.
|
|
59
70
|
# @return [IdleClient]
|
|
60
71
|
def self.for(target, **)
|
|
61
|
-
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env,
|
|
72
|
+
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, api: target.api,
|
|
73
|
+
auth: target.auth, dialect: target.dialect, **)
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
THINK_RE = /<\|think\|.*?\|think\|>/m
|
|
@@ -86,11 +98,13 @@ module Samagotchi
|
|
|
86
98
|
# when there is nothing to summarize. Any failure raises SummarizeError
|
|
87
99
|
# (the caller isolates it).
|
|
88
100
|
# @return [Summary, nil]
|
|
89
|
-
|
|
101
|
+
# +session_id+: the session it is about, sent as the turns send it
|
|
102
|
+
# (Session-Id, a dialect's affinity header), nil for none.
|
|
103
|
+
def summarize(prompt, session_id: nil)
|
|
90
104
|
messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
|
|
91
105
|
return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
|
|
92
106
|
|
|
93
|
-
response = generate(messages)
|
|
107
|
+
response = generate(messages, session_id: session_id)
|
|
94
108
|
cleaned = response.text.to_s.strip
|
|
95
109
|
cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
|
|
96
110
|
rescue SummarizeError
|
|
@@ -106,14 +120,15 @@ module Samagotchi
|
|
|
106
120
|
# @param kind [String] what it is for, in the usage log line
|
|
107
121
|
# @param options [Hash] more request body fields (logprobs: true,
|
|
108
122
|
# top_logprobs: 5)
|
|
123
|
+
# @param session_id [String, nil] the session it is about (#summarize)
|
|
109
124
|
# @return [Summary] the answer ("" when the model said nothing)
|
|
110
125
|
# @raise [SummarizeError] any failure but a cancel; its #cause is an
|
|
111
126
|
# LLM::BadRequest when the host refused the request (+options+ it
|
|
112
127
|
# doesn't take, say)
|
|
113
128
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
114
|
-
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {})
|
|
129
|
+
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil)
|
|
115
130
|
response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
116
|
-
kind: kind, options: options)
|
|
131
|
+
kind: kind, options: options, session_id: session_id)
|
|
117
132
|
Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
|
|
118
133
|
rescue SummarizeError, LLM::RequestCancelled
|
|
119
134
|
raise
|
|
@@ -130,17 +145,15 @@ module Samagotchi
|
|
|
130
145
|
# the reply has neither content nor reasoning_content. Cut off by
|
|
131
146
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
132
147
|
# or all of it, with "…".
|
|
133
|
-
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {}
|
|
148
|
+
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {},
|
|
149
|
+
session_id: nil)
|
|
134
150
|
response = begin
|
|
135
|
-
request(messages, max_tokens, cancel_controller, options)
|
|
151
|
+
request(messages, max_tokens, cancel_controller, options, session_id)
|
|
136
152
|
rescue LLM::BadRequest => e
|
|
137
|
-
raise unless e.reasoning_refused? &&
|
|
153
|
+
raise unless e.reasoning_refused? && next_thinking_step
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@thinking_refused = true
|
|
142
|
-
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
143
|
-
request(messages, max_tokens, cancel_controller, options)
|
|
155
|
+
Log.info(:recap, "thinking_refused", model: @model, step: @thinking_step, detail: e.detail)
|
|
156
|
+
retry
|
|
144
157
|
end
|
|
145
158
|
log_usage(response, kind)
|
|
146
159
|
content = response.text
|
|
@@ -173,16 +186,40 @@ module Samagotchi
|
|
|
173
186
|
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
|
|
174
187
|
end
|
|
175
188
|
|
|
176
|
-
|
|
189
|
+
# After a host refused this step's thinking fields: the next step, kept
|
|
190
|
+
# from now on (true), or false when none is left.
|
|
191
|
+
# 1. The host won't turn thinking off (gpt-oss, an always-thinking
|
|
192
|
+
# model): the adapter's fields for that (none on the OpenAI API, low
|
|
193
|
+
# effort on Messages).
|
|
194
|
+
# 2. Those refused too (a Messages model that takes no thinking fields
|
|
195
|
+
# at all): none.
|
|
196
|
+
def next_thinking_step
|
|
197
|
+
step = @thinking_step.to_i
|
|
198
|
+
return false if step >= 2 || (step == 1 && @chat.thinking_off_refused_fields.empty?)
|
|
199
|
+
|
|
200
|
+
@thinking_step = step + 1
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def request(messages, max_tokens, cancel_controller, options, session_id)
|
|
205
|
+
fields = thinking_fields
|
|
206
|
+
max_tokens = [max_tokens, THINKING_MAX_TOKENS].max if @thinking_step == 1 && !fields.empty?
|
|
177
207
|
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
178
|
-
options: { max_tokens: max_tokens, **
|
|
208
|
+
options: { max_tokens: max_tokens, **fields, **options }, session_id: session_id)
|
|
179
209
|
end
|
|
180
210
|
|
|
181
211
|
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
182
212
|
# the model's level (a reasoning model otherwise spends the budget
|
|
183
|
-
# thinking and the recap stops mid-sentence).
|
|
213
|
+
# thinking and the recap stops mid-sentence). Once the host refused
|
|
214
|
+
# that, the adapter's fields for a model that always thinks (with a
|
|
215
|
+
# limit of at least THINKING_MAX_TOKENS when there are any), then none
|
|
216
|
+
# (#next_thinking_step).
|
|
184
217
|
def thinking_fields
|
|
185
|
-
@
|
|
218
|
+
case @thinking_step.to_i
|
|
219
|
+
when 0 then @chat.thinking_fields(:off)
|
|
220
|
+
when 1 then @chat.thinking_off_refused_fields
|
|
221
|
+
else {}
|
|
222
|
+
end
|
|
186
223
|
end
|
|
187
224
|
end
|
|
188
225
|
end
|