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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -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 hosts.
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
- def hook_notify(text, level, hook, fallback_for: nil)
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 = IdleClient.for(target, timeout: timeout)
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 rest as HostRegistry::HostEntry
19
- # describes them, nil when unset; models: {downcased id => HostModel}.
20
- HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
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
- location = parse_location(name, value) or return nil
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
- def self.parse_location(name, value)
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/openai_chat"
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, :profile,
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 the OpenAI chat API (the chat loop); nil and raw apis use the
51
- # raw-prompt loop.
52
- def chat? = api == :openai
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). Only chat hosts use it for turns.
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::OpenAIChat.for(entry, models_ttl: entry.models_ttl,
225
- first_token_timeout: entry.first_token_limit)
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.openai_base_url]
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/openai_chat"
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 OpenAIChat: one plain request, no
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
- def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap")
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
- @chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
54
- stream: false, retries: false, timeout: timeout, env: env, purpose: purpose)
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
- def summarize(prompt)
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? && !@thinking_refused
153
+ raise unless e.reasoning_refused? && next_thinking_step
138
154
 
139
- # The host won't turn thinking off (gpt-oss): ask again without the
140
- # fields, and leave them out from now on.
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
- def request(messages, max_tokens, cancel_controller, options = {})
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, **thinking_fields, **options })
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). None once the host refused them.
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
- @thinking_refused ? {} : Thinking.chat_fields(:off)
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