samagotchi 0.10.1 → 0.12.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 +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- 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/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- 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 +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- 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 +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- metadata +13 -1
|
@@ -20,7 +20,10 @@ require_relative "tools/builtins"
|
|
|
20
20
|
require_relative "muted_memories"
|
|
21
21
|
require_relative "tool_activity"
|
|
22
22
|
require_relative "tool_runner"
|
|
23
|
+
require_relative "tool_response"
|
|
23
24
|
require_relative "answer_display"
|
|
25
|
+
require_relative "llm/model_result"
|
|
26
|
+
require_relative "llm/turn_settings"
|
|
24
27
|
|
|
25
28
|
module Samagotchi
|
|
26
29
|
# The KernelLoop drives the model ↔ tool interaction cycle.
|
|
@@ -36,30 +39,12 @@ module Samagotchi
|
|
|
36
39
|
# - Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
|
|
37
40
|
# - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
38
41
|
class KernelLoop
|
|
39
|
-
Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
|
|
40
|
-
def exhausted?
|
|
41
|
-
exhausted
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def pending_tool_calls?
|
|
45
|
-
pending_tool_calls
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def resumable?
|
|
49
|
-
exhausted? && pending_tool_calls?
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def canceled?
|
|
53
|
-
canceled
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
42
|
# The built-in tool classes (Tools::Builtins registers them).
|
|
58
43
|
TOOLS = Tools::Builtins::CLASSES
|
|
59
44
|
|
|
60
45
|
# What a tool handler (call, kctx) gets from the kernel: the reminder
|
|
61
46
|
# store, the peers, the model key, and the memory read and ask-user
|
|
62
|
-
# flows that need its state.
|
|
47
|
+
# flows that need its state (read through the kernel's public readers).
|
|
63
48
|
class ToolContext
|
|
64
49
|
def initialize(kernel)
|
|
65
50
|
@kernel = kernel
|
|
@@ -68,8 +53,44 @@ module Samagotchi
|
|
|
68
53
|
def reminder_store = @kernel.reminder_store
|
|
69
54
|
def peers = @kernel.peers
|
|
70
55
|
def model_key = @kernel.model_key
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
73
94
|
end
|
|
74
95
|
|
|
75
96
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
@@ -78,17 +99,16 @@ module Samagotchi
|
|
|
78
99
|
|
|
79
100
|
# @param tools [Tools::Registry, nil] the tools calls dispatch to (the
|
|
80
101
|
# Engine's; nil: the built-ins alone)
|
|
81
|
-
def initialize(client: nil, profile: nil, model_name: nil,
|
|
82
|
-
tools: nil)
|
|
102
|
+
def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil)
|
|
83
103
|
@client = client || Client.new
|
|
84
104
|
@tools = tools || Tools::Builtins.default
|
|
85
|
-
@no_interrupt = no_interrupt
|
|
86
105
|
resolved_model_name = ModelProfile.required_model_name(model_name)
|
|
87
|
-
# 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
|
|
88
108
|
# over the config alias); on every debug dump so we can see exactly
|
|
89
|
-
# which model each request went to. The Engine sets
|
|
109
|
+
# which model each request went to. The Engine sets them per turn:
|
|
90
110
|
# the chat loop dispatches tools here without going through #run.
|
|
91
|
-
@
|
|
111
|
+
@turn_settings = LLM::TurnSettings.none.with(model_name: resolved_model_name)
|
|
92
112
|
@profile = profile ? ModelProfile.normalize(profile) : ModelProfile.from_model_name(resolved_model_name)
|
|
93
113
|
# Where @profile came from, as /stats shows it (a Resolution's label
|
|
94
114
|
# once the Engine resolves one, see #use_profile!).
|
|
@@ -96,26 +116,28 @@ module Samagotchi
|
|
|
96
116
|
@hooks = hooks
|
|
97
117
|
@reminder_store = reminder_store
|
|
98
118
|
@model_key = model_key
|
|
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,275 +173,25 @@ 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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
qwen_partial_tool_call = nil
|
|
170
|
-
empty_retries = 0
|
|
171
|
-
empty_retry_limit = EmptyAnswerRetry.limit
|
|
172
|
-
@retry_generation = false
|
|
173
|
-
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
174
|
-
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
175
|
-
# prompt starts with what the server already has cached.
|
|
176
|
-
prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
|
|
177
|
-
partial_assistant_buffer = +""
|
|
178
|
-
|
|
179
|
-
effective_max_iterations = @no_interrupt ? 1000 : max_iterations
|
|
180
|
-
effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
|
|
181
|
-
effective_max_iterations.times do |iteration_index|
|
|
182
|
-
inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
|
|
183
|
-
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
184
|
-
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
|
|
185
|
-
context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
|
|
186
|
-
emit_context_status_event(on_stream_event, context, prompt, iteration_index: iteration_index, window: context_window,
|
|
187
|
-
image_tokens: image_tokens)
|
|
188
|
-
if (line = context.take_guidance)
|
|
189
|
-
# The model's own copy, on the tail (the prompt cache keeps its
|
|
190
|
-
# prefix), then the prompt again with it.
|
|
191
|
-
conversation << line
|
|
192
|
-
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
193
|
-
end
|
|
194
|
-
# Each generation splits its own stream: one that ended inside a
|
|
195
|
-
# thought or a tool call doesn't leave the next one "inside" it.
|
|
196
|
-
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
197
|
-
emit_stream_event(
|
|
198
|
-
on_stream_event,
|
|
199
|
-
type: :generation_started,
|
|
200
|
-
iteration: iteration_index + 1,
|
|
201
|
-
context_window_tokens: context_window.tokens,
|
|
202
|
-
context_window_source: context_window.source,
|
|
203
|
-
profile: @profile.name,
|
|
204
|
-
profile_source: @profile_source
|
|
205
|
-
)
|
|
206
|
-
served_model = nil
|
|
207
|
-
# This generation's own server counts (the run-long ones in
|
|
208
|
-
# `context` can be an earlier one's).
|
|
209
|
-
generation_usage = nil
|
|
210
|
-
# The thinking this generation streamed, for the log (a stuck
|
|
211
|
-
# thinking generation shows as thinking_chars=N content_length=…).
|
|
212
|
-
streamed_thinking = 0
|
|
213
|
-
# Fire :before_generation hook
|
|
214
|
-
gen_event = { type: :before_generation, iteration: iteration_index + 1 }
|
|
215
|
-
fire_hook(:before_generation, gen_event) if @hooks
|
|
216
|
-
# The request runs under the generation's own controller: a plugin's
|
|
217
|
-
# stop_generation cuts it alone, and the turn goes on (a cut).
|
|
218
|
-
buffer_mark = partial_assistant_buffer.length
|
|
219
|
-
cut = nil
|
|
220
|
-
response = with_generation(cancel_controller) do |generation_controller|
|
|
221
|
-
request_generation(
|
|
222
|
-
prompt,
|
|
223
|
-
generation_controller: generation_controller,
|
|
224
|
-
cancel_controller: cancel_controller,
|
|
225
|
-
model_name: resolved_model_name,
|
|
226
|
-
on_chunk: lambda { |chunk|
|
|
227
|
-
generation_usage = context.capture(chunk[:payload]) || generation_usage
|
|
228
|
-
# llama.cpp names the loaded model in the stream's last payload.
|
|
229
|
-
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
230
|
-
served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
231
|
-
split = stream_splitter.feed(chunk[:content])
|
|
232
|
-
partial_assistant_buffer << split[:text]
|
|
233
|
-
streamed_thinking += split[:thinking].to_s.length
|
|
234
|
-
if on_stream_event
|
|
235
|
-
emit_stream_event(
|
|
236
|
-
on_stream_event,
|
|
237
|
-
type: :generation_chunk,
|
|
238
|
-
iteration: iteration_index + 1,
|
|
239
|
-
content: chunk[:content],
|
|
240
|
-
text: split[:text],
|
|
241
|
-
thinking: split[:thinking],
|
|
242
|
-
payload: chunk[:payload]
|
|
243
|
-
)
|
|
244
|
-
end
|
|
245
|
-
},
|
|
246
|
-
on_retry: lambda { |retry_event|
|
|
247
|
-
# The retry streams from the start: its counts replace these.
|
|
248
|
-
generation_usage = nil
|
|
249
|
-
streamed_thinking = 0
|
|
250
|
-
next unless on_stream_event
|
|
251
|
-
|
|
252
|
-
emit_stream_event(
|
|
253
|
-
on_stream_event,
|
|
254
|
-
{
|
|
255
|
-
type: :generation_retrying,
|
|
256
|
-
iteration: iteration_index + 1
|
|
257
|
-
}.merge(retry_event)
|
|
258
|
-
)
|
|
259
|
-
},
|
|
260
|
-
images: images
|
|
261
|
-
)
|
|
262
|
-
rescue Client::RequestCancelled
|
|
263
|
-
raise unless cut?(generation_controller, cancel_controller)
|
|
264
|
-
|
|
265
|
-
cut = generation_controller.detail || {}
|
|
266
|
-
""
|
|
267
|
-
end
|
|
268
|
-
if cut
|
|
269
|
-
# The cut stream's visible text goes with it: the buffer as it was
|
|
270
|
-
# before (the next generation gets a fresh splitter).
|
|
271
|
-
partial_assistant_buffer.slice!(buffer_mark..)
|
|
272
|
-
emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
|
|
273
|
-
content_length: 0, thinking_chars: streamed_thinking,
|
|
274
|
-
served_model: served_model, requested_model: resolved_model_name,
|
|
275
|
-
finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
|
|
276
|
-
# A Stop that came right after the cut is a plain cancel.
|
|
277
|
-
raise Client::RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
|
|
278
|
-
|
|
279
|
-
if empty_retries < empty_retry_limit
|
|
280
|
-
# Queued input (the user's line, a plugin's steer) goes in place of the nudge.
|
|
281
|
-
next if inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
|
|
282
|
-
|
|
283
|
-
empty_retries += 1
|
|
284
|
-
@retry_generation = true
|
|
285
|
-
emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
|
|
286
|
-
attempt: empty_retries, of: empty_retry_limit,
|
|
287
|
-
thinking_chars: streamed_thinking, stopped_by: cut[:by])
|
|
288
|
-
conversation << TurnNote.cut_retry(cut[:by], cut[:reason])
|
|
289
|
-
next
|
|
290
|
-
end
|
|
291
|
-
# No retry left: the turn ends as cancelled (hook), with nothing
|
|
292
|
-
# salvaged and without the spent nudge.
|
|
293
|
-
drop_last_empty_retry!(conversation)
|
|
294
|
-
cancel_controller.cancel!(:hook, cut)
|
|
295
|
-
emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
|
|
296
|
-
return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
|
|
297
|
-
end
|
|
298
|
-
context.generation_done(generation_usage, prompt_chars: prompt.length, image_tokens: image_tokens, window: context_window)
|
|
299
|
-
emit_stream_event(
|
|
300
|
-
on_stream_event,
|
|
301
|
-
type: :generation_completed,
|
|
302
|
-
iteration: iteration_index + 1,
|
|
303
|
-
content_length: response.to_s.length,
|
|
304
|
-
thinking_chars: thinking_chars(response.to_s, streamed_thinking),
|
|
305
|
-
served_model: served_model,
|
|
306
|
-
requested_model: resolved_model_name
|
|
307
|
-
)
|
|
308
|
-
dump_log("response", response, iteration: iteration_index + 1)
|
|
309
|
-
# Fire :after_generation hook (after LLM returns, before tool parse),
|
|
310
|
-
# with a read-only copy of the conversation as sent.
|
|
311
|
-
after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
|
|
312
|
-
messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
|
|
313
|
-
fire_hook(:after_generation, after_gen_event) if @hooks
|
|
314
|
-
conversation << { role: "model", content: prefill + response.to_s }
|
|
315
|
-
|
|
316
|
-
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
317
|
-
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
318
|
-
# iteration if the model opened a tool-call block it did not close.
|
|
319
|
-
calls, qwen_partial_tool_call = parser.parse_with_recovery(response, qwen_partial_tool_call)
|
|
320
|
-
calls = calls.map do |call|
|
|
321
|
-
PromptLiteralGuard.restore_call(call, profile: @profile)
|
|
322
|
-
end
|
|
323
|
-
qwen_incomplete_tool_call = !qwen_partial_tool_call.nil?
|
|
324
|
-
|
|
325
|
-
if calls.empty?
|
|
326
|
-
if qwen_incomplete_tool_call && qwen_recovery_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
|
|
327
|
-
qwen_recovery_attempts += 1
|
|
328
|
-
conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
|
|
329
|
-
pending_tool_calls = false
|
|
330
|
-
next
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
pending_tool_calls = false
|
|
334
|
-
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
335
|
-
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
336
|
-
retry_empty = empty && empty_retries < empty_retry_limit && !cancel_controller&.cancelled?
|
|
337
|
-
# The empty generation goes (its thinking would be sent again and
|
|
338
|
-
# prime the same loop); an empty answer that will be retried is no
|
|
339
|
-
# answer site, so a plugin's steer joins the retry.
|
|
340
|
-
conversation.pop if retry_empty
|
|
341
|
-
unless inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller,
|
|
342
|
-
answer: retry_empty ? nil : answer)
|
|
343
|
-
if retry_empty
|
|
344
|
-
empty_retries += 1
|
|
345
|
-
@retry_generation = true
|
|
346
|
-
emit_stream_event(on_stream_event, type: :empty_answer_retry, iteration: iteration_index + 1,
|
|
347
|
-
attempt: empty_retries, of: empty_retry_limit,
|
|
348
|
-
thinking_chars: thinking_chars(response.to_s, streamed_thinking))
|
|
349
|
-
conversation << TurnNote.empty_retry
|
|
350
|
-
next
|
|
351
|
-
end
|
|
352
|
-
# The Engine's TurnNote.empty says it all: the spent nudge goes.
|
|
353
|
-
drop_last_empty_retry!(conversation) if empty && empty_retries.positive?
|
|
354
|
-
break
|
|
355
|
-
end
|
|
356
|
-
# Queued steering keeps the turn going: loop again so the model
|
|
357
|
-
# answers the injected message instead of stopping here.
|
|
358
|
-
next
|
|
359
|
-
end
|
|
360
|
-
|
|
361
|
-
qwen_recovery_attempts = 0
|
|
362
|
-
qwen_partial_tool_call = nil
|
|
363
|
-
|
|
364
|
-
emit_stream_event(on_stream_event, type: :tool_dispatch_started, iteration: iteration_index + 1, call_count: calls.length)
|
|
365
|
-
tool_images = []
|
|
366
|
-
image_counts = []
|
|
367
|
-
shown_params = []
|
|
368
|
-
shown_labels = []
|
|
369
|
-
diffs = []
|
|
370
|
-
results = calls.map.with_index do |call, call_index|
|
|
371
|
-
run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
|
|
372
|
-
call_count: calls.length, on_stream_event: on_stream_event,
|
|
373
|
-
max_tool_output_chars: effective_max_tool_output_chars)
|
|
374
|
-
tool_activity << run[:activity]
|
|
375
|
-
tool_images.concat(Array(run[:images]))
|
|
376
|
-
image_counts << Array(run[:images]).size
|
|
377
|
-
shown_params << run[:shown_params]
|
|
378
|
-
shown_labels << run[:shown_label]
|
|
379
|
-
diffs << run[:diff]
|
|
380
|
-
run[:output]
|
|
381
|
-
end.join("\n\n---\n\n")
|
|
382
|
-
emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
|
|
383
|
-
# The joined results carry every call's images, in call order.
|
|
384
|
-
tool_response = { role: "tool_response", content: results }
|
|
385
|
-
tool_response[:images] = tool_images unless tool_images.empty?
|
|
386
|
-
# How many of them each call returned, in call order, so the web's
|
|
387
|
-
# reload puts each on its own tool row; the prompt never reads it.
|
|
388
|
-
tool_response[:image_counts] = image_counts unless tool_images.empty?
|
|
389
|
-
# A plugin tool's params line, one per call in call order (nil for
|
|
390
|
-
# a built-in), for the web's reload; the prompt never reads it.
|
|
391
|
-
tool_response[:tool_params] = shown_params if shown_params.any?
|
|
392
|
-
tool_response[:tool_labels] = shown_labels if shown_labels.any?
|
|
393
|
-
# What each edit/write changed (nil for other calls), in call order,
|
|
394
|
-
# for the web's reload; the prompt never reads it.
|
|
395
|
-
tool_response[:tool_diffs] = diffs if diffs.any?
|
|
396
|
-
conversation << tool_response
|
|
397
|
-
pending_tool_calls = true
|
|
178
|
+
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
179
|
+
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
180
|
+
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
|
|
181
|
+
|
|
182
|
+
max_iterations.times do |iteration_index|
|
|
183
|
+
turn.iteration = iteration_index + 1
|
|
184
|
+
outcome = iterate(turn)
|
|
185
|
+
return outcome if outcome.is_a?(LLM::ModelResult)
|
|
186
|
+
break if outcome == :answer
|
|
398
187
|
rescue Client::RequestCancelled => e
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
iteration: iteration_index + 1,
|
|
403
|
-
reason: e.reason
|
|
404
|
-
)
|
|
405
|
-
return cancelled_result(conversation, tool_activity: tool_activity, reason: e.reason, partial_assistant_text: partial_assistant_buffer)
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
if pending_tool_calls && tool_response_turn?(conversation.last)
|
|
409
|
-
exhausted = true
|
|
188
|
+
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason)
|
|
189
|
+
return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
|
|
190
|
+
partial_assistant_text: turn.buffer)
|
|
410
191
|
end
|
|
411
|
-
|
|
412
|
-
output = strip_thought_blocks(last_model_content(conversation))
|
|
413
|
-
# A turn stopped at the limit ends on a call it never ran: show only its text.
|
|
414
|
-
output = parser.strip_tool_calls(output) if exhausted
|
|
415
|
-
Result.new(
|
|
416
|
-
output: PromptLiteralGuard.restore(output, profile: @profile),
|
|
417
|
-
conversation: duplicate_conversation(conversation),
|
|
418
|
-
exhausted: exhausted,
|
|
419
|
-
pending_tool_calls: pending_tool_calls,
|
|
420
|
-
tool_activity: tool_activity,
|
|
421
|
-
canceled: false,
|
|
422
|
-
cancellation_reason: nil,
|
|
423
|
-
context_status: context.display
|
|
424
|
-
)
|
|
192
|
+
finish(turn)
|
|
425
193
|
rescue StandardError => e
|
|
426
|
-
LLM::FailedTurn.attach(e,
|
|
194
|
+
LLM::FailedTurn.attach(e, turn && duplicate_conversation(turn.conversation))
|
|
427
195
|
raise
|
|
428
196
|
end
|
|
429
197
|
|
|
@@ -434,30 +202,277 @@ module Samagotchi
|
|
|
434
202
|
@profile_source = resolution.label
|
|
435
203
|
end
|
|
436
204
|
|
|
437
|
-
|
|
205
|
+
# @param fallback [String, nil] a key whose overlay memory_read takes
|
|
206
|
+
# when +key+ has none (the alias a model was typed as)
|
|
207
|
+
def sync_model_key!(key, fallback: nil)
|
|
438
208
|
@model_key = key
|
|
209
|
+
@model_key_fallback = fallback
|
|
439
210
|
end
|
|
440
211
|
|
|
441
212
|
private
|
|
442
213
|
|
|
443
|
-
#
|
|
444
|
-
|
|
445
|
-
#
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
214
|
+
# ── One turn (#run) ────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
# One run's state, threaded through its steps: the conversation, the
|
|
217
|
+
# context tracker and the retry budget, the tool activity, the visible
|
|
218
|
+
# text streamed so far (salvaged on a cancel), the Qwen recovery state,
|
|
219
|
+
# the prefill, and what the caller gave.
|
|
220
|
+
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
|
|
221
|
+
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
222
|
+
:cancel_controller, :cap, :emit, :iteration, keyword_init: true)
|
|
223
|
+
# One request: the prompt and its images as sent, the images' token
|
|
224
|
+
# estimate, and the window it was measured against.
|
|
225
|
+
Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
|
|
226
|
+
# One generation: the response (nil when cut), the cut's detail, this
|
|
227
|
+
# generation's own server counts, the model the server named, the
|
|
228
|
+
# thinking it streamed, and why it stopped (the transport's finish
|
|
229
|
+
# reason, Client::Transport#finish_reason_from; nil when not named).
|
|
230
|
+
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
|
|
231
|
+
private_constant :Turn, :Request, :Generation
|
|
232
|
+
|
|
233
|
+
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
234
|
+
conversation = prepare_conversation(messages)
|
|
235
|
+
Turn.new(
|
|
236
|
+
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
237
|
+
empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
|
|
238
|
+
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
239
|
+
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
240
|
+
# prompt starts with what the server already has cached.
|
|
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,
|
|
243
|
+
on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
|
|
244
|
+
emit: ->(event) { emit_stream_event(on_stream_event, event) }
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# One iteration. Returns :next, :answer (the turn ends), or the turn's
|
|
249
|
+
# result (it ended cancelled).
|
|
250
|
+
def iterate(turn)
|
|
251
|
+
inject_pending_input!(turn)
|
|
252
|
+
request = prepare_request(turn)
|
|
253
|
+
generation = generate(turn, request)
|
|
254
|
+
return after_cut(turn, generation) if generation.cut
|
|
255
|
+
|
|
256
|
+
turn.conversation << { role: "model", content: turn.prefill + generation.response.to_s }
|
|
257
|
+
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
258
|
+
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
259
|
+
# iteration if the model opened a tool-call block it did not close.
|
|
260
|
+
calls, turn.qwen_partial = parser.parse_with_recovery(generation.response, turn.qwen_partial)
|
|
261
|
+
calls = calls.map { |call| PromptLiteralGuard.restore_call(call, profile: @profile) }
|
|
262
|
+
return after_text(turn, request, generation) if calls.empty?
|
|
263
|
+
|
|
264
|
+
dispatch_calls(turn, calls)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The prompt for this iteration, after the context check: a rise into a
|
|
268
|
+
# bucket that asks the model for a change puts its line on the tail
|
|
269
|
+
# (the prompt cache keeps its prefix), and the prompt is formatted again
|
|
270
|
+
# with it.
|
|
271
|
+
def prepare_request(turn)
|
|
272
|
+
prompt, images = format_prompt(turn)
|
|
273
|
+
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
274
|
+
window = ContextWindow.resolve(client: @client, model: turn.model_name)
|
|
275
|
+
emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
|
|
276
|
+
window: window, image_tokens: image_tokens)
|
|
277
|
+
if (line = turn.context.take_guidance)
|
|
278
|
+
turn.conversation << line
|
|
279
|
+
prompt, images = format_prompt(turn)
|
|
280
|
+
end
|
|
281
|
+
Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def format_prompt(turn)
|
|
285
|
+
Prompt.format_with_images(turn.conversation, profile: @profile, vision: @turn_settings.vision, prefill: turn.prefill)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# One streamed request, under the generation's own controller: a
|
|
289
|
+
# plugin's stop_generation cuts it alone, and the turn goes on (a cut).
|
|
290
|
+
# Ends with its :generation_completed (and, unless cut, the
|
|
291
|
+
# after_generation hook).
|
|
292
|
+
def generate(turn, request)
|
|
293
|
+
# Each generation splits its own stream: one that ended inside a
|
|
294
|
+
# thought or a tool call doesn't leave the next one "inside" it.
|
|
295
|
+
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
296
|
+
emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
|
|
297
|
+
context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source)
|
|
298
|
+
# This generation's own server counts (the run-long ones in the
|
|
299
|
+
# context tracker can be an earlier one's), and the thinking it
|
|
300
|
+
# streamed, for the log (a stuck thinking generation shows as
|
|
301
|
+
# thinking_chars=N content_length=…).
|
|
302
|
+
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
303
|
+
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
304
|
+
buffer_mark = turn.buffer.length
|
|
305
|
+
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
306
|
+
request_generation(
|
|
307
|
+
request.prompt,
|
|
308
|
+
generation_controller: generation_controller,
|
|
309
|
+
cancel_controller: turn.cancel_controller,
|
|
310
|
+
model_name: turn.model_name,
|
|
311
|
+
sampling: turn.empty_retry.request_sampling(@turn_settings.sampling),
|
|
312
|
+
on_chunk: ->(chunk) { stream_chunk(turn, generation, stream_splitter, chunk) },
|
|
313
|
+
on_retry: lambda { |retry_event|
|
|
314
|
+
# The retry streams from the start: its counts replace these.
|
|
315
|
+
generation.usage = nil
|
|
316
|
+
generation.streamed_thinking = 0
|
|
317
|
+
generation.finish_reason = nil
|
|
318
|
+
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
319
|
+
},
|
|
320
|
+
images: request.images
|
|
321
|
+
)
|
|
322
|
+
rescue Client::RequestCancelled
|
|
323
|
+
raise unless cut?(generation_controller, turn.cancel_controller)
|
|
452
324
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
325
|
+
generation.cut = generation_controller.detail || {}
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
|
|
329
|
+
generation
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def stream_chunk(turn, generation, stream_splitter, chunk)
|
|
333
|
+
generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
|
|
334
|
+
# llama.cpp names the loaded model in the stream's last payload.
|
|
335
|
+
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
336
|
+
generation.served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
337
|
+
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
338
|
+
split = stream_splitter.feed(chunk[:content])
|
|
339
|
+
turn.buffer << split[:text]
|
|
340
|
+
generation.streamed_thinking += split[:thinking].to_s.length
|
|
341
|
+
return unless turn.on_stream_event
|
|
342
|
+
|
|
343
|
+
emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
|
|
344
|
+
thinking: split[:thinking], payload: chunk[:payload])
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# The cut stream's visible text goes with it: the buffer as it was
|
|
348
|
+
# before (the next generation gets a fresh splitter).
|
|
349
|
+
def cut_generation(turn, generation, buffer_mark)
|
|
350
|
+
turn.buffer.slice!(buffer_mark..)
|
|
351
|
+
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: 0,
|
|
352
|
+
thinking_chars: generation.streamed_thinking, served_model: generation.served_model,
|
|
353
|
+
requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut[:by],
|
|
354
|
+
stop_reason: generation.cut[:reason])
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def completed_generation(turn, request, generation)
|
|
358
|
+
response = generation.response.to_s
|
|
359
|
+
turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
|
|
360
|
+
window: request.window)
|
|
361
|
+
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
362
|
+
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
363
|
+
served_model: generation.served_model, requested_model: turn.model_name,
|
|
364
|
+
finish_reason: generation.finish_reason)
|
|
365
|
+
dump_log("response", generation.response, iteration: turn.iteration)
|
|
366
|
+
# The after_generation hook (after the model returns, before the tool
|
|
367
|
+
# parse), with a read-only copy of the conversation as sent.
|
|
368
|
+
return unless @hooks
|
|
456
369
|
|
|
457
|
-
|
|
458
|
-
|
|
370
|
+
fire_hook(:after_generation, { type: :after_generation, iteration: turn.iteration, response: generation.response,
|
|
371
|
+
messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
|
|
372
|
+
end
|
|
459
373
|
|
|
460
|
-
|
|
374
|
+
# A generation a plugin cut is an empty answer made early: asked again
|
|
375
|
+
# with its own nudge while the retry budget lasts (queued input goes in
|
|
376
|
+
# place of the nudge), else the turn ends as cancelled (hook), with
|
|
377
|
+
# nothing salvaged and without the spent nudge. A Stop that came right
|
|
378
|
+
# after the cut is a plain cancel. Returns :next or the turn's result.
|
|
379
|
+
def after_cut(turn, generation)
|
|
380
|
+
cut = generation.cut
|
|
381
|
+
raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
|
|
382
|
+
|
|
383
|
+
if turn.empty_retry.left?
|
|
384
|
+
return :next if inject_pending_input!(turn)
|
|
385
|
+
|
|
386
|
+
turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
|
|
387
|
+
emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
|
|
388
|
+
thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
|
|
389
|
+
return :next
|
|
390
|
+
end
|
|
391
|
+
turn.empty_retry.drop_nudge!(turn.conversation)
|
|
392
|
+
turn.cancel_controller.cancel!(:hook, cut)
|
|
393
|
+
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook)
|
|
394
|
+
cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# A generation with no tool calls: a Qwen call left open is asked to be
|
|
398
|
+
# finished; an empty answer is asked again (EmptyAnswerRetry: not when
|
|
399
|
+
# a length stop came with the context full); queued input keeps the
|
|
400
|
+
# turn going (the model answers it); else it is the answer. Returns
|
|
401
|
+
# :next or :answer.
|
|
402
|
+
def after_text(turn, request, generation)
|
|
403
|
+
turn.pending_tool_calls = false
|
|
404
|
+
if turn.qwen_partial && turn.qwen_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
|
|
405
|
+
turn.qwen_attempts += 1
|
|
406
|
+
turn.conversation << { role: "user", content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT, preserve_literals: true }
|
|
407
|
+
return :next
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
response = generation.response
|
|
411
|
+
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
412
|
+
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
413
|
+
retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
|
|
414
|
+
cancelled: turn.cancel_controller&.cancelled?,
|
|
415
|
+
finish_reason: generation.finish_reason,
|
|
416
|
+
used_tokens: generation.usage&.dig(:total_tokens),
|
|
417
|
+
window_tokens: request.window.tokens)
|
|
418
|
+
# The empty generation goes (its thinking would be sent again and
|
|
419
|
+
# prime the same loop); an empty answer that will be retried is no
|
|
420
|
+
# answer site, so a plugin's steer joins the retry.
|
|
421
|
+
turn.conversation.pop if retry_empty
|
|
422
|
+
# Queued steering keeps the turn going: the model answers the
|
|
423
|
+
# injected message instead of stopping here.
|
|
424
|
+
return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
|
|
425
|
+
|
|
426
|
+
if retry_empty
|
|
427
|
+
turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
|
|
428
|
+
emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
|
|
429
|
+
thinking_chars: thinking_chars(response.to_s, generation.streamed_thinking))
|
|
430
|
+
return :next
|
|
431
|
+
end
|
|
432
|
+
# The Engine's TurnNote.empty says it all: the spent nudge goes.
|
|
433
|
+
turn.empty_retry.drop_nudge!(turn.conversation) if empty && turn.empty_retry.used?
|
|
434
|
+
:answer
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The batch runs (ToolResponse) and its results go back as one entry.
|
|
438
|
+
def dispatch_calls(turn, calls)
|
|
439
|
+
turn.qwen_attempts = 0
|
|
440
|
+
turn.qwen_partial = nil
|
|
441
|
+
runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
|
|
442
|
+
on_stream_event: turn.on_stream_event, cap: turn.cap)
|
|
443
|
+
turn.tool_activity.concat(ToolResponse.activities(runs))
|
|
444
|
+
# One entry for the batch: the full outputs joined (ToolResponse.joined).
|
|
445
|
+
turn.conversation << ToolResponse.joined(runs)
|
|
446
|
+
turn.pending_tool_calls = true
|
|
447
|
+
:next
|
|
448
|
+
end
|
|
449
|
+
|
|
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.
|
|
453
|
+
def finish(turn)
|
|
454
|
+
exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
|
|
455
|
+
output = strip_thought_blocks(last_model_content(turn.conversation))
|
|
456
|
+
output = parser.strip_tool_calls(output) if exhausted
|
|
457
|
+
LLM::ModelResult.new(
|
|
458
|
+
text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
|
|
459
|
+
conversation: duplicate_conversation(turn.conversation),
|
|
460
|
+
exhausted: exhausted,
|
|
461
|
+
pending_tool_calls: turn.pending_tool_calls,
|
|
462
|
+
tool_activity: turn.tool_activity,
|
|
463
|
+
context_status: turn.context.display
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def emit(turn, **event)
|
|
468
|
+
emit_stream_event(turn.on_stream_event, event)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Queued input at an iteration boundary (Steer.inject!); +answer+ is a
|
|
472
|
+
# proc, built only on a merge. Returns true when anything was injected.
|
|
473
|
+
def inject_pending_input!(turn, answer: nil)
|
|
474
|
+
Steer.inject!(turn.conversation, turn.pending_input, iteration: turn.iteration, emit: turn.emit,
|
|
475
|
+
cancel_controller: turn.cancel_controller, answer: answer)
|
|
461
476
|
end
|
|
462
477
|
|
|
463
478
|
def emit_stream_event(callback, event)
|
|
@@ -466,34 +481,6 @@ module Samagotchi
|
|
|
466
481
|
nil
|
|
467
482
|
end
|
|
468
483
|
|
|
469
|
-
# Drain the pending input queue (if any) and, when messages are waiting,
|
|
470
|
-
# append them as ONE merged user message at the conversation tail and emit
|
|
471
|
-
# :pending_input_merged. Tail-append only: head mutation would invalidate
|
|
472
|
-
# the server-side prefix KV cache. Returns true when a message was injected.
|
|
473
|
-
# A plugin's steers (Steer) follow the user's message, each its own
|
|
474
|
-
# message; after an answer the Engine's drain has already dropped them.
|
|
475
|
-
# After a cancel the input stays queued: it runs as the next turn instead
|
|
476
|
-
# of dying with this one. +answer+ (a proc, called only on a merge) is the
|
|
477
|
-
# answer the merge follows: the UIs show it, the turn summary has only the
|
|
478
|
-
# last one.
|
|
479
|
-
def inject_pending_input!(conversation, pending_input, on_stream_event, iteration, cancel_controller = nil, answer: nil)
|
|
480
|
-
return false unless pending_input
|
|
481
|
-
return false if cancel_controller&.cancelled?
|
|
482
|
-
|
|
483
|
-
merge = Steer.merge(Steer.drain(pending_input, at_answer: !answer.nil?))
|
|
484
|
-
return false if merge.empty?
|
|
485
|
-
|
|
486
|
-
answer = answer.call.to_s if answer
|
|
487
|
-
conversation.concat(merge.messages)
|
|
488
|
-
emit_stream_event(
|
|
489
|
-
on_stream_event,
|
|
490
|
-
type: :pending_input_merged,
|
|
491
|
-
iteration: iteration,
|
|
492
|
-
**merge.event_fields,
|
|
493
|
-
answer: answer.to_s.strip.empty? ? nil : answer
|
|
494
|
-
)
|
|
495
|
-
true
|
|
496
|
-
end
|
|
497
484
|
|
|
498
485
|
# ── Hook dispatch helper ───────────────────────────────────────────────────
|
|
499
486
|
|
|
@@ -553,21 +540,20 @@ module Samagotchi
|
|
|
553
540
|
generation_controller&.cancelled? && !cancel_controller.cancelled?
|
|
554
541
|
end
|
|
555
542
|
|
|
556
|
-
|
|
543
|
+
# +sampling+: this request's (EmptyAnswerRetry#request_sampling).
|
|
544
|
+
def complete_kwargs(cancel_controller:, model_name: nil, on_chunk: nil, on_retry: nil, images: [], sampling: nil)
|
|
557
545
|
kwargs = {}
|
|
558
546
|
# Only a request with images names them: a text-only call is unchanged.
|
|
559
547
|
kwargs[:images] = images unless images.empty?
|
|
560
548
|
kwargs[:on_chunk] = on_chunk if on_chunk
|
|
561
|
-
kwargs[:on_retry] = on_retry if on_retry
|
|
562
|
-
kwargs[:cancel_controller] = cancel_controller if cancel_controller
|
|
563
|
-
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
|
|
564
552
|
n_predict = completion_n_predict
|
|
565
|
-
kwargs[:n_predict] = n_predict if n_predict
|
|
553
|
+
kwargs[:n_predict] = n_predict if n_predict
|
|
566
554
|
resolved_model_name = completion_model_name(model_name)
|
|
567
|
-
kwargs[:model] = resolved_model_name if resolved_model_name
|
|
568
|
-
sampling =
|
|
569
|
-
@retry_generation = false
|
|
570
|
-
kwargs[:sampling] = sampling if sampling && !sampling.empty? && client_supports_keyword?(:sampling)
|
|
555
|
+
kwargs[:model] = resolved_model_name if resolved_model_name
|
|
556
|
+
kwargs[:sampling] = sampling if sampling && !sampling.empty?
|
|
571
557
|
kwargs
|
|
572
558
|
end
|
|
573
559
|
|
|
@@ -580,19 +566,6 @@ module Samagotchi
|
|
|
580
566
|
ModelProfile.required_model_name(override)
|
|
581
567
|
end
|
|
582
568
|
|
|
583
|
-
def client_supports_keyword?(keyword)
|
|
584
|
-
@client_complete_keyword_support ||= {}
|
|
585
|
-
return @client_complete_keyword_support[keyword] if @client_complete_keyword_support.key?(keyword)
|
|
586
|
-
|
|
587
|
-
@client_complete_keyword_support[keyword] = begin
|
|
588
|
-
parameters = @client.method(:complete).parameters
|
|
589
|
-
parameters.any? { |kind, name| (kind == :key || kind == :keyreq) && name == keyword } ||
|
|
590
|
-
parameters.any? { |kind, _name| kind == :keyrest }
|
|
591
|
-
rescue StandardError
|
|
592
|
-
false
|
|
593
|
-
end
|
|
594
|
-
end
|
|
595
|
-
|
|
596
569
|
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
|
|
597
570
|
partial = partial_assistant_text.to_s.strip
|
|
598
571
|
conversation = duplicate_conversation(conversation)
|
|
@@ -603,11 +576,9 @@ module Samagotchi
|
|
|
603
576
|
unless partial.empty?
|
|
604
577
|
conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
|
|
605
578
|
end
|
|
606
|
-
|
|
607
|
-
|
|
579
|
+
LLM::ModelResult.new(
|
|
580
|
+
text: "",
|
|
608
581
|
conversation: conversation,
|
|
609
|
-
exhausted: false,
|
|
610
|
-
pending_tool_calls: false,
|
|
611
582
|
tool_activity: tool_activity,
|
|
612
583
|
canceled: true,
|
|
613
584
|
cancellation_reason: reason
|
|
@@ -619,7 +590,7 @@ module Samagotchi
|
|
|
619
590
|
def dump_log(event, payload, **fields)
|
|
620
591
|
return unless Log.level?(:debug)
|
|
621
592
|
|
|
622
|
-
Log.debug(:model, event, payload: payload, model: @
|
|
593
|
+
Log.debug(:model, event, payload: payload, model: @turn_settings.model_name, **fields)
|
|
623
594
|
end
|
|
624
595
|
|
|
625
596
|
# Estimate context usage for this iteration's prompt (ContextStatus#observe)
|
|
@@ -627,7 +598,7 @@ module Samagotchi
|
|
|
627
598
|
# :context_status event. The telemetry is not for the model (it used to
|
|
628
599
|
# be injected as a synthetic system message).
|
|
629
600
|
def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
|
|
630
|
-
event = context.observe(prompt, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
|
|
601
|
+
event = context.observe(prompt.length, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
|
|
631
602
|
return unless event
|
|
632
603
|
|
|
633
604
|
emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
|
|
@@ -644,13 +615,6 @@ module Samagotchi
|
|
|
644
615
|
strip_thought_blocks(text)
|
|
645
616
|
end
|
|
646
617
|
|
|
647
|
-
# The status line's context value ({est_pct:, bucket:}) for +used_tokens+
|
|
648
|
-
# of +window_tokens+; nil without both, or with context.status off. The
|
|
649
|
-
# chat loop builds its value with it too.
|
|
650
|
-
def context_display(used_tokens:, window_tokens:)
|
|
651
|
-
ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
|
|
652
|
-
end
|
|
653
|
-
|
|
654
618
|
# Per-profile parse strategy. Rebuilt when the active profile changes
|
|
655
619
|
# (the profile may be re-inferred per run when not explicitly pinned).
|
|
656
620
|
# Public so other loops (and specs) can reach the active profile's parser.
|
|
@@ -696,11 +660,6 @@ module Samagotchi
|
|
|
696
660
|
messages.map(&:dup)
|
|
697
661
|
end
|
|
698
662
|
|
|
699
|
-
def drop_last_empty_retry!(conversation)
|
|
700
|
-
index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
|
|
701
|
-
conversation.delete_at(index) if index
|
|
702
|
-
end
|
|
703
|
-
|
|
704
663
|
def last_model_content(conversation)
|
|
705
664
|
message = conversation.reverse.find { |entry| entry[:role] == "model" }
|
|
706
665
|
message ? message[:content].to_s : ""
|
|
@@ -760,19 +719,5 @@ module Samagotchi
|
|
|
760
719
|
activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
|
|
761
720
|
}
|
|
762
721
|
end
|
|
763
|
-
|
|
764
|
-
# ask_user_question: validate the call, then hand the payload to the
|
|
765
|
-
# Engine's question flow (question_handler, which blocks until the user
|
|
766
|
-
# answers). Without one (headless), the payload as JSON so the model sees
|
|
767
|
-
# the options and can ask in plain text.
|
|
768
|
-
def handle_ask_user_question(call)
|
|
769
|
-
payload = Samagotchi::Tools::AskUserQuestion.validate(call)
|
|
770
|
-
return payload if payload.is_a?(String)
|
|
771
|
-
return JSON.pretty_generate(payload) unless @question_handler
|
|
772
|
-
|
|
773
|
-
@question_handler.call(payload).to_s
|
|
774
|
-
rescue => e
|
|
775
|
-
"Error: ask_user_question handler failed: #{e.message}"
|
|
776
|
-
end
|
|
777
722
|
end
|
|
778
723
|
end
|