samagotchi 0.11.0 → 0.13.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 +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -1
|
@@ -22,6 +22,8 @@ require_relative "tool_activity"
|
|
|
22
22
|
require_relative "tool_runner"
|
|
23
23
|
require_relative "tool_response"
|
|
24
24
|
require_relative "answer_display"
|
|
25
|
+
require_relative "llm/model_result"
|
|
26
|
+
require_relative "llm/turn_settings"
|
|
25
27
|
|
|
26
28
|
module Samagotchi
|
|
27
29
|
# The KernelLoop drives the model ↔ tool interaction cycle.
|
|
@@ -37,30 +39,12 @@ module Samagotchi
|
|
|
37
39
|
# - Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
|
|
38
40
|
# - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
39
41
|
class KernelLoop
|
|
40
|
-
Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
|
|
41
|
-
def exhausted?
|
|
42
|
-
exhausted
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def pending_tool_calls?
|
|
46
|
-
pending_tool_calls
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def resumable?
|
|
50
|
-
exhausted? && pending_tool_calls?
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def canceled?
|
|
54
|
-
canceled
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
42
|
# The built-in tool classes (Tools::Builtins registers them).
|
|
59
43
|
TOOLS = Tools::Builtins::CLASSES
|
|
60
44
|
|
|
61
45
|
# What a tool handler (call, kctx) gets from the kernel: the reminder
|
|
62
46
|
# store, the peers, the model key, and the memory read and ask-user
|
|
63
|
-
# flows that need its state.
|
|
47
|
+
# flows that need its state (read through the kernel's public readers).
|
|
64
48
|
class ToolContext
|
|
65
49
|
def initialize(kernel)
|
|
66
50
|
@kernel = kernel
|
|
@@ -69,8 +53,44 @@ module Samagotchi
|
|
|
69
53
|
def reminder_store = @kernel.reminder_store
|
|
70
54
|
def peers = @kernel.peers
|
|
71
55
|
def model_key = @kernel.model_key
|
|
72
|
-
|
|
73
|
-
|
|
56
|
+
|
|
57
|
+
# memory_read with the session's mutes applied: a blank name (the index)
|
|
58
|
+
# loses the muted memories' lines; a muted name in a comma list is
|
|
59
|
+
# refused with its own error line and the rest is read as usual.
|
|
60
|
+
def muted_memory_read(call)
|
|
61
|
+
muted = Array(@kernel.muted_memory_names)
|
|
62
|
+
content = call[:content].to_s
|
|
63
|
+
read = lambda do |names|
|
|
64
|
+
Tools::MemoryRead.call(names, scope: call[:scope], model_key: model_key, fallback_model_key: @kernel.model_key_fallback)
|
|
65
|
+
end
|
|
66
|
+
return read.call(content) if muted.empty?
|
|
67
|
+
return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
|
|
68
|
+
|
|
69
|
+
names = Tools::MemoryRead.parse_names(content)
|
|
70
|
+
refused, allowed = names.partition { |name| MutedMemories.muted?(name, muted) }
|
|
71
|
+
return read.call(content) if refused.empty?
|
|
72
|
+
|
|
73
|
+
errors = refused.map { |name| "Error: memory '#{name}' is muted for this session" }
|
|
74
|
+
return errors.join("\n") if allowed.empty?
|
|
75
|
+
|
|
76
|
+
[read.call(allowed.join(",")), *errors].join(Tools::MemoryRead::SEPARATOR)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ask_user_question: validate the call, then hand the payload to the
|
|
80
|
+
# Engine's question flow (the kernel's question_handler, which blocks
|
|
81
|
+
# until the user answers). Without one (headless), the payload as JSON
|
|
82
|
+
# so the model sees the options and can ask in plain text.
|
|
83
|
+
def ask_user_question(call)
|
|
84
|
+
payload = Tools::AskUserQuestion.validate(call)
|
|
85
|
+
return payload if payload.is_a?(String)
|
|
86
|
+
|
|
87
|
+
handler = @kernel.question_handler
|
|
88
|
+
return JSON.pretty_generate(payload) unless handler
|
|
89
|
+
|
|
90
|
+
handler.call(payload).to_s
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
"Error: ask_user_question handler failed: #{e.message}"
|
|
93
|
+
end
|
|
74
94
|
end
|
|
75
95
|
|
|
76
96
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
@@ -83,11 +103,12 @@ module Samagotchi
|
|
|
83
103
|
@client = client || Client.new
|
|
84
104
|
@tools = tools || Tools::Builtins.default
|
|
85
105
|
resolved_model_name = ModelProfile.required_model_name(model_name)
|
|
86
|
-
# The
|
|
106
|
+
# The turn's settings (LLM::TurnSettings). Its model name is the
|
|
107
|
+
# resolved model id actually used for this run (per-run override wins
|
|
87
108
|
# over the config alias); on every debug dump so we can see exactly
|
|
88
|
-
# which model each request went to. The Engine sets
|
|
109
|
+
# which model each request went to. The Engine sets them per turn:
|
|
89
110
|
# the chat loop dispatches tools here without going through #run.
|
|
90
|
-
@
|
|
111
|
+
@turn_settings = LLM::TurnSettings.none.with(model_name: resolved_model_name)
|
|
91
112
|
@profile = profile ? ModelProfile.normalize(profile) : ModelProfile.from_model_name(resolved_model_name)
|
|
92
113
|
# Where @profile came from, as /stats shows it (a Resolution's label
|
|
93
114
|
# once the Engine resolves one, see #use_profile!).
|
|
@@ -98,24 +119,25 @@ module Samagotchi
|
|
|
98
119
|
@model_key_fallback = nil
|
|
99
120
|
end
|
|
100
121
|
|
|
101
|
-
# @return [ReminderStore, nil] the reminder store
|
|
102
|
-
# Engine
|
|
103
|
-
|
|
104
|
-
attr_reader :reminder_store
|
|
122
|
+
# @return [ReminderStore, nil] the reminder store the reminder tools
|
|
123
|
+
# write; the Engine's own (it hands it to a kernel it was given).
|
|
124
|
+
attr_accessor :reminder_store
|
|
105
125
|
|
|
106
126
|
# @return [ModelProfile] the active prompt profile
|
|
107
127
|
attr_reader :profile
|
|
108
128
|
|
|
109
129
|
# @return [Samagotchi::Hooks::Registry, nil] hooks registry shared with Engine.
|
|
110
130
|
# Engine owns the registry; KernelLoop only fires events. Accessor allows
|
|
111
|
-
# Engine to
|
|
131
|
+
# Engine to hand its registry to a kernel it is given (specs).
|
|
112
132
|
attr_accessor :hooks
|
|
113
133
|
# @return [Tools::Registry] the tools #dispatch runs. The Engine sets
|
|
114
|
-
# its own on a kernel
|
|
134
|
+
# its own on a kernel it is given, as with hooks.
|
|
115
135
|
attr_accessor :tools
|
|
116
136
|
attr_accessor :client
|
|
117
137
|
attr_accessor :model_key
|
|
118
|
-
|
|
138
|
+
# @return [String, nil] the key whose overlay memory_read takes when
|
|
139
|
+
# #model_key has none (#sync_model_key!)
|
|
140
|
+
attr_reader :model_key_fallback
|
|
119
141
|
# @return [Array<String>, nil] the session's muted memories (normalized
|
|
120
142
|
# names, see MutedMemories); memory_read refuses them. The Engine sets it.
|
|
121
143
|
attr_accessor :muted_memory_names
|
|
@@ -125,15 +147,11 @@ module Samagotchi
|
|
|
125
147
|
# The Guardrails::Gate ToolRunner asks before each call; the Engine sets
|
|
126
148
|
# it (nil: ToolRunner's own, hooks only).
|
|
127
149
|
attr_accessor :guardrail_gate
|
|
128
|
-
#
|
|
129
|
-
# the Engine per turn
|
|
130
|
-
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
attr_accessor :sampling
|
|
134
|
-
# The turn's thinking level (Thinking.resolve), set by the Engine per
|
|
135
|
-
# turn; its own accessor, since the native path sends @sampling as is.
|
|
136
|
-
attr_accessor :thinking
|
|
150
|
+
# @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
|
|
151
|
+
# model name, set by the Engine per turn (#run sets the model name
|
|
152
|
+
# too); the thinking level is its own field, since the native path
|
|
153
|
+
# sends the sampling as is.
|
|
154
|
+
attr_accessor :turn_settings
|
|
137
155
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
138
156
|
# list_sessions and send_note speak for; nil outside a session.
|
|
139
157
|
attr_accessor :peers
|
|
@@ -142,7 +160,7 @@ module Samagotchi
|
|
|
142
160
|
# resumable conversation state when execution stops at max_iterations.
|
|
143
161
|
#
|
|
144
162
|
# @param messages [Array<Hash>] conversation so far ({role:, content:});
|
|
145
|
-
# a stopped run resumes from its
|
|
163
|
+
# a stopped run resumes from its result's #conversation
|
|
146
164
|
# @param max_iterations [Integer] safety cap on tool-call rounds
|
|
147
165
|
# @param on_stream_event [Proc, nil] optional callback for generation events
|
|
148
166
|
# @param cancel_controller [CancellationController, nil] optional cancellation source
|
|
@@ -155,16 +173,16 @@ module Samagotchi
|
|
|
155
173
|
# steering mid-stream); drained lines merge into ONE user message appended
|
|
156
174
|
# at the conversation tail (prefix KV cache preserved) and a
|
|
157
175
|
# :pending_input_merged stream event is emitted.
|
|
158
|
-
# @return [
|
|
176
|
+
# @return [LLM::ModelResult] final visible response with continuation metadata
|
|
159
177
|
def run(messages, max_iterations: 100, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
160
|
-
@
|
|
178
|
+
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
161
179
|
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
162
180
|
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
|
|
163
181
|
|
|
164
182
|
max_iterations.times do |iteration_index|
|
|
165
183
|
turn.iteration = iteration_index + 1
|
|
166
184
|
outcome = iterate(turn)
|
|
167
|
-
return outcome if outcome.is_a?(
|
|
185
|
+
return outcome if outcome.is_a?(LLM::ModelResult)
|
|
168
186
|
break if outcome == :answer
|
|
169
187
|
rescue Client::RequestCancelled => e
|
|
170
188
|
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
|
|
@@ -220,15 +238,15 @@ module Samagotchi
|
|
|
220
238
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
221
239
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
222
240
|
# prompt starts with what the server already has cached.
|
|
223
|
-
prefill: Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill,
|
|
224
|
-
pending_tool_calls: false, model_name: @
|
|
241
|
+
prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
|
|
242
|
+
pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
|
|
225
243
|
on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
|
|
226
244
|
emit: ->(event) { emit_stream_event(on_stream_event, event) }
|
|
227
245
|
)
|
|
228
246
|
end
|
|
229
247
|
|
|
230
248
|
# One iteration. Returns :next, :answer (the turn ends), or the turn's
|
|
231
|
-
#
|
|
249
|
+
# result (it ended cancelled).
|
|
232
250
|
def iterate(turn)
|
|
233
251
|
inject_pending_input!(turn)
|
|
234
252
|
request = prepare_request(turn)
|
|
@@ -264,7 +282,7 @@ module Samagotchi
|
|
|
264
282
|
end
|
|
265
283
|
|
|
266
284
|
def format_prompt(turn)
|
|
267
|
-
Prompt.format_with_images(turn.conversation, profile: @profile, vision: @vision, prefill: turn.prefill)
|
|
285
|
+
Prompt.format_with_images(turn.conversation, profile: @profile, vision: @turn_settings.vision, prefill: turn.prefill)
|
|
268
286
|
end
|
|
269
287
|
|
|
270
288
|
# One streamed request, under the generation's own controller: a
|
|
@@ -290,7 +308,7 @@ module Samagotchi
|
|
|
290
308
|
generation_controller: generation_controller,
|
|
291
309
|
cancel_controller: turn.cancel_controller,
|
|
292
310
|
model_name: turn.model_name,
|
|
293
|
-
sampling: turn.empty_retry.request_sampling(@sampling),
|
|
311
|
+
sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
|
|
294
312
|
on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
|
|
295
313
|
on_retry: lambda { |retry_event|
|
|
296
314
|
# The retry streams from the start: its counts replace these.
|
|
@@ -357,7 +375,7 @@ module Samagotchi
|
|
|
357
375
|
# with its own nudge while the retry budget lasts (queued input goes in
|
|
358
376
|
# place of the nudge), else the turn ends as cancelled (hook), with
|
|
359
377
|
# nothing salvaged and without the spent nudge. A Stop that came right
|
|
360
|
-
# after the cut is a plain cancel. Returns :next or the turn's
|
|
378
|
+
# after the cut is a plain cancel. Returns :next or the turn's result.
|
|
361
379
|
def after_cut(turn, generation)
|
|
362
380
|
cut = generation.cut
|
|
363
381
|
raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
|
|
@@ -429,20 +447,19 @@ module Samagotchi
|
|
|
429
447
|
:next
|
|
430
448
|
end
|
|
431
449
|
|
|
432
|
-
# The turn's
|
|
433
|
-
# results it never answered
|
|
450
|
+
# The turn's result. It is exhausted only when it stopped on tool
|
|
451
|
+
# results it never answered (so exhausted? alone means resumable?);
|
|
452
|
+
# then the answer shows only its text.
|
|
434
453
|
def finish(turn)
|
|
435
454
|
exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
|
|
436
455
|
output = strip_thought_blocks(last_model_content(turn.conversation))
|
|
437
456
|
output = parser.strip_tool_calls(output) if exhausted
|
|
438
|
-
|
|
439
|
-
|
|
457
|
+
LLM::ModelResult.new(
|
|
458
|
+
text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
|
|
440
459
|
conversation: duplicate_conversation(turn.conversation),
|
|
441
460
|
exhausted: exhausted,
|
|
442
461
|
pending_tool_calls: turn.pending_tool_calls,
|
|
443
462
|
tool_activity: turn.tool_activity,
|
|
444
|
-
canceled: false,
|
|
445
|
-
cancellation_reason: nil,
|
|
446
463
|
context_status: turn.context.display
|
|
447
464
|
)
|
|
448
465
|
end
|
|
@@ -458,26 +475,6 @@ module Samagotchi
|
|
|
458
475
|
cancel_controller: turn.cancel_controller, answer: answer)
|
|
459
476
|
end
|
|
460
477
|
|
|
461
|
-
# memory_read with the session's mutes applied: a blank name (the index)
|
|
462
|
-
# loses the muted memories' lines; a muted name in a comma list is
|
|
463
|
-
# refused with its own error line and the rest is read as usual.
|
|
464
|
-
def muted_memory_read(tool, call)
|
|
465
|
-
muted = Array(@muted_memory_names)
|
|
466
|
-
content = call[:content].to_s
|
|
467
|
-
read = ->(names) { tool.call(names, scope: call[:scope], model_key: @model_key, fallback_model_key: @model_key_fallback) }
|
|
468
|
-
return read.call(content) if muted.empty?
|
|
469
|
-
return MutedMemories.filter_index(read.call(content), muted) if content.strip.empty?
|
|
470
|
-
|
|
471
|
-
names = Tools::MemoryRead.parse_names(content)
|
|
472
|
-
refused, allowed = names.partition { |name| MutedMemories.muted?(name, muted) }
|
|
473
|
-
return read.call(content) if refused.empty?
|
|
474
|
-
|
|
475
|
-
errors = refused.map { |name| "Error: memory '#{name}' is muted for this session" }
|
|
476
|
-
return errors.join("\n") if allowed.empty?
|
|
477
|
-
|
|
478
|
-
[read.call(allowed.join(",")), *errors].join(Tools::MemoryRead::SEPARATOR)
|
|
479
|
-
end
|
|
480
|
-
|
|
481
478
|
def emit_stream_event(callback, event)
|
|
482
479
|
callback&.call(event)
|
|
483
480
|
rescue StandardError
|
|
@@ -549,14 +546,14 @@ module Samagotchi
|
|
|
549
546
|
# Only a request with images names them: a text-only call is unchanged.
|
|
550
547
|
kwargs[:images] = images unless images.empty?
|
|
551
548
|
kwargs[:on_chunk] = on_chunk if on_chunk
|
|
552
|
-
kwargs[:on_retry] = on_retry if on_retry
|
|
553
|
-
kwargs[:cancel_controller] = cancel_controller if cancel_controller
|
|
554
|
-
kwargs[:stop] = @profile.stop_sequences
|
|
549
|
+
kwargs[:on_retry] = on_retry if on_retry
|
|
550
|
+
kwargs[:cancel_controller] = cancel_controller if cancel_controller
|
|
551
|
+
kwargs[:stop] = @profile.stop_sequences
|
|
555
552
|
n_predict = completion_n_predict
|
|
556
|
-
kwargs[:n_predict] = n_predict if n_predict
|
|
553
|
+
kwargs[:n_predict] = n_predict if n_predict
|
|
557
554
|
resolved_model_name = completion_model_name(model_name)
|
|
558
|
-
kwargs[:model] = resolved_model_name if resolved_model_name
|
|
559
|
-
kwargs[:sampling] = sampling if sampling && !sampling.empty?
|
|
555
|
+
kwargs[:model] = resolved_model_name if resolved_model_name
|
|
556
|
+
kwargs[:sampling] = sampling if sampling && !sampling.empty?
|
|
560
557
|
kwargs
|
|
561
558
|
end
|
|
562
559
|
|
|
@@ -569,19 +566,6 @@ module Samagotchi
|
|
|
569
566
|
ModelProfile.required_model_name(override)
|
|
570
567
|
end
|
|
571
568
|
|
|
572
|
-
def client_supports_keyword?(keyword)
|
|
573
|
-
@client_complete_keyword_support ||= {}
|
|
574
|
-
return @client_complete_keyword_support[keyword] if @client_complete_keyword_support.key?(keyword)
|
|
575
|
-
|
|
576
|
-
@client_complete_keyword_support[keyword] = begin
|
|
577
|
-
parameters = @client.method(:complete).parameters
|
|
578
|
-
parameters.any? { |kind, name| (kind == :key || kind == :keyreq) && name == keyword } ||
|
|
579
|
-
parameters.any? { |kind, _name| kind == :keyrest }
|
|
580
|
-
rescue StandardError
|
|
581
|
-
false
|
|
582
|
-
end
|
|
583
|
-
end
|
|
584
|
-
|
|
585
569
|
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
|
|
586
570
|
partial = partial_assistant_text.to_s.strip
|
|
587
571
|
conversation = duplicate_conversation(conversation)
|
|
@@ -592,11 +576,9 @@ module Samagotchi
|
|
|
592
576
|
unless partial.empty?
|
|
593
577
|
conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
|
|
594
578
|
end
|
|
595
|
-
|
|
596
|
-
|
|
579
|
+
LLM::ModelResult.new(
|
|
580
|
+
text: "",
|
|
597
581
|
conversation: conversation,
|
|
598
|
-
exhausted: false,
|
|
599
|
-
pending_tool_calls: false,
|
|
600
582
|
tool_activity: tool_activity,
|
|
601
583
|
canceled: true,
|
|
602
584
|
cancellation_reason: reason
|
|
@@ -608,7 +590,7 @@ module Samagotchi
|
|
|
608
590
|
def dump_log(event, payload, **fields)
|
|
609
591
|
return unless Log.level?(:debug)
|
|
610
592
|
|
|
611
|
-
Log.debug(:model, event, payload: payload, model: @
|
|
593
|
+
Log.debug(:model, event, payload: payload, model: @turn_settings.model_name, **fields)
|
|
612
594
|
end
|
|
613
595
|
|
|
614
596
|
# Estimate context usage for this iteration's prompt (ContextStatus#observe)
|
|
@@ -633,12 +615,6 @@ module Samagotchi
|
|
|
633
615
|
strip_thought_blocks(text)
|
|
634
616
|
end
|
|
635
617
|
|
|
636
|
-
# The status line's context value ({est_pct:, bucket:}) for +used_tokens+
|
|
637
|
-
# of +window_tokens+; nil without both, or with context.status off.
|
|
638
|
-
def context_display(used_tokens:, window_tokens:)
|
|
639
|
-
ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
|
|
640
|
-
end
|
|
641
|
-
|
|
642
618
|
# Per-profile parse strategy. Rebuilt when the active profile changes
|
|
643
619
|
# (the profile may be re-inferred per run when not explicitly pinned).
|
|
644
620
|
# Public so other loops (and specs) can reach the active profile's parser.
|
|
@@ -743,19 +719,5 @@ module Samagotchi
|
|
|
743
719
|
activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
|
|
744
720
|
}
|
|
745
721
|
end
|
|
746
|
-
|
|
747
|
-
# ask_user_question: validate the call, then hand the payload to the
|
|
748
|
-
# Engine's question flow (question_handler, which blocks until the user
|
|
749
|
-
# answers). Without one (headless), the payload as JSON so the model sees
|
|
750
|
-
# the options and can ask in plain text.
|
|
751
|
-
def handle_ask_user_question(call)
|
|
752
|
-
payload = Samagotchi::Tools::AskUserQuestion.validate(call)
|
|
753
|
-
return payload if payload.is_a?(String)
|
|
754
|
-
return JSON.pretty_generate(payload) unless @question_handler
|
|
755
|
-
|
|
756
|
-
@question_handler.call(payload).to_s
|
|
757
|
-
rescue => e
|
|
758
|
-
"Error: ask_user_question handler failed: #{e.message}"
|
|
759
|
-
end
|
|
760
722
|
end
|
|
761
723
|
end
|
|
@@ -77,10 +77,8 @@ module Samagotchi
|
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
# The kernel's tools
|
|
81
|
-
def tools
|
|
82
|
-
@kernel.respond_to?(:tools) ? @kernel.tools : Tools::Builtins.default
|
|
83
|
-
end
|
|
80
|
+
# The kernel's tools.
|
|
81
|
+
def tools = @kernel.tools
|
|
84
82
|
|
|
85
83
|
# engine-format conversation -> OpenAI wire messages. Model turns are
|
|
86
84
|
# thought-stripped and carry their tool_calls; tool responses go as tool
|
|
@@ -133,20 +131,15 @@ module Samagotchi
|
|
|
133
131
|
|
|
134
132
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
135
133
|
|
|
136
|
-
# The turn's VisionContext (the Engine sets
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
end
|
|
134
|
+
# The turn's VisionContext (the Engine sets the kernel's
|
|
135
|
+
# turn_settings), or nil.
|
|
136
|
+
def vision = @kernel.turn_settings.vision
|
|
140
137
|
|
|
141
|
-
# The turn's request parameters (the
|
|
142
|
-
def sampling
|
|
143
|
-
@kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
|
|
144
|
-
end
|
|
138
|
+
# The turn's request parameters (the kernel's turn_settings).
|
|
139
|
+
def sampling = @kernel.turn_settings.sampling || {}
|
|
145
140
|
|
|
146
|
-
# The turn's thinking level (the
|
|
147
|
-
def thinking
|
|
148
|
-
(@kernel.respond_to?(:thinking) && @kernel.thinking) || Thinking::DEFAULT
|
|
149
|
-
end
|
|
141
|
+
# The turn's thinking level (the kernel's turn_settings).
|
|
142
|
+
def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
|
|
150
143
|
|
|
151
144
|
# The request fields the thinking level adds (Thinking.chat_fields);
|
|
152
145
|
# none for a model whose host refused them (#thinking_refused!).
|
|
@@ -171,9 +164,7 @@ module Samagotchi
|
|
|
171
164
|
deep_compact(options)
|
|
172
165
|
end
|
|
173
166
|
|
|
174
|
-
def strip_model_thought(text)
|
|
175
|
-
@kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
|
|
176
|
-
end
|
|
167
|
+
def strip_model_thought(text) = @kernel.strip_model_thought(text)
|
|
177
168
|
|
|
178
169
|
def tool_runner
|
|
179
170
|
@tool_runner ||= ToolRunner.new(@kernel)
|
|
@@ -184,8 +175,7 @@ module Samagotchi
|
|
|
184
175
|
# llama.cpp answers /props), then the host's model list, config, env
|
|
185
176
|
# and the default. A remote provider has no /props to probe.
|
|
186
177
|
def context_window(model)
|
|
187
|
-
|
|
188
|
-
client = @kernel.client if !remote && @kernel.respond_to?(:client)
|
|
178
|
+
client = @adapter.remote? ? nil : @kernel.client
|
|
189
179
|
ContextWindow.resolve(client: client, model: model, adapter: @adapter)
|
|
190
180
|
rescue StandardError
|
|
191
181
|
nil
|
|
@@ -193,8 +183,7 @@ module Samagotchi
|
|
|
193
183
|
|
|
194
184
|
# A hook failing must not break the turn (as in ToolRunner).
|
|
195
185
|
def fire_hook(name, event)
|
|
196
|
-
|
|
197
|
-
hooks&.fire(name, event)
|
|
186
|
+
@kernel.hooks&.fire(name, event)
|
|
198
187
|
rescue StandardError
|
|
199
188
|
nil
|
|
200
189
|
end
|
|
@@ -297,7 +286,7 @@ module Samagotchi
|
|
|
297
286
|
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|
|
298
287
|
end
|
|
299
288
|
|
|
300
|
-
# One turn's state: the conversation, the stream sink
|
|
289
|
+
# One turn's state: the conversation, the stream sink and tool
|
|
301
290
|
# activity.
|
|
302
291
|
class Run
|
|
303
292
|
def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input)
|
|
@@ -307,12 +296,7 @@ module Samagotchi
|
|
|
307
296
|
@cancel_controller = cancel_controller
|
|
308
297
|
@model_name = model_name
|
|
309
298
|
@pending_input = pending_input
|
|
310
|
-
@usage = UsageCollector.new
|
|
311
299
|
@tool_activity = []
|
|
312
|
-
# What an estimate counts as the prompt when the server reports no
|
|
313
|
-
# usage: the text, and each image's estimate (not its base64).
|
|
314
|
-
@prompt_text = conversation.sum("") { |entry| entry[:content].to_s }
|
|
315
|
-
@image_tokens = ImagePlan.estimated_tokens(conversation)
|
|
316
300
|
@empty_retry = EmptyAnswerRetry.new
|
|
317
301
|
@context = ContextStatus.new(conversation: conversation)
|
|
318
302
|
end
|
|
@@ -578,23 +562,18 @@ module Samagotchi
|
|
|
578
562
|
@conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
|
|
579
563
|
conversation = @loop.plain(@conversation)
|
|
580
564
|
conversation.last[:interrupted] = true unless visible.empty?
|
|
581
|
-
ModelResult.new(text: "",
|
|
582
|
-
cancellation_reason: reason, tool_activity: @tool_activity,
|
|
565
|
+
ModelResult.new(text: "", conversation: conversation, canceled: true,
|
|
566
|
+
cancellation_reason: reason, tool_activity: @tool_activity,
|
|
583
567
|
context_status: @context.display)
|
|
584
568
|
end
|
|
585
569
|
|
|
586
570
|
def result(text, exhausted:)
|
|
587
|
-
ModelResult.new(text: text,
|
|
588
|
-
tool_activity: @tool_activity,
|
|
571
|
+
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
572
|
+
tool_activity: @tool_activity, empty_answer: text == EMPTY_ANSWER,
|
|
589
573
|
context_status: @context.display)
|
|
590
574
|
end
|
|
591
575
|
|
|
592
|
-
def usage
|
|
593
|
-
@usage.usage(prompt_text: @prompt_text, extra_prompt_tokens: @image_tokens)
|
|
594
|
-
end
|
|
595
|
-
|
|
596
576
|
def emit(event)
|
|
597
|
-
@usage.observe(event)
|
|
598
577
|
@on_stream_event&.call(event)
|
|
599
578
|
rescue StandardError
|
|
600
579
|
nil
|
|
@@ -3,29 +3,20 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module LLM
|
|
5
5
|
# Normalized result returned by every ModelBackend downstream of the agentic
|
|
6
|
-
# loop
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# .conversation, .canceled?, .cancellation_reason, .output, .exhausted?
|
|
13
|
-
# plus the native loop's .tool_activity, .context_status and
|
|
14
|
-
# .pending_tool_calls, which the interactive renderer needs.
|
|
6
|
+
# loop (the loop lives inside the backend). What Engine#run_turn and the
|
|
7
|
+
# renderers (TerminalUI / Bridge) read: .output (= .text, and .to_s, which
|
|
8
|
+
# is how the :turn_completed `result:` reaches JSON), .conversation,
|
|
9
|
+
# .canceled?, .cancellation_reason, .exhausted?, .resumable?,
|
|
10
|
+
# .empty_answer?, plus the native loop's .tool_activity, .context_status
|
|
11
|
+
# and .pending_tool_calls, which the interactive renderer needs.
|
|
15
12
|
class ModelResult
|
|
16
|
-
attr_reader :text, :
|
|
17
|
-
:conversation, :canceled, :cancellation_reason, :exhausted,
|
|
13
|
+
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
18
14
|
:tool_activity, :context_status, :pending_tool_calls
|
|
19
15
|
|
|
20
|
-
def initialize(text:,
|
|
21
|
-
conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
16
|
+
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
22
17
|
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_answer: false)
|
|
23
18
|
@empty_answer = empty_answer
|
|
24
19
|
@text = text
|
|
25
|
-
@tool_calls = tool_calls
|
|
26
|
-
@provider = provider
|
|
27
|
-
@usage = usage
|
|
28
|
-
@metadata = metadata
|
|
29
20
|
@conversation = conversation
|
|
30
21
|
@canceled = canceled
|
|
31
22
|
@cancellation_reason = cancellation_reason
|
|
@@ -44,10 +35,13 @@ module Samagotchi
|
|
|
44
35
|
!!canceled
|
|
45
36
|
end
|
|
46
37
|
|
|
47
|
-
# The turn ended with nothing visible:
|
|
48
|
-
#
|
|
38
|
+
# The turn ended with nothing visible: no text, or the chat loop's
|
|
39
|
+
# placeholder (empty_answer:). A cancelled turn or one that can be
|
|
40
|
+
# continued is not an empty answer.
|
|
49
41
|
def empty_answer?
|
|
50
|
-
|
|
42
|
+
return false if canceled? || resumable?
|
|
43
|
+
|
|
44
|
+
!!@empty_answer || text.to_s.strip.empty?
|
|
51
45
|
end
|
|
52
46
|
|
|
53
47
|
def exhausted?
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "backend"
|
|
4
4
|
require_relative "model_result"
|
|
5
|
-
require_relative "usage"
|
|
6
5
|
|
|
7
6
|
module Samagotchi
|
|
8
7
|
module LLM
|
|
9
8
|
# The raw-prompt loop as a backend: KernelLoop#run (llama.cpp /completion,
|
|
10
|
-
# Gemma/Qwen prompt formats)
|
|
9
|
+
# Gemma/Qwen prompt formats), which returns a ModelResult itself, so
|
|
11
10
|
# Engine and the TUI call every backend the same way.
|
|
12
11
|
class NativeBackend < ModelBackend
|
|
13
12
|
def initialize(kernel:)
|
|
@@ -18,32 +17,15 @@ module Samagotchi
|
|
|
18
17
|
|
|
19
18
|
def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
|
|
20
19
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
21
|
-
|
|
22
|
-
kernel_result = @kernel.run(
|
|
20
|
+
@kernel.run(
|
|
23
21
|
messages,
|
|
24
22
|
max_iterations: max_iterations,
|
|
25
|
-
on_stream_event:
|
|
26
|
-
usage.observe(event)
|
|
27
|
-
on_stream_event&.call(event)
|
|
28
|
-
},
|
|
23
|
+
on_stream_event: ->(event) { on_stream_event&.call(event) },
|
|
29
24
|
cancel_controller: cancel_controller,
|
|
30
25
|
model_name: model_name,
|
|
31
26
|
max_tool_output_chars: max_tool_output_chars,
|
|
32
27
|
pending_input: pending_input
|
|
33
28
|
)
|
|
34
|
-
ModelResult.new(
|
|
35
|
-
text: kernel_result.output.to_s,
|
|
36
|
-
tool_calls: nil,
|
|
37
|
-
provider: :native,
|
|
38
|
-
usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
|
|
39
|
-
conversation: kernel_result.conversation,
|
|
40
|
-
canceled: kernel_result.canceled?,
|
|
41
|
-
cancellation_reason: kernel_result.cancellation_reason,
|
|
42
|
-
exhausted: kernel_result.exhausted,
|
|
43
|
-
tool_activity: kernel_result.tool_activity,
|
|
44
|
-
context_status: kernel_result.context_status,
|
|
45
|
-
pending_tool_calls: kernel_result.pending_tool_calls
|
|
46
|
-
)
|
|
47
29
|
end
|
|
48
30
|
end
|
|
49
31
|
end
|
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
# @param entry [HostRegistry::HostEntry]
|
|
57
57
|
def self.for(entry, **options)
|
|
58
58
|
new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
|
|
59
|
-
remote: entry.
|
|
59
|
+
remote: entry.remote?, **options)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
|
|
@@ -245,12 +245,19 @@ module Samagotchi
|
|
|
245
245
|
function = call["function"] || {}
|
|
246
246
|
ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
|
|
247
247
|
end
|
|
248
|
-
ChatResponse.new(text: message["content"].to_s,
|
|
248
|
+
ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
|
|
249
249
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
250
250
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
251
251
|
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
|
|
252
252
|
end
|
|
253
253
|
|
|
254
|
+
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
255
|
+
# writes after </think> at the start of the answer
|
|
256
|
+
# (https://github.com/incoai/splash/issues/254). Remove once Splash
|
|
257
|
+
# strips it; llama.cpp already does. Streamed, it applies to the chunks
|
|
258
|
+
# until the first text.
|
|
259
|
+
def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
|
|
260
|
+
|
|
254
261
|
def self.served_model(payload)
|
|
255
262
|
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
256
263
|
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
@@ -372,6 +379,7 @@ module Samagotchi
|
|
|
372
379
|
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
373
380
|
delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
|
|
374
381
|
content = delta["content"].to_s
|
|
382
|
+
content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
|
|
375
383
|
reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
|
|
376
384
|
@text << content
|
|
377
385
|
@reasoning << reasoning
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../thinking"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module LLM
|
|
7
|
+
# A turn's settings on the kernel, read by both loops (KernelLoop,
|
|
8
|
+
# ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
|
|
9
|
+
# images, placeholders instead), its request parameters
|
|
10
|
+
# (SamplingSettings.for; empty sends none), its thinking level
|
|
11
|
+
# (Thinking.resolve) and the bare model id the turn runs on (every
|
|
12
|
+
# debug dump is tagged with it). The Engine sets them once per turn,
|
|
13
|
+
# in Engine#generate, after the model's host is synced.
|
|
14
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name) do
|
|
15
|
+
# No vision, no sampling, the default thinking, no model name.
|
|
16
|
+
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|