samagotchi 0.51.0 → 0.53.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/releasing.md +6 -3
  11. data/docs/sessions.md +5 -1
  12. data/docs/testing.md +8 -1
  13. data/lib/samagotchi/analytics_file.rb +35 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge.rb +10 -13
  17. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  18. data/lib/samagotchi/bundle_command.rb +9 -13
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/children_status.rb +1 -1
  22. data/lib/samagotchi/commands/registry.rb +2 -1
  23. data/lib/samagotchi/config.rb +50 -182
  24. data/lib/samagotchi/config_text_edit.rb +8 -32
  25. data/lib/samagotchi/context_sources.rb +0 -7
  26. data/lib/samagotchi/context_window.rb +2 -2
  27. data/lib/samagotchi/continue_offer.rb +5 -4
  28. data/lib/samagotchi/cut_policy.rb +0 -1
  29. data/lib/samagotchi/delivery.rb +23 -0
  30. data/lib/samagotchi/engine.rb +63 -18
  31. data/lib/samagotchi/engine_peers.rb +1 -0
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +18 -10
  35. data/lib/samagotchi/last_turn.rb +48 -0
  36. data/lib/samagotchi/live_versions.rb +0 -6
  37. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  38. data/lib/samagotchi/llm_context_override.rb +3 -22
  39. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  40. data/lib/samagotchi/memory_bundle/builder.rb +23 -21
  41. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  42. data/lib/samagotchi/memory_bundle/installer.rb +4 -4
  43. data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
  44. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
  46. data/lib/samagotchi/model_profile.rb +4 -4
  47. data/lib/samagotchi/model_settings.rb +46 -0
  48. data/lib/samagotchi/plugin/context.rb +3 -3
  49. data/lib/samagotchi/plugin/sessions.rb +2 -2
  50. data/lib/samagotchi/queued_command.rb +35 -0
  51. data/lib/samagotchi/reminder_store.rb +0 -27
  52. data/lib/samagotchi/reply_wait.rb +11 -13
  53. data/lib/samagotchi/sampling_settings.rb +4 -4
  54. data/lib/samagotchi/self_report.rb +8 -5
  55. data/lib/samagotchi/send_command.rb +40 -46
  56. data/lib/samagotchi/session.rb +45 -24
  57. data/lib/samagotchi/session_commands.rb +68 -11
  58. data/lib/samagotchi/session_inbox.rb +18 -9
  59. data/lib/samagotchi/session_manager.rb +44 -11
  60. data/lib/samagotchi/session_metrics.rb +15 -20
  61. data/lib/samagotchi/session_setup.rb +30 -0
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/steer_cut.rb +9 -2
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  66. data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
  67. data/lib/samagotchi/terminal_ui.rb +21 -4
  68. data/lib/samagotchi/thinking.rb +67 -7
  69. data/lib/samagotchi/thinking_command.rb +72 -0
  70. data/lib/samagotchi/tools/builtins.rb +7 -2
  71. data/lib/samagotchi/tools/peers.rb +3 -2
  72. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +4 -3
  75. data/lib/samagotchi/web/app.rb +56 -13
  76. data/lib/samagotchi/web/public/app.js +131 -20
  77. data/lib/samagotchi/web/public/command_complete.js +7 -2
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +8 -5
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/thinking_chip.js +179 -0
  84. data/lib/samagotchi/web/public/timing.js +15 -0
  85. data/lib/samagotchi/web/public/turn_events.js +23 -0
  86. data/lib/samagotchi/web/public/turn_view.js +328 -310
  87. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  88. data/lib/samagotchi/web/session_summary.rb +1 -1
  89. data/lib/samagotchi/worker.rb +43 -42
  90. data/lib/samagotchi/worker_inbound.rb +5 -5
  91. data/lib/samagotchi/worker_wakes.rb +0 -2
  92. data/lib/samagotchi/yaml_lines.rb +72 -0
  93. metadata +13 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "last_turn"
4
5
  require_relative "log"
5
6
 
6
7
  module Samagotchi
@@ -85,11 +86,11 @@ module Samagotchi
85
86
  # Stop runs no turn: last_turn says so (not_continued), so a wait on
86
87
  # it ends (chi answer --option Stop).
87
88
  def announce_resolved(result, command)
88
- @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
89
+ @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
89
90
  return if result.resume
90
91
 
91
92
  session = @engine.session
92
- session.last_turn = { "outcome" => NOT_CONTINUED, "ended_at" => Time.now.iso8601(3) } if session
93
+ session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3)) if session
93
94
  end
94
95
 
95
96
  # After a command's command_ran and save: a /continue that answered the
@@ -128,7 +129,7 @@ module Samagotchi
128
129
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
129
130
  return announce_offer(offer)
130
131
  end
131
- @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
132
+ @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
132
133
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
133
134
  if error
134
135
  announce_offer(offer)
@@ -183,7 +184,7 @@ module Samagotchi
183
184
  # The limit the offer's turn ran out at (Engine's last_turn marker).
184
185
  def limit_of_last_turn
185
186
  last = @engine.session&.last_turn
186
- last.is_a?(Hash) && last["exhausted"] ? last["limit"] : nil
187
+ last&.exhausted ? last.limit : nil
187
188
  end
188
189
 
189
190
  # The question's answer as the /continue line a typed answer would be:
@@ -22,7 +22,6 @@ module Samagotchi
22
22
  Outcome = Data.define(:kind) do
23
23
  def stopped? = kind == :stopped
24
24
  def again? = kind == :again
25
- def hook? = kind == :hook
26
25
  end
27
26
 
28
27
  STOPPED = Outcome.new(kind: :stopped)
@@ -20,6 +20,29 @@ module Samagotchi
20
20
  raw.is_a?(String) && VALUES.include?(raw) ? raw : NEXT_STEP
21
21
  end
22
22
 
23
+ # Why a request body's delivery field can't be taken, as {error:,
24
+ # detail:} for a 400, or nil when it can. The Bridge and the web's
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).
29
+ # @param raw [Object] the wire value
30
+ # @return [Hash, nil]
31
+ def self.refusal(raw)
32
+ return nil if raw.nil?
33
+ return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
34
+ return QUEUE_UNAVAILABLE if raw == QUEUE
35
+
36
+ nil
37
+ end
38
+
39
+ 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)"
43
+ }.freeze
44
+ private_constant :QUEUE_UNAVAILABLE
45
+
23
46
  # Whether it is the one every sender defaulting to a step boundary uses.
24
47
  def self.next_step?(value) = parse(value) == NEXT_STEP
25
48
 
@@ -504,6 +504,20 @@ module Samagotchi
504
504
  end
505
505
  private :record_prompt_notes
506
506
 
507
+ # A failed turn's one line (the error's #summary). A host's failure
508
+ # under an alias says what the alias became (HostRegistry#alias_note):
509
+ # "host main" alone doesn't tell --model splash's user it was splash.
510
+ # @return [String]
511
+ def failure_summary(error)
512
+ summary = error.respond_to?(:summary) ? error.summary : error.message
513
+ return summary unless error.is_a?(LLM::ProviderError) && error.host
514
+
515
+ note = @host_registry.alias_note(@effective_model_name)
516
+ note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
517
+ rescue StandardError
518
+ summary
519
+ end
520
+
507
521
  # +name+'s resolved ref (ModelRef#ref).
508
522
  def model_ref_for(name)
509
523
  @host_registry.model_ref(name).ref
@@ -954,7 +968,7 @@ module Samagotchi
954
968
  served, served_for = served_model_for(snapshot, target: target)
955
969
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
956
970
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
957
- prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
971
+ thinking: thinking_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
958
972
  unless snapshot.dig(:context, :window_tokens)
959
973
  window = current_context_window(target)
960
974
  if window
@@ -1029,16 +1043,46 @@ module Samagotchi
1029
1043
  nil
1030
1044
  end
1031
1045
 
1032
- # "off (models: qwen)" for /model: the effective model's thinking level
1033
- # and where it came from, nil when none is set.
1046
+ # "off (models: qwen)", "low (session)" for /model: the effective
1047
+ # model's thinking level and where it came from, nil when none is set.
1034
1048
  def thinking_summary
1049
+ explained = thinking_explained
1050
+ explained&.source ? explained.label : nil
1051
+ end
1052
+
1053
+ # The effective model's thinking level with where it came from and the
1054
+ # session's own (Thinking::Explained), as the next turn resolves it;
1055
+ # nil when it can't be resolved.
1056
+ # @return [Thinking::Explained, nil]
1057
+ def thinking_explained
1035
1058
  target = @host_registry.resolve(@effective_model_name)
1036
- level, source = Thinking.resolve(target, names: model_lookup_names(target))
1037
- source ? "#{level} (#{source})" : nil
1059
+ Thinking.explain(target, names: model_lookup_names(target), session: thinking_override)
1038
1060
  rescue StandardError
1039
1061
  nil
1040
1062
  end
1041
1063
 
1064
+ # The session's own thinking level, nil when it has none.
1065
+ # @return [Symbol, nil]
1066
+ def thinking_override = @session&.thinking
1067
+
1068
+ # What a thinking level change costs the effective model's prompt
1069
+ # cache (Thinking.cache_cost): :full, :provider or :tail.
1070
+ # @return [Symbol]
1071
+ def thinking_cache_cost
1072
+ target = @host_registry.resolve(@effective_model_name)
1073
+ Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
1074
+ end
1075
+
1076
+ # Set the session's own thinking level (nil unsets it); the next turn's
1077
+ # start resolves it, never a running one (its TurnSettings are fixed).
1078
+ # The caller saves the session.
1079
+ # @raise [ArgumentError] with no session yet
1080
+ def thinking_override=(level)
1081
+ raise ArgumentError, "no session yet" unless @session
1082
+
1083
+ @session.thinking = level
1084
+ end
1085
+
1042
1086
  # The model the server serves for the current model, and the name asked
1043
1087
  # for: what the last generation of that name reported, else llama.cpp's
1044
1088
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -1105,6 +1149,7 @@ module Samagotchi
1105
1149
  served_expected_by: served_expected_by(served_pair[0]),
1106
1150
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1107
1151
  llm_context: llm_context_summary,
1152
+ thinking: thinking_explained&.summary,
1108
1153
  recap_enabled: !@recap.nil?,
1109
1154
  recap_min_user_turns: @recap&.min_user_turns,
1110
1155
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1242,22 +1287,22 @@ module Samagotchi
1242
1287
  elsif client_id == ClientId::REMINDER then "reminder"
1243
1288
  else "client"
1244
1289
  end
1245
- session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1246
- "seconds" => seconds.round(1), "origin" => source }
1247
- session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1248
- session.last_turn.merge!(stop_facts(event))
1290
+ session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1291
+ origin: source, exhausted: limit ? true : nil, limit: limit,
1292
+ **stop_facts(event))
1249
1293
  end
1250
1294
 
1251
- # Why a turn stopped, from its end event, for a parent agent's wait
1252
- # (ReplyWait): a provider error's kind and whether a retry may help, a
1253
- # cancel's reason and the hook that stopped it. Only what is known.
1295
+ # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1296
+ # parent agent's wait (ReplyWait): a provider error's kind and whether
1297
+ # a retry may help, a cancel's reason and the hook that stopped it.
1298
+ # Only what is known.
1254
1299
  def stop_facts(event)
1255
1300
  case event&.dig(:type)
1256
1301
  # kept_steps: the turn's work stayed (a parent must not send the task again).
1257
1302
  when :turn_failed
1258
- { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable], "kept_steps" => event[:kept_steps] }.compact
1303
+ { error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
1259
1304
  when :turn_canceled
1260
- { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1305
+ { cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
1261
1306
  else {}
1262
1307
  end
1263
1308
  end
@@ -2088,7 +2133,7 @@ module Samagotchi
2088
2133
  failed[:kept_steps] = steps if steps
2089
2134
  # A provider error says what kind it is, for one line per kind in the UIs.
2090
2135
  if error.is_a?(LLM::ProviderError)
2091
- failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
2136
+ failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
2092
2137
  end
2093
2138
  failed
2094
2139
  end
@@ -2237,9 +2282,9 @@ module Samagotchi
2237
2282
  hook_notify(text, level, "thinking")
2238
2283
  end
2239
2284
 
2240
- # +target+'s thinking level (Thinking.resolve).
2285
+ # +target+'s thinking level (Thinking.resolve), the session's own first.
2241
2286
  def thinking_level(target)
2242
- Thinking.resolve(target, names: model_lookup_names(target)).first
2287
+ Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
2243
2288
  rescue StandardError
2244
2289
  Thinking::DEFAULT
2245
2290
  end
@@ -2470,7 +2515,7 @@ module Samagotchi
2470
2515
  model_key: -> { @model_key },
2471
2516
  state_dir: -> { session_state_dir },
2472
2517
  scratch: -> { @scratch },
2473
- llm_context: -> { @session&.llm_context },
2518
+ setup: -> { SessionSetup.of(@session) },
2474
2519
  frontend: -> { interface }
2475
2520
  )
2476
2521
  end
@@ -17,6 +17,7 @@ module Samagotchi
17
17
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
18
  def relay = engine.relay_peer
19
19
  def model_ref = engine.effective_model_ref
20
+ def thinking = engine.thinking_override
20
21
  end
21
22
 
22
23
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
+ require_relative "../memory_bundle/bundle_hook"
6
7
  require_relative "../memory_bundle/provenance"
7
8
  require_relative "../memory_bundle/manifest"
8
9
  require_relative "../version"
@@ -44,7 +45,8 @@ module Samagotchi
44
45
  #
45
46
  # @param bundle_name [String]
46
47
  # @param hooks_dir [String] directory containing the bundle's .rb hook files
47
- # @param metadata [Hash] basename => { event:, on_error:, priority: }
48
+ # @param metadata [Hash] basename => { event:, on_error:, priority:,
49
+ # sha256: } (MemoryBundle::BundleHook.parse reads it)
48
50
  # @param registry [Samagotchi::Hooks::Registry]
49
51
  # @param failures [Guardrails::LoadFailures, nil] collects hooks that
50
52
  # failed to load
@@ -64,14 +66,13 @@ module Samagotchi
64
66
  basename = raw_basename.to_s
65
67
  next if basename.empty?
66
68
 
67
- meta = ->(key) { raw_meta && (raw_meta[key] || raw_meta[key.to_s]) }
68
- event = meta.call(:event).to_s
69
+ hook = MemoryBundle::BundleHook.parse(raw_meta)
69
70
  # A hook with no declared event cannot be auto-registered.
70
- next if event.empty?
71
+ next if hook.event.empty?
71
72
 
72
- event_sym = event.to_sym
73
- on_error = (meta.call(:on_error) || "skip").to_s
74
- priority = (meta.call(:priority) || 100).to_i
73
+ event_sym = hook.event.to_sym
74
+ on_error = hook.on_error
75
+ priority = hook.priority
75
76
  fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
76
77
  what = "hook #{basename} (bundle #{bundle_name})"
77
78
  file = hooks_dir && File.join(hooks_dir, basename)
@@ -79,7 +80,7 @@ module Samagotchi
79
80
  failures&.add(what, "the file is missing", required: true) if fail_closed
80
81
  next
81
82
  end
82
- if (mismatch = too_old || sha_mismatch(file, meta.call(:sha256), required: fail_closed))
83
+ if (mismatch = too_old || sha_mismatch(file, hook.sha256, required: fail_closed))
83
84
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
84
85
  failures&.add(what, mismatch, required: fail_closed)
85
86
  next
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "config"
5
+ require_relative "host_model"
6
+ require_relative "llm_context_strategy"
7
+
8
+ module Samagotchi
9
+ # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
10
+ # holds them by lowercased name; HostRegistry builds its HostEntry from
11
+ # one). A worker gets its parent's hosts back through
12
+ # SAMAGOTCHI_HOSTS_JSON: #to_env_h writes every field, and .parse reads
13
+ # it back to an equal HostConfig.
14
+ #
15
+ # name: the lowercased entry name; host, port, scheme: where it is (from
16
+ # url: when given); url: the configured url, nil when host/port; transport,
17
+ # api: symbols, nil when unset; the rest as HostRegistry::HostEntry
18
+ # describes them, nil when unset; models: {downcased id => HostModel}.
19
+ HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
20
+ :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
21
+ :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models) do
22
+ # Every field but name may be left out: nil, models {}.
23
+ def initialize(**fields)
24
+ super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
25
+ end
26
+
27
+ # A hosts: entry as written (YAML, or a worker's SAMAGOTCHI_HOSTS_JSON),
28
+ # or nil when it is disabled (enabled: false) or invalid. An invalid
29
+ # one warns once, naming the entry, the way each reader of a setting
30
+ # does for a bad value (that value is then unset).
31
+ # @param raw_name [String, Symbol]
32
+ # @param raw [Hash] string or symbol keys
33
+ # @return [HostConfig, nil]
34
+ def self.parse(raw_name, raw)
35
+ name = raw_name.to_s.strip
36
+ return nil if name.empty?
37
+ return invalid(name, "must match /[a-z0-9][a-z0-9._-]*/i") unless name.match?(ConfigFile::HOST_NAME_RE)
38
+ return invalid(name, "expected mapping") unless raw.is_a?(Hash)
39
+ return nil if disabled?(raw)
40
+
41
+ value = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key.to_sym] }
42
+ where = "hosts entry '#{name}'"
43
+ fields = {
44
+ api_key_env: value.call(:api_key_env).to_s.strip.then { |v| v.empty? ? nil : v },
45
+ # Kept as written; ModelProfile.resolve warns about an unknown one.
46
+ profile: value.call(:profile).to_s.strip.downcase.then { |v| v.empty? ? nil : v },
47
+ first_token_timeout: value.call(:first_token_timeout),
48
+ vision: ConfigFile.vision_flag(value.call(:vision), where),
49
+ remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
50
+ window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
51
+ sampling: ConfigFile.sampling_map(value.call(:sampling), where),
52
+ thinking: Thinking.level(value.call(:thinking), where),
53
+ llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
54
+ llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
55
+ models: HostModel.parse_map(value.call(:models), name),
56
+ llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where)
57
+ }
58
+ timeout = fields[:first_token_timeout]
59
+ unless timeout.nil? || (timeout.is_a?(Numeric) && !timeout.negative?)
60
+ ConfigFile.warn_once "Warning: #{where}: first_token_timeout must be seconds (0 = off); using the default"
61
+ fields[:first_token_timeout] = nil
62
+ end
63
+ api_key_env = fields[:api_key_env]
64
+ unless api_key_env.nil? || api_key_env.match?(ConfigFile::ENV_NAME_RE)
65
+ return invalid(name, "api_key_env must be an environment variable name")
66
+ end
67
+
68
+ location = parse_location(name, value) or return nil
69
+ kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
70
+ new(name: name.downcase, **location, **kinds, **fields)
71
+ rescue StandardError => e
72
+ # One entry that trips a reader is dropped on its own, and said so;
73
+ # the other hosts stay (the worker's copy too).
74
+ invalid(name || raw_name, "#{e.class}: #{e.message}")
75
+ end
76
+
77
+ # A literal Hash of fields (specs and in-memory callers building a
78
+ # HostRegistry) as a HostConfig, unchecked; a HostConfig as it is.
79
+ # @param name [String] the entry's name
80
+ # @param cfg [HostConfig, Hash] symbol keys, as #to_h gives them
81
+ def self.coerce(name, cfg)
82
+ return cfg if cfg.is_a?(HostConfig)
83
+
84
+ new(**cfg.to_h.slice(*members), name: name.to_s.downcase)
85
+ end
86
+
87
+ # enabled: false (or "false", any case) in a hosts entry as written.
88
+ def self.disabled?(raw)
89
+ enabled = if raw.key?("enabled")
90
+ raw["enabled"]
91
+ else
92
+ (raw.key?(:enabled) ? raw[:enabled] : true)
93
+ end
94
+ enabled == false || enabled.to_s.strip.downcase == "false"
95
+ end
96
+
97
+ # host/port/scheme/url from url:, or from host: and port:; nil (warned)
98
+ # when they are wrong.
99
+ def self.parse_location(name, value)
100
+ host = value.call(:host)
101
+ port = value.call(:port)
102
+ url = value.call(:url).to_s.strip
103
+ scheme = "http"
104
+ unless url.empty?
105
+ return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
106
+
107
+ uri = begin
108
+ URI.parse(url)
109
+ rescue URI::InvalidURIError
110
+ nil
111
+ end
112
+ return invalid(name, "url must be an http(s) URL") unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
113
+
114
+ host = uri.host
115
+ port = uri.port
116
+ scheme = uri.scheme
117
+ url = url.chomp("/")
118
+ end
119
+ host = host.to_s.strip
120
+ return invalid(name, "host is required") if host.empty?
121
+
122
+ port = port.to_s.strip.empty? ? 8080 : port.to_i
123
+ return invalid(name, "invalid port") if port <= 0 || port > 65_535
124
+
125
+ { host: host, port: port, scheme: scheme, url: url.empty? ? nil : url }
126
+ end
127
+ private_class_method :parse_location
128
+
129
+ # transport: and api: as symbols; a raw-prompt api is also the
130
+ # transport. nil (warned) when unknown or contradicting each other.
131
+ def self.parse_kinds(name, transport, api)
132
+ transport = transport.to_s.strip.downcase
133
+ if transport.empty?
134
+ transport = nil
135
+ elsif !ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(transport)
136
+ return invalid(name, "unknown transport '#{transport}'")
137
+ end
138
+ api = api.to_s.strip.downcase
139
+ if api.empty?
140
+ api = nil
141
+ elsif !ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
142
+ return invalid(name, "unknown api '#{api}'")
143
+ elsif ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(api)
144
+ # A raw-prompt api is the transport; a different transport contradicts it.
145
+ return invalid(name, "api '#{api}' conflicts with transport '#{transport}'") if transport && transport != api
146
+
147
+ transport = api
148
+ end
149
+ { transport: transport&.to_sym, api: api&.to_sym }
150
+ end
151
+ private_class_method :parse_kinds
152
+
153
+ def self.invalid(name, reason)
154
+ ConfigFile.warn_once "Warning: ignoring hosts entry '#{name}': #{reason}"
155
+ nil
156
+ end
157
+ private_class_method :invalid
158
+
159
+ # The entry as a worker's SAMAGOTCHI_HOSTS_JSON carries it (string keys,
160
+ # unset fields left out); .parse(name, to_env_h) gives it back. A url
161
+ # entry travels as its url (host/port come from it); the API key stays
162
+ # in the environment, which workers inherit.
163
+ # @return [Hash{String => Object}]
164
+ def to_env_h
165
+ location = url ? { "url" => url } : { "host" => host, "port" => port }
166
+ location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
167
+ "profile" => profile, "first_token_timeout" => first_token_timeout,
168
+ "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
169
+ "remote" => remote, "window_tokens" => window_tokens,
170
+ "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
171
+ "llm_context_apply" => llm_context_apply&.to_s,
172
+ "llm_context_budget_tokens" => llm_context_budget_tokens,
173
+ "models" => models_config).compact
174
+ end
175
+
176
+ private
177
+
178
+ # hosts.<name>.models as written back (HostModel#to_config), nil when none.
179
+ def models_config
180
+ models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
181
+ end
182
+ end
183
+ end
@@ -98,16 +98,11 @@ module Samagotchi
98
98
  raw = hosts_config || ConfigFile.hosts_config(env: env)
99
99
  @entries = {}
100
100
  raw.each do |key, cfg|
101
- # cfg: {name:, host:, port:, transport:, original_name:}
102
- transport = cfg[:transport]
103
- entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
104
- api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
105
- profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
106
- vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
107
- remote: cfg[:remote], window_tokens: cfg[:window_tokens],
108
- llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply],
109
- llm_context_budget_tokens: cfg[:llm_context_budget_tokens], models: cfg[:models] || {})
110
- entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
101
+ cfg = HostConfig.coerce(key, cfg)
102
+ # Every HostConfig field is a HostEntry member (a new one must be added there too).
103
+ fields = cfg.to_h.merge(name: key.to_s.downcase, port: cfg.port.to_i, api: cfg.api&.to_sym, models: cfg.models || {})
104
+ entry = HostEntry.new(**fields)
105
+ entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
111
106
  first_token_timeout: entry.first_token_limit, name: entry.name,
112
107
  api_key_env: entry.api_key_env, env: env)
113
108
  @entries[entry.name] = entry
@@ -278,6 +273,19 @@ module Samagotchi
278
273
  ModelTarget.new(model: raw_model, entry: entry, bare_model: bare, client: client_for(entry))
279
274
  end
280
275
 
276
+ # What the alias +raw_model+ became, for an error that names only the
277
+ # host ("splash is an alias: gemma-x on host main"), or nil when it
278
+ # isn't an alias. An alias without a host of its own goes where a bare
279
+ # id goes (#host_for_model), often the default host the user never typed.
280
+ # @return [String, nil]
281
+ def alias_note(raw_model)
282
+ ref = model_ref(raw_model)
283
+ return nil unless ref.alias_name
284
+
285
+ target = resolve(raw_model)
286
+ "#{ref.alias_name} is an alias: #{target.bare_model} on host #{target.entry.name}"
287
+ end
288
+
281
289
  # The model id sent for +full_ref+: its alias applied, a known host
282
290
  # prefix stripped ("box:gemma" → "gemma").
283
291
  def bare_name(full_ref)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # How a session's last turn ended (Session#last_turn), saved as
5
+ # session.json's "last_turn" and sent in the web's session summary.
6
+ # Engine#record_last_turn writes it at every turn's end, ContinueOffer
7
+ # when a Stop answered the step-limit question (no turn ran).
8
+ #
9
+ # outcome: "completed", "failed", "canceled" or "not_continued";
10
+ # ended_at: iso8601; seconds: Float; origin: "client", "reminder",
11
+ # "delegate", "delegate_report" (a turn a parent ran for its delegate
12
+ # children's reports) or "context" (one an attached context source's
13
+ # change started); exhausted/limit: the turn ran out of iterations at
14
+ # limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
15
+ # known. A field may be nil (unset; left out of the file).
16
+ LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
17
+ :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
18
+ def initialize(**fields)
19
+ super(**members.to_h { |m| [m, nil] }, **fields)
20
+ end
21
+
22
+ # A saved record (string keys; symbol keys read too, a key we don't
23
+ # know dropped), a LastTurn as it is; nil for anything else.
24
+ # @return [LastTurn, nil]
25
+ def self.from_file(raw)
26
+ return raw if raw.is_a?(LastTurn)
27
+ return nil unless raw.is_a?(Hash)
28
+
29
+ new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
30
+ end
31
+
32
+ # The session file's record: string keys, in member order, unset
33
+ # fields left out.
34
+ # @return [Hash{String => Object}]
35
+ def to_file = to_h.compact.transform_keys(&:to_s)
36
+
37
+ # The stop facts this turn has (LastTurn::STOP_FACTS), symbol keys.
38
+ # @return [Hash{Symbol => Object}]
39
+ def stop_facts = to_h.slice(*self.class::STOP_FACTS).compact
40
+ end
41
+
42
+ # Why a turn stopped, for a parent agent's wait (ReplyWait::Result takes
43
+ # them by these names): a failed turn's provider error kind, whether a
44
+ # retry may help and whether its work stayed (kept_steps: a parent must
45
+ # not send the task again); a canceled one's reason and the hook that
46
+ # stopped it.
47
+ LastTurn::STOP_FACTS = %i[error_kind retryable kept_steps cancel_reason stopped_by].freeze
48
+ end
@@ -36,12 +36,6 @@ module Samagotchi
36
36
  workers(state_dir: state_dir).reject { |w| w.version == version }
37
37
  end
38
38
 
39
- # The version a chi web on host:port runs, or nil when nothing (or not
40
- # chi web) answers.
41
- def web_version(host, port, timeout: WEB_TIMEOUT)
42
- web_info(host, port, timeout: timeout)&.fetch("version", nil).to_s.then { |v| v.empty? ? nil : v }
43
- end
44
-
45
39
  # The /api/info of a chi web on host:port, or nil when nothing (or not
46
40
  # chi web) answers.
47
41
  def web_info(host, port, timeout: WEB_TIMEOUT)
@@ -26,11 +26,6 @@ module Samagotchi
26
26
  Tools::BuiltinCalls.build(call.name.to_s, args_for(call))
27
27
  end
28
28
 
29
- # Map an Array of tool calls to internal call hashes (nils dropped).
30
- def normalize_all(calls)
31
- Array(calls).map { |call| normalize(call) }.compact
32
- end
33
-
34
29
  private
35
30
 
36
31
  # args_for: arguments is normally a Hash. Defensively handle nil /
@@ -48,7 +48,6 @@ module Samagotchi
48
48
  FIELDS = %i[strategy apply budget_tokens].freeze
49
49
  # A field's word for "unset: follow the model" (/llm-context strategy default).
50
50
  DEFAULT_WORD = "default"
51
- OFF_WORDS = %w[off 0].freeze
52
51
  # The start flags (chi, chi send --new), by field.
53
52
  FLAGS = { strategy: "--llm-context", apply: "--llm-context-apply", budget_tokens: "--llm-context-budget" }.freeze
54
53
  # The session command, and its words by field.
@@ -111,7 +110,7 @@ module Samagotchi
111
110
  end
112
111
 
113
112
  def self.file_budget(raw)
114
- raw.is_a?(Integer) ? check_budget(raw) : nil
113
+ raw.is_a?(Integer) ? LLMContextStrategy.check_budget(raw) : nil
115
114
  end
116
115
 
117
116
  # +current+ with +words+ (field => what the user typed) applied: each
@@ -153,27 +152,9 @@ module Samagotchi
153
152
  rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
154
153
  end
155
154
 
156
- # The smallest and largest budget a session may set.
157
- MIN_BUDGET = 4_000
158
- MAX_BUDGET = 10_000_000
159
-
160
- # "64000", "64k" or "off" (0).
155
+ # "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
161
156
  # @return [Integer] 0 for off
162
- def self.parse_budget_tokens(text)
163
- return 0 if OFF_WORDS.include?(text.downcase)
164
-
165
- match = /\A(\d[\d_]*)(k)?\z/i.match(text)
166
- raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
167
-
168
- check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
169
- end
170
-
171
- # +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
172
- def self.check_budget(tokens)
173
- return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
174
-
175
- raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
176
- end
157
+ def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
177
158
 
178
159
  private_class_method :file_value, :file_strategy, :file_budget
179
160
  end