samagotchi 0.63.0 → 0.65.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delivery"
4
+
5
+ module Samagotchi
6
+ # Where a line typed at a terminal's prompt goes, for both terminals (the
7
+ # REPL's TerminalUI#steer_line / #run_input_line, the attached
8
+ # AttachedLoop#submit). It only classifies; each UI keeps what it does
9
+ # with the answer (the REPL's mid-turn command policy and image lines, the
10
+ # attached TUI's continue offer and question). The web's composer.js
11
+ # routeComposerLine decides the same; spec/fixtures/composer_routes.json
12
+ # holds the cases both answer (docs/testing.md).
13
+ #
14
+ # In order:
15
+ # 1. /cut TEXT, /queue TEXT (.steer_choice), before every command check:
16
+ # the bare word is :usage. While a turn runs TEXT is a :deliver with
17
+ # its delivery (Delivery::CUT, Delivery::QUEUE); whether the worker
18
+ # can queue is the UI's to check. With none running TEXT is routed
19
+ # on as a line (step 2), its own /cut not read again.
20
+ # 2. A line: :empty; a terminal's own word (:local, the registry's
21
+ # lookup_local id: :exit, :archive, :detach, :stats, :recap…); a
22
+ # session command (:command); a typo of one (:typo, the hint in
23
+ # +reply+); else :prompt. A line with images (+images+) is a message:
24
+ # no command or typo check.
25
+ module ComposerRoute
26
+ CUT_COMMAND = "/cut"
27
+ QUEUE_COMMAND = "/queue"
28
+ # The word, then any whitespace and the text (as the registry's
29
+ # entries match them, so a line Tab or /help knows is never a prompt).
30
+ STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
31
+
32
+ KINDS = %i[empty usage deliver local command typo prompt].freeze
33
+
34
+ # @!attribute kind [Symbol] one of KINDS
35
+ # @!attribute text [String, nil] the line stripped: a /cut's or
36
+ # /queue's own text, not the word
37
+ # @!attribute delivery [String, nil] a :deliver's Delivery value
38
+ # @!attribute reply [String, nil] a :typo's hint
39
+ # @!attribute local [Symbol, nil] a :local's registry id
40
+ # @!attribute choice [Symbol, nil] :cut or :queue when the line was
41
+ # typed with that word (an idle one's text routed on keeps it)
42
+ Route = Data.define(:kind, :text, :delivery, :reply, :local, :choice) do
43
+ def initialize(kind:, text: nil, delivery: nil, reply: nil, local: nil, choice: nil)
44
+ super
45
+ end
46
+ end
47
+
48
+ # @param line [String, nil] as typed
49
+ # @param running [Boolean] a turn runs
50
+ # @param registry [Commands::Registry] the session's commands
51
+ # @param images [Integer] the attachments not in the text (the web's
52
+ # chips); a terminal's @path images are its own to handle (0)
53
+ # @return [Route]
54
+ def self.call(line, running:, registry:, images: 0)
55
+ text = line.to_s.strip
56
+ choice, message = steer_choice(text)
57
+ return line_route(text, registry: registry, images: images) unless choice
58
+ return Route.new(kind: :usage, choice: choice) if message.empty?
59
+ return line_route(message, registry: registry, images: images).with(choice: choice) unless running
60
+
61
+ Route.new(kind: :deliver, text: message, delivery: choice == :cut ? Delivery::CUT : Delivery::QUEUE, choice: choice)
62
+ end
63
+
64
+ # The choice a line carries, as [:cut, "TEXT"] or [:queue, "TEXT"]; nil
65
+ # for any other line. The text is "" when the line named the choice
66
+ # without one.
67
+ # @param line [String, nil]
68
+ # @return [Array(Symbol, String), nil]
69
+ def self.steer_choice(line)
70
+ match = STEER_CHOICE.match(line.to_s.strip) or return nil
71
+
72
+ [match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
73
+ end
74
+
75
+ def self.line_route(text, registry:, images:)
76
+ return Route.new(kind: :empty, text: text) if text.empty?
77
+
78
+ if images.zero?
79
+ local = registry.lookup_local(text)&.id
80
+ return Route.new(kind: :local, text: text, local: local) if local
81
+ return Route.new(kind: :command, text: text) if registry.command?(text)
82
+
83
+ hint = registry.unknown_command_hint(text)
84
+ return Route.new(kind: :typo, text: text, reply: hint) if hint
85
+ end
86
+ Route.new(kind: :prompt, text: text)
87
+ end
88
+ private_class_method :line_route
89
+ end
90
+ end
@@ -279,7 +279,7 @@ module Samagotchi
279
279
  # Maps of named entries and the keys an entry may hold
280
280
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
281
281
  MAP_ENTRY_KEYS = {
282
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
282
+ "hosts" => %w[host port url transport api api_key_env auth dialect profile first_token_timeout vision sampling thinking
283
283
  reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
284
284
  "models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
285
285
  llm_context_budget_tokens].freeze
@@ -851,13 +851,14 @@ module Samagotchi
851
851
 
852
852
  HOSTS_KEY = "hosts"
853
853
  VALID_TRANSPORTS_FOR_CONFIG = %w[llama_cpp mlx omlx].freeze
854
- # How chi talks to a host: a raw-prompt api (also the Client transport) or
855
- # openai, the chat loop against <host>/v1.
856
- VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai]).freeze
854
+ # How chi talks to a host: a raw-prompt api (also the Client transport),
855
+ # or a chat api the chat loop talks (LLM::Adapters): openai (Chat
856
+ # Completions against <host>/v1) or anthropic (Messages).
857
+ VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai anthropic]).freeze
857
858
  HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
858
859
  ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
859
860
 
860
- # config.yml's hosts: (or a worker's SAMAGOTCHI_HOSTS_JSON) by lowercased
861
+ # config.yml's hosts: (or a SAMAGOTCHI_HOSTS_JSON override) by lowercased
861
862
  # name; an entry that is disabled or invalid (warned) is left out. No
862
863
  # entry left: "default", from server.host/port/transport.
863
864
  # @return [Hash{String => HostConfig}]
@@ -914,7 +915,7 @@ module Samagotchi
914
915
 
915
916
  # The names (lowercased) of the hosts entries with enabled: false, which
916
917
  # hosts_config leaves out. Read from the same source: config.yml's
917
- # hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
918
+ # hosts:, or a SAMAGOTCHI_HOSTS_JSON override.
918
919
  # @return [Array<String>]
919
920
  def disabled_host_names(env: ENV, path: global_path(env: env))
920
921
  raw = hosts_source(read_yaml(env: env, path: path), env)
@@ -926,7 +927,8 @@ module Samagotchi
926
927
  end
927
928
 
928
929
  # config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
929
- # a worker inherits from its parent.
930
+ # set on this process (specs; SessionManager.spawn_options unsets it for
931
+ # a worker, which reads the file).
930
932
  def hosts_source(data, env)
931
933
  raw = data[HOSTS_KEY] if data.is_a?(Hash)
932
934
  env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
@@ -1041,6 +1043,8 @@ module Samagotchi
1041
1043
  hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
1042
1044
  end
1043
1045
 
1046
+ # hosts_config as a SAMAGOTCHI_HOSTS_JSON override (workers no longer
1047
+ # get one: they read config.yml).
1044
1048
  def hosts_json_for_env(env: ENV, path: global_path(env: env))
1045
1049
  hosts = hosts_config(env: env, path: path)
1046
1050
  # Only serialize if non-default or explicitly configured hosts:
@@ -85,12 +85,17 @@ module Samagotchi
85
85
  # Called with the event log held, before the command's command_ran. A
86
86
  # Stop runs no turn: last_turn says so (not_continued), so a wait on
87
87
  # it ends (chi answer --option Stop).
88
+ # It carries the offer turn's enqueued_id, so a wait on that turn
89
+ # (ReplyWait's turn:) ends on it too.
88
90
  def announce_resolved(result, command)
89
91
  @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
90
92
  return if result.resume
91
93
 
92
94
  session = @engine.session
93
- session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3)) if session
95
+ return unless session
96
+
97
+ session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3),
98
+ enqueued_id: offer_turn_id)
94
99
  end
95
100
 
96
101
  # After a command's command_ran and save: a /continue that answered the
@@ -117,9 +122,13 @@ module Samagotchi
117
122
  # asks again, one before the turn began too (logged, no :turn_failed).
118
123
  # The answer's text is carried into the turn as it begins; a turn that
119
124
  # never began leaves it to no other.
125
+ # The continue turn is the offer turn's: its origin carries that turn's
126
+ # enqueued_id, so its last_turn, its reply file and its checkpoint mark
127
+ # do too, and a wait on that turn (chi answer) gets its reply.
120
128
  def run_continue_turn(command)
121
129
  offer = @turn_flow.offer
122
130
  resume_from = @turn_flow.resume_from
131
+ enqueued_id = offer_turn_id
123
132
  close("answered")
124
133
  steer = @continue_steer
125
134
  @continue_steer = nil
@@ -131,8 +140,8 @@ module Samagotchi
131
140
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
132
141
  return announce_offer(offer)
133
142
  end
134
- @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
135
- resume_from: resume_from,
143
+ origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
144
+ @run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
136
145
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
137
146
  if error
138
147
  announce_offer(offer)
@@ -156,6 +165,11 @@ module Samagotchi
156
165
 
157
166
  private
158
167
 
168
+ # The enqueued_id of the turn the offer stands for: the exhausted
169
+ # turn's last_turn (a canceled continue turn's carries the same id);
170
+ # nil when it ran no input (a reminder turn, a delegate's first).
171
+ def offer_turn_id = @engine.session&.last_turn&.enqueued_id
172
+
159
173
  def announce_offer(offer = @turn_flow.offer)
160
174
  @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
161
175
  ask
@@ -23,25 +23,30 @@ module Samagotchi
23
23
  # Why a request body's delivery field can't be taken, as {error:,
24
24
  # detail:} for a 400, or nil when it can. The Bridge and the web's
25
25
  # POST /turn both ask this, so they refuse the same values. A missing
26
- # or unknown string is not refused: it is the default (#parse). QUEUE
27
- # is refused until a worker runs it (no worker reads it yet, so it
28
- # would merge at the next step while the ack said queue).
26
+ # or unknown string is not refused: it is the default (#parse).
29
27
  # @param raw [Object] the wire value
30
28
  # @return [Hash, nil]
31
29
  def self.refusal(raw)
32
30
  return nil if raw.nil?
33
31
  return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
34
- return QUEUE_UNAVAILABLE if raw == QUEUE
35
32
 
36
33
  nil
37
34
  end
38
35
 
36
+ # The worker sidecar feature (Bridge::FEATURES) of a worker that runs
37
+ # all three values. One without it (before 0.64) ignores QUEUE, or
38
+ # refuses it with a 400 delivery_unavailable, so a sender checks it
39
+ # before sending a queue (SessionManager.deliver_turn, the attached TUI).
40
+ QUEUE_FEATURE = "delivery"
41
+
42
+ # The refusal for a queue a worker without QUEUE_FEATURE would steer
43
+ # (SessionManager.deliver_turn answers it as a 409). P0-P2 workers
44
+ # answer the same error code for a queue, with their own detail.
39
45
  QUEUE_UNAVAILABLE = {
40
- error: "delivery_unavailable",
41
- detail: "delivery queue isn't available yet; without it the message goes in at the running turn's next step " \
42
- "(or runs as a turn when none runs)"
46
+ "error" => "delivery_unavailable",
47
+ "detail" => "this session's worker predates queueing, so the message would steer the running turn; " \
48
+ "restart it (chi sessions restart ID, or the web's update badge) and send again"
43
49
  }.freeze
44
- private_constant :QUEUE_UNAVAILABLE
45
50
 
46
51
  # Whether it is the one every sender defaulting to a step boundary uses.
47
52
  def self.next_step?(value) = parse(value) == NEXT_STEP
@@ -224,6 +224,7 @@ module Samagotchi
224
224
  # and after a reminder turn), so the idle layer's clock is identical
225
225
  # across UIs.
226
226
  @turn_state.restart_clock!
227
+ @side_client_mutex = Mutex.new
227
228
  @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
228
229
  session_target: -> { session_model_recap_target },
229
230
  session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
@@ -299,7 +300,7 @@ module Samagotchi
299
300
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
300
301
  @hooks.runtime = hook_runtime
301
302
  # The loop follows the effective model's host (its api:): the raw-prompt
302
- # NativeBackend, or the chat backend for openai hosts.
303
+ # NativeBackend, or the chat backend for chat hosts (openai, anthropic).
303
304
  @native_backend = LLM::NativeBackend.new(kernel: @kernel)
304
305
  end
305
306
  private :wire_kernel
@@ -405,6 +406,10 @@ module Samagotchi
405
406
  # for #cut_for_steer.
406
407
  def input_epoch = @steer_cut.input_epoch
407
408
 
409
+ # The running generation's phase (SteerCut#generation_snapshot): what
410
+ # Bridge#snapshot puts in current_turn.generation.
411
+ def generation_snapshot = @steer_cut.generation_snapshot
412
+
408
413
  # Like #steer, for the turn that begins next (a continue turn answered
409
414
  # with a text): it joins that turn at its first boundary. Callable from
410
415
  # any thread; a turn that never begins keeps it for the next one, unless
@@ -1082,7 +1087,7 @@ module Samagotchi
1082
1087
  @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1083
1088
  end
1084
1089
 
1085
- # The effective model is on a chat host (api: openai), whose loop uses
1090
+ # The effective model is on a chat host (api: openai or anthropic), whose loop uses
1086
1091
  # no prompt profile.
1087
1092
  def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
1088
1093
 
@@ -1433,8 +1438,10 @@ module Samagotchi
1433
1438
  elsif client_id == ClientId::REMINDER then "reminder"
1434
1439
  else "client"
1435
1440
  end
1441
+ enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
1436
1442
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1437
1443
  origin: source, exhausted: limit ? true : nil, limit: limit,
1444
+ enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
1438
1445
  **stop_facts(event))
1439
1446
  end
1440
1447
 
@@ -2283,7 +2290,7 @@ module Samagotchi
2283
2290
  # A retry's nudge at the tail goes: this note says it all. An earlier
2284
2291
  # turn's note stays: it says why that turn ended.
2285
2292
  if result.empty_answer?
2286
- note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
2293
+ note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
2287
2294
  conversation&.replace(TurnNote.without_trailing_nudge(conversation))
2288
2295
  saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
2289
2296
  conversation << note if conversation
@@ -2480,6 +2487,15 @@ module Samagotchi
2480
2487
  nil
2481
2488
  end
2482
2489
 
2490
+ # The host refused a sampling field (the empty-answer retry's
2491
+ # temperature on a model that takes none) and the chat loop asked again
2492
+ # without them, as it will for the model from now on: a log line.
2493
+ def sampling_refused(event)
2494
+ Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
2495
+ rescue StandardError
2496
+ nil
2497
+ end
2498
+
2483
2499
  # The host refused the model's reasoning sent back (reasoning_history)
2484
2500
  # and the chat loop asked the step again without it, as it will from
2485
2501
  # now on: logged, and said once per session and host.
@@ -2547,7 +2563,7 @@ module Samagotchi
2547
2563
  context_status: result.context_status&.dup
2548
2564
  }
2549
2565
  # No answer: every UI shows its notice (TurnNote.empty_answer_line).
2550
- summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
2566
+ summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
2551
2567
  summary
2552
2568
  end
2553
2569
 
@@ -2732,19 +2748,36 @@ module Samagotchi
2732
2748
 
2733
2749
  # ctx.ask_model's request: the session's current model on its host, as
2734
2750
  # a turn resolves them (a /model switch counts), through its own
2735
- # IdleClient, so it shares nothing with the turn's backend.
2751
+ # IdleClient, so it shares nothing with the turn's backend. The client
2752
+ # is kept while the target and timeout stay (what it learned, e.g. a
2753
+ # refused thinking-off, holds for the next request); the session's id
2754
+ # goes with the request.
2736
2755
  # @return [String] the answer
2737
2756
  def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
2738
2757
  target = session_model_recap_target
2739
- client = IdleClient.for(target, timeout: timeout)
2758
+ client = side_model_client(target, timeout)
2740
2759
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2741
- answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
2760
+ answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
2742
2761
  Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
2743
2762
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
2744
2763
  answer.text
2745
2764
  end
2746
2765
  private :ask_side_model
2747
2766
 
2767
+ # The IdleClient for +target+ at +timeout+: the last one when both are
2768
+ # the same, else a new one.
2769
+ def side_model_client(target, timeout)
2770
+ @side_client_mutex.synchronize do
2771
+ key = [target, timeout]
2772
+ unless @side_client && @side_client_key == key
2773
+ @side_client = IdleClient.for(target, timeout: timeout)
2774
+ @side_client_key = key
2775
+ end
2776
+ @side_client
2777
+ end
2778
+ end
2779
+ private :side_model_client
2780
+
2748
2781
  # The session's current model as a recap target: its host's OpenAI API
2749
2782
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2750
2783
  # and bare model name, as a turn resolves them.
@@ -2811,6 +2844,7 @@ module Samagotchi
2811
2844
  # reasoning sent back: a notice, not an event of its own.
2812
2845
  next thinking_refused(event) if event[:type] == :thinking_refused
2813
2846
  next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2847
+ next sampling_refused(event) if event[:type] == :sampling_refused
2814
2848
 
2815
2849
  event = with_generation_report(event) if event[:type] == :generation_completed
2816
2850
  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,29 +4,38 @@ 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
10
11
  # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
11
12
  # holds them by lowercased name; HostRegistry builds its HostEntry from
12
- # one). A worker gets its parent's hosts back through
13
- # SAMAGOTCHI_HOSTS_JSON: #to_env_h writes every field, and .parse reads
14
- # it back to an equal HostConfig.
13
+ # one). #to_env_h writes every field in the shape of the
14
+ # SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
15
+ # .parse reads it back to an equal HostConfig.
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)
27
36
  end
28
37
 
29
- # A hosts: entry as written (YAML, or a worker's SAMAGOTCHI_HOSTS_JSON),
38
+ # A hosts: entry as written (YAML, or a SAMAGOTCHI_HOSTS_JSON override),
30
39
  # or nil when it is disabled (enabled: false) or invalid. An invalid
31
40
  # one warns once, naming the entry, the way each reader of a setting
32
41
  # does for a bad value (that value is then unset).
@@ -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?
@@ -160,14 +180,15 @@ module Samagotchi
160
180
  end
161
181
  private_class_method :invalid
162
182
 
163
- # The entry as a worker's SAMAGOTCHI_HOSTS_JSON carries it (string keys,
183
+ # The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
164
184
  # unset fields left out); .parse(name, to_env_h) gives it back. A url
165
185
  # entry travels as its url (host/port come from it); the API key stays
166
- # in the environment, which workers inherit.
186
+ # in the environment.
167
187
  # @return [Hash{String => Object}]
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,
@@ -104,8 +104,8 @@ module Samagotchi
104
104
  end
105
105
  end
106
106
 
107
- # The entry as written back to config (SAMAGOTCHI_HOSTS_JSON for
108
- # workers): nil for a bare id.
107
+ # The entry as written back to config (HostConfig#to_env_h): nil for a
108
+ # bare id.
109
109
  def to_config
110
110
  config = { "price" => price&.to_config, "served" => served }.compact
111
111
  config unless config.empty?
@@ -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(" ")})"