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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +5 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundle_command.rb +9 -13
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +63 -18
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +18 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/builder.rb +23 -21
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installer.rb +4 -4
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +40 -46
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +68 -11
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +44 -11
- data/lib/samagotchi/session_metrics.rb +15 -20
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
- data/lib/samagotchi/terminal_ui.rb +21 -4
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +56 -13
- data/lib/samagotchi/web/public/app.js +131 -20
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +179 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- 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
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -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
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
|
1252
|
-
# (ReplyWait): a provider error's kind and whether
|
|
1253
|
-
# cancel's reason and the hook that stopped it.
|
|
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
|
-
{
|
|
1303
|
+
{ error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
|
|
1259
1304
|
when :turn_canceled
|
|
1260
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
74
|
-
priority =
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
#
|
|
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
|