samagotchi 0.28.0 → 0.30.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 +84 -1
- data/docs/cli.md +4 -4
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/engine.rb +4 -3
- data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +27 -7
- data/lib/samagotchi/llm/chat_loop.rb +12 -6
- data/lib/samagotchi/memory_bundle/installer.rb +14 -0
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_commands.rb +6 -2
- data/lib/samagotchi/session_manager.rb +9 -2
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
- data/lib/samagotchi/terminal_ui.rb +35 -5
- data/lib/samagotchi/tool_activity.rb +38 -8
- data/lib/samagotchi/tools/builtins.rb +10 -7
- data/lib/samagotchi/tools/delegate.rb +14 -10
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/tools/task_runtime.rb +21 -0
- data/lib/samagotchi/turn_note.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +23 -11
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/index.html +51 -25
- data/lib/samagotchi/web/public/sessions_list.js +21 -4
- data/lib/samagotchi/web/public/stage_model.js +23 -2
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_model.js +29 -15
- data/lib/samagotchi/web/public/turn_view.js +70 -11
- data/lib/samagotchi/worker.rb +24 -7
- metadata +2 -1
|
@@ -197,7 +197,8 @@ module Samagotchi
|
|
|
197
197
|
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
|
|
198
198
|
stopped_by: turn.cancel_controller&.stopped_by)
|
|
199
199
|
return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
|
|
200
|
-
partial_assistant_text: turn.buffer
|
|
200
|
+
partial_assistant_text: turn.buffer,
|
|
201
|
+
streamed_thinking: turn.streamed_thinking)
|
|
201
202
|
end
|
|
202
203
|
finish(turn)
|
|
203
204
|
rescue StandardError => e
|
|
@@ -239,8 +240,8 @@ module Samagotchi
|
|
|
239
240
|
# context tracker and the retry budget, the tool activity, the visible
|
|
240
241
|
# text streamed so far (salvaged on a cancel), the Qwen recovery state,
|
|
241
242
|
# the prefill, and what the caller gave.
|
|
242
|
-
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :
|
|
243
|
-
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
243
|
+
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
|
|
244
|
+
:qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
244
245
|
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
|
|
245
246
|
# One request: the prompt and its images as sent, the images' token
|
|
246
247
|
# estimate, and the window it was measured against.
|
|
@@ -258,7 +259,8 @@ module Samagotchi
|
|
|
258
259
|
conversation = prepare_conversation(messages)
|
|
259
260
|
Turn.new(
|
|
260
261
|
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
261
|
-
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"",
|
|
262
|
+
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
263
|
+
qwen_attempts: 0, qwen_partial: nil,
|
|
262
264
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
263
265
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
264
266
|
# prompt starts with what the server already has cached.
|
|
@@ -329,6 +331,9 @@ module Samagotchi
|
|
|
329
331
|
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
330
332
|
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
331
333
|
buffer_mark = turn.buffer.length
|
|
334
|
+
# A cancel salvages only this generation's thinking: the earlier
|
|
335
|
+
# steps' is on their own messages already.
|
|
336
|
+
turn.streamed_thinking = +""
|
|
332
337
|
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
333
338
|
request_generation(
|
|
334
339
|
request.prompt,
|
|
@@ -342,6 +347,7 @@ module Samagotchi
|
|
|
342
347
|
generation.usage = nil
|
|
343
348
|
generation.cache = nil
|
|
344
349
|
generation.streamed_thinking = 0
|
|
350
|
+
turn.streamed_thinking = +""
|
|
345
351
|
generation.finish_reason = nil
|
|
346
352
|
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
347
353
|
},
|
|
@@ -368,17 +374,25 @@ module Samagotchi
|
|
|
368
374
|
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
369
375
|
split = stream_splitter.feed(chunk[:content])
|
|
370
376
|
turn.buffer << split[:text]
|
|
377
|
+
turn.streamed_thinking << split[:thinking].to_s
|
|
371
378
|
generation.streamed_thinking += split[:thinking].to_s.length
|
|
372
379
|
return unless turn.on_stream_event
|
|
373
380
|
|
|
374
381
|
event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
|
|
375
|
-
thinking: split[:thinking], payload: chunk[:payload] }
|
|
382
|
+
thinking: split[:thinking], payload: event_payload(chunk[:payload]) }
|
|
376
383
|
# A chunk of a tool call (its bytes are dropped from both lanes): a
|
|
377
384
|
# steer doesn't cut this generation (Engine#cut_for_steer).
|
|
378
385
|
event[:tool_call] = true if split[:tool]
|
|
379
386
|
emit(turn, **event)
|
|
380
387
|
end
|
|
381
388
|
|
|
389
|
+
# The payload a :generation_chunk event carries: llama.cpp's last chunk
|
|
390
|
+
# echoes the whole rendered prompt, which nothing reads and every SSE
|
|
391
|
+
# client would get (the kernel's own reads keep the full payload).
|
|
392
|
+
def event_payload(payload)
|
|
393
|
+
payload.is_a?(Hash) ? payload.except("prompt") : payload
|
|
394
|
+
end
|
|
395
|
+
|
|
382
396
|
# The payload's prompt-cache counts, kept as the largest seen.
|
|
383
397
|
def note_cache_counts(generation, payload)
|
|
384
398
|
counts = TokenUsage.from_payload(payload)
|
|
@@ -618,7 +632,7 @@ module Samagotchi
|
|
|
618
632
|
ModelProfile.required_model_name(override)
|
|
619
633
|
end
|
|
620
634
|
|
|
621
|
-
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
|
|
635
|
+
def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "", streamed_thinking: "")
|
|
622
636
|
partial = partial_assistant_text.to_s.strip
|
|
623
637
|
conversation = duplicate_conversation(conversation)
|
|
624
638
|
# Salvage the already-streamed visible reply (thought/tool_call lanes
|
|
@@ -626,7 +640,13 @@ module Samagotchi
|
|
|
626
640
|
# cannot leak) so a follow-up steering message continues with the model's
|
|
627
641
|
# half-finished work in context instead of losing it.
|
|
628
642
|
unless partial.empty?
|
|
629
|
-
|
|
643
|
+
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
|
|
644
|
+
# The thinking this generation streamed goes with it (the chat loop's
|
|
645
|
+
# with_thinking key, which MessageParts reads and the prompt doesn't):
|
|
646
|
+
# without it, reloading the cut step shows the empty answer it isn't.
|
|
647
|
+
thinking = streamed_thinking.to_s
|
|
648
|
+
message[:thinking] = thinking unless thinking.strip.empty?
|
|
649
|
+
conversation << message
|
|
630
650
|
end
|
|
631
651
|
LLM::ModelResult.new(
|
|
632
652
|
text: "",
|
|
@@ -338,8 +338,8 @@ module Samagotchi
|
|
|
338
338
|
return canceled(iteration, @cancel_controller.reason) if @cancel_controller&.cancelled?
|
|
339
339
|
|
|
340
340
|
inject_pending_input(iteration)
|
|
341
|
-
response, partial = generate(iteration)
|
|
342
|
-
return canceled(iteration, response, partial) if partial
|
|
341
|
+
response, partial, thought = generate(iteration)
|
|
342
|
+
return canceled(iteration, response, partial, thought) if partial
|
|
343
343
|
|
|
344
344
|
if response.cut
|
|
345
345
|
outcome = after_cut(iteration, response)
|
|
@@ -468,7 +468,7 @@ module Samagotchi
|
|
|
468
468
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
469
469
|
[response, nil]
|
|
470
470
|
rescue RequestCancelled => e
|
|
471
|
-
[e.reason, streamed]
|
|
471
|
+
[e.reason, streamed, thought]
|
|
472
472
|
end
|
|
473
473
|
|
|
474
474
|
# The request runs under the generation's own controller (the turn
|
|
@@ -593,11 +593,17 @@ module Samagotchi
|
|
|
593
593
|
end
|
|
594
594
|
|
|
595
595
|
# The text streamed before the cancel stays, marked [interrupted],
|
|
596
|
-
# as the native loop's salvage does
|
|
597
|
-
|
|
596
|
+
# as the native loop's salvage does; the reasoning this generation
|
|
597
|
+
# streamed rides along on the message (with_thinking's key).
|
|
598
|
+
def canceled(iteration, reason, partial = "", thinking = "")
|
|
598
599
|
emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
|
|
599
600
|
visible = @loop.strip_model_thought(partial.to_s).strip
|
|
600
|
-
|
|
601
|
+
unless visible.empty?
|
|
602
|
+
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true }
|
|
603
|
+
thought = thinking.to_s
|
|
604
|
+
message[:thinking] = thought unless thought.strip.empty?
|
|
605
|
+
@conversation << message
|
|
606
|
+
end
|
|
601
607
|
conversation = @loop.plain(@conversation)
|
|
602
608
|
conversation.last[:interrupted] = true unless visible.empty?
|
|
603
609
|
ModelResult.new(text: "", conversation: conversation, canceled: true,
|
|
@@ -303,6 +303,7 @@ module Samagotchi
|
|
|
303
303
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
304
304
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
305
305
|
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
306
|
+
warn_rules_requires_chi(manifest, incoming_rules)
|
|
306
307
|
|
|
307
308
|
# Files the previous version shipped and this one doesn't.
|
|
308
309
|
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
@@ -559,6 +560,19 @@ module Samagotchi
|
|
|
559
560
|
end
|
|
560
561
|
end
|
|
561
562
|
|
|
563
|
+
# GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
|
|
564
|
+
# requires_chi this chi doesn't meet, and a session that never loaded
|
|
565
|
+
# them records a required load failure (the gate refuses guarded calls
|
|
566
|
+
# until chi is updated): say so at install, as the hooks warning does.
|
|
567
|
+
def warn_rules_requires_chi(manifest, rule_files)
|
|
568
|
+
return if manifest.nil? || rule_files.empty?
|
|
569
|
+
|
|
570
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
571
|
+
@warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
|
|
572
|
+
"(chi update), guarded tool calls are refused"
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
562
576
|
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
563
577
|
# warning when its sha256 differs from the declared one or this chi
|
|
564
578
|
# doesn't meet requires_chi (the Engine then won't load it).
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
require_relative "log"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# The model ids each host last listed, saved in
|
|
12
|
+
# <state dir>/model_lists.json for the processes that spawn a worker but
|
|
13
|
+
# never list a host themselves: `chi send --new --model` and the
|
|
14
|
+
# `delegate` tool warn about an unknown model id up front from it
|
|
15
|
+
# (ModelProfile.model_warning) instead of only the worker's first turn
|
|
16
|
+
# failing. It is a cache of the hosts' own lists, so it never raises: no
|
|
17
|
+
# file, an unreadable one or bad JSON reads as no list at all. Only a
|
|
18
|
+
# successful listing writes (a host that is down keeps the ids we know),
|
|
19
|
+
# and a list older than TTL_SECONDS is no evidence about the host any
|
|
20
|
+
# more.
|
|
21
|
+
#
|
|
22
|
+
# { "<host name>" => { "ids" => ["…"], "at" => <epoch seconds> } }
|
|
23
|
+
module ModelListStore
|
|
24
|
+
FILE = "model_lists.json"
|
|
25
|
+
# When a saved list is too old to say what a host serves: a model may
|
|
26
|
+
# have been added or removed since.
|
|
27
|
+
TTL_SECONDS = 7 * 24 * 60 * 60
|
|
28
|
+
|
|
29
|
+
# One host's saved list: the ids it listed and when (+at+, epoch
|
|
30
|
+
# seconds).
|
|
31
|
+
Saved = Data.define(:host, :ids, :at) do
|
|
32
|
+
def age(now: Time.now.to_i) = now.to_i - at.to_i
|
|
33
|
+
|
|
34
|
+
# Too old to check an id against (ModelProfile.model_warning): a model
|
|
35
|
+
# may have been added or removed since.
|
|
36
|
+
def stale?(now: Time.now.to_i) = age(now: now) > ModelListStore::TTL_SECONDS
|
|
37
|
+
|
|
38
|
+
# A host's own list spells its ids, and resolution matches them by
|
|
39
|
+
# case (the registry's model index is downcased): so does this.
|
|
40
|
+
def known?(id) = ids.any? { |known| known.casecmp?(id.to_s.strip) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# @return [String] <state home>/samagotchi/model_lists.json
|
|
46
|
+
def path(env: ENV)
|
|
47
|
+
File.join(Paths.state_dir(env: env), FILE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Every saved list by host name, the lists as [Saved] (empty without a
|
|
51
|
+
# file, with bad JSON, or with nothing readable in it).
|
|
52
|
+
# @return [Hash{String => Saved}]
|
|
53
|
+
def read(env: ENV)
|
|
54
|
+
all(env: env).each_with_object({}) do |(name, entry), acc|
|
|
55
|
+
list = saved_from(name, entry)
|
|
56
|
+
acc[name] = list if list
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One host's saved list.
|
|
61
|
+
# @return [Saved, nil] nil without a file, with bad JSON, or with no
|
|
62
|
+
# entry for the host
|
|
63
|
+
def find(host, env: ENV)
|
|
64
|
+
read(env: env)[host.to_s.strip.downcase]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Save the ids +host+ just listed, keeping every other host's entry.
|
|
68
|
+
# Best effort: a store that can't be written never fails the listing
|
|
69
|
+
# that found the ids. An empty list is not saved (it is no evidence the
|
|
70
|
+
# host serves nothing: keep the ids we know).
|
|
71
|
+
# @return [Saved, nil] what was saved, nil when nothing was
|
|
72
|
+
def save(host, ids, at: Time.now.to_i, env: ENV)
|
|
73
|
+
name = host.to_s.strip.downcase
|
|
74
|
+
list = Array(ids).map { |id| id.to_s.strip }.reject(&:empty?).uniq
|
|
75
|
+
return nil if name.empty? || list.empty?
|
|
76
|
+
|
|
77
|
+
file = path(env: env)
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
79
|
+
# Under the lock, so two workers listing a host at once (or two
|
|
80
|
+
# threads of one registry) never drop each other's entries.
|
|
81
|
+
File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
82
|
+
lock.flock(File::LOCK_EX)
|
|
83
|
+
entries = all(env: env)
|
|
84
|
+
entries[name] = { "ids" => list, "at" => at.to_i }
|
|
85
|
+
AtomicFile.write(file, JSON.pretty_generate(entries) + "\n")
|
|
86
|
+
end
|
|
87
|
+
Saved.new(host: name, ids: list, at: at.to_i)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Log.warn(:model, "model_list_save_failed", host: host.to_s, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The whole file, host names downcased: {name => entry}.
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def all(env: ENV)
|
|
96
|
+
data = JSON.parse(File.read(path(env: env)))
|
|
97
|
+
return {} unless data.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
data.each_with_object({}) do |(name, entry), acc|
|
|
100
|
+
acc[name.to_s.strip.downcase] = entry if entry.is_a?(Hash)
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One +entry+ of the file as a Saved, or nil when it holds no ids or no
|
|
107
|
+
# time.
|
|
108
|
+
def saved_from(name, entry)
|
|
109
|
+
ids = Array(entry["ids"]).map { |id| id.to_s.strip }.reject(&:empty?)
|
|
110
|
+
at = entry["at"].to_i
|
|
111
|
+
return nil if ids.empty? || at <= 0
|
|
112
|
+
|
|
113
|
+
Saved.new(host: name, ids: ids, at: at)
|
|
114
|
+
end
|
|
115
|
+
private_class_method :saved_from
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -179,6 +179,49 @@ module Samagotchi
|
|
|
179
179
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# A warning when +model_name+ resolves to a model id a host's saved
|
|
183
|
+
# list doesn't have (ModelListStore: what a `chi models`, a /models or
|
|
184
|
+
# the web's GET /api/models last listed for that host), else nil.
|
|
185
|
+
# Called where a session is spawned without listing the hosts itself
|
|
186
|
+
# (`chi send --new --model`, the delegate tool), so a typo is
|
|
187
|
+
# named before the worker's first turn fails on it. Only a warning: some
|
|
188
|
+
# hosts serve ids they don't list (a one-model llama.cpp server takes any
|
|
189
|
+
# name, OpenRouter's :nitro variants), so the session starts anyway.
|
|
190
|
+
# Never asks a host.
|
|
191
|
+
#
|
|
192
|
+
# Only a ref that names a host is checked (its alias resolved first, as
|
|
193
|
+
# ModelRef.parse does), and only against that host's own list: a bare id
|
|
194
|
+
# goes to whichever host lists it (HostRegistry#host_for_model), so no
|
|
195
|
+
# single list can judge it. Nothing is checked either without a saved
|
|
196
|
+
# list for the host or with one older than ModelListStore::TTL_SECONDS
|
|
197
|
+
# (a week: the host may serve different models by now).
|
|
198
|
+
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
199
|
+
# entries); config.yml's by default
|
|
200
|
+
# @param lists [#read] ModelListStore by default
|
|
201
|
+
# @return [String, nil]
|
|
202
|
+
def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
|
|
203
|
+
require_relative "config"
|
|
204
|
+
require_relative "model_list_store"
|
|
205
|
+
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
206
|
+
lists ||= Samagotchi::ModelListStore
|
|
207
|
+
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
208
|
+
host = parsed.host_name
|
|
209
|
+
return nil unless host
|
|
210
|
+
|
|
211
|
+
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
212
|
+
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
213
|
+
|
|
214
|
+
unknown_model_message(parsed.id, host, list.ids)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The warning for an id the host doesn't list, with up to three close
|
|
218
|
+
# ids (Config.near_names) when there are any.
|
|
219
|
+
def self.unknown_model_message(id, host, ids)
|
|
220
|
+
near = Samagotchi::Config.near_names(id, ids).first(3)
|
|
221
|
+
hint = near.empty? ? "" : " (did you mean: #{near.join(", ")}?)"
|
|
222
|
+
"host '#{host}' doesn't list model '#{id}'#{hint}; started it anyway; `chi models` lists what the hosts serve"
|
|
223
|
+
end
|
|
224
|
+
|
|
182
225
|
# The prefix of "box:x" when box is a host config.yml has with
|
|
183
226
|
# enabled: false (left out of +hosts+), else nil.
|
|
184
227
|
def self.disabled_host_prefix(ref, hosts, env)
|
|
@@ -241,9 +241,10 @@ module Samagotchi
|
|
|
241
241
|
|
|
242
242
|
# A worker session like the web start page's: saved as running with the
|
|
243
243
|
# message before its worker spawns, so lists and the web show it at
|
|
244
|
-
# once. The full id, so a script can pass it on.
|
|
245
|
-
#
|
|
246
|
-
#
|
|
244
|
+
# once. The full id, so a script can pass it on. The model's host is
|
|
245
|
+
# checked here (in spawn_session, as in the web): an unknown one is
|
|
246
|
+
# refused before anything is spawned; an id the host's saved model list
|
|
247
|
+
# doesn't have starts with a warning on stderr.
|
|
247
248
|
# @return [Integer] the exit status
|
|
248
249
|
def run_new(prompt, options)
|
|
249
250
|
images = !@images.empty?
|
|
@@ -264,6 +265,7 @@ module Samagotchi
|
|
|
264
265
|
error_line("chi send: could not start a session: #{e.message}")
|
|
265
266
|
return 1
|
|
266
267
|
end
|
|
268
|
+
error_line("chi send: warning: #{session.model_warning}") if session.model_warning
|
|
267
269
|
if through_worker
|
|
268
270
|
return 1 unless deliver_new(session, prompt)
|
|
269
271
|
else
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -79,6 +79,11 @@ module Samagotchi
|
|
|
79
79
|
# sets it); not saved.
|
|
80
80
|
attr_accessor :seed_images_dropped
|
|
81
81
|
|
|
82
|
+
# The warning for a model id its host's saved list doesn't have
|
|
83
|
+
# (ModelProfile.model_warning; SessionManager.spawn_session sets it);
|
|
84
|
+
# not saved.
|
|
85
|
+
attr_accessor :model_warning
|
|
86
|
+
|
|
82
87
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
83
88
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
84
89
|
first_preview: "", test_run: false, pending_question: nil,
|
|
@@ -5,6 +5,7 @@ require_relative "model_profile"
|
|
|
5
5
|
require_relative "served_model"
|
|
6
6
|
require_relative "turn_flow"
|
|
7
7
|
require_relative "tools/execute"
|
|
8
|
+
require_relative "tools/builtins"
|
|
8
9
|
require_relative "commands/registry"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -207,12 +208,15 @@ module Samagotchi
|
|
|
207
208
|
end
|
|
208
209
|
|
|
209
210
|
# The output goes into the conversation for the next turn. Not saved
|
|
210
|
-
# here (as in the REPL, the next turn saves it).
|
|
211
|
+
# here (as in the REPL, the next turn saves it). No parent-session
|
|
212
|
+
# marker: the user runs this, not the model. A worker's `chi --model X`
|
|
213
|
+
# default is unset, though: it was that run's, not the default of a chi
|
|
214
|
+
# run here (Tools::Builtins.cli_model_unset).
|
|
211
215
|
def shell(text)
|
|
212
216
|
command = text.delete_prefix(SHELL_BANG_PREFIX).strip
|
|
213
217
|
return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
|
|
214
218
|
|
|
215
|
-
output = Samagotchi::Tools::Execute.call(command)
|
|
219
|
+
output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
|
|
216
220
|
@engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
|
|
217
221
|
# Rolling back past this would silently drop the command output.
|
|
218
222
|
@turn_flow.note_conversation_changed
|
|
@@ -130,14 +130,20 @@ module Samagotchi
|
|
|
130
130
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
131
131
|
# @param title [String, nil] what the lists show before the first turn
|
|
132
132
|
# (the prompt's preview by default, else the seed's first user message)
|
|
133
|
-
# @return [Session] with #seed_images_dropped: refs whose file was gone
|
|
133
|
+
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
134
|
+
# and #model_warning: an id its host's saved list doesn't have
|
|
134
135
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
135
136
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
136
137
|
title: nil)
|
|
137
138
|
sd = state_dir || Session.default_state_dir
|
|
138
139
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
139
|
-
# alias is retargeted), with the name as typed beside it.
|
|
140
|
+
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
141
|
+
# refuses here, before anything is saved or spawned; a model id the
|
|
142
|
+
# host's saved list doesn't have (ModelListStore) only warns
|
|
143
|
+
# (#model_warning on the session), as some hosts serve ids they don't
|
|
144
|
+
# list.
|
|
140
145
|
typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
|
|
146
|
+
model_warning = Samagotchi::ModelProfile.model_warning(typed)
|
|
141
147
|
ref = ConfigFile.model_ref(typed).ref
|
|
142
148
|
session = Session.new_session(
|
|
143
149
|
mode: mode,
|
|
@@ -149,6 +155,7 @@ module Samagotchi
|
|
|
149
155
|
parent_id: parent_id,
|
|
150
156
|
messages: messages
|
|
151
157
|
)
|
|
158
|
+
session.model_warning = model_warning
|
|
152
159
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
153
160
|
# the prompts), so the session starts idle.
|
|
154
161
|
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
@@ -481,9 +481,12 @@ module Samagotchi
|
|
|
481
481
|
# A turn that failed mid-stream never saw its generation end:
|
|
482
482
|
# what it got to still counts.
|
|
483
483
|
close_generation if @turn.gen_open
|
|
484
|
-
# A call still running when the turn ends
|
|
485
|
-
#
|
|
486
|
-
|
|
484
|
+
# A call still running when the turn ends never completes: its
|
|
485
|
+
# record says so, as the count already has it. A cancel stopped it
|
|
486
|
+
# ("stopped", as ToolActivity names a call the Stop cut); a
|
|
487
|
+
# failure left it "canceled".
|
|
488
|
+
cut = status == "canceled" ? "stopped" : "canceled"
|
|
489
|
+
@turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, cut) }
|
|
487
490
|
@turn.tool_calls_by_id.clear
|
|
488
491
|
finished_at = now
|
|
489
492
|
record = {
|
|
@@ -226,19 +226,22 @@ module Samagotchi
|
|
|
226
226
|
# A cancel's reason as the UIs name it (anything else as it is);
|
|
227
227
|
# spec/shared/labels_matrix.json pins it for both.
|
|
228
228
|
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
229
|
+
# A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
|
|
230
|
+
# not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
|
|
231
|
+
STOP_MARK = "■"
|
|
229
232
|
# Under a canceled prompt turn (a canceled continue is back where it
|
|
230
233
|
# started): its partial progress stays in the conversation.
|
|
231
234
|
ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
|
|
232
235
|
|
|
233
|
-
# "
|
|
234
|
-
# (+by+, turn_canceled's cancelled_by): "
|
|
236
|
+
# "■ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
|
|
237
|
+
# (+by+, turn_canceled's cancelled_by): "■ turn stopped by loop-guard"
|
|
235
238
|
def turn_canceled_line(reason, duration_ms, by: nil)
|
|
236
239
|
if reason.to_s == "hook" && !by.to_s.empty?
|
|
237
|
-
return "#{paint(
|
|
240
|
+
return "#{paint(STOP_MARK, 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
|
|
238
241
|
end
|
|
239
242
|
|
|
240
243
|
label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
|
|
241
|
-
"#{paint(
|
|
244
|
+
"#{paint(STOP_MARK, 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
|
|
242
245
|
end
|
|
243
246
|
|
|
244
247
|
# "✕ turn failed: <summary> · 2.0s"
|
|
@@ -16,6 +16,7 @@ require_relative "host_registry"
|
|
|
16
16
|
require_relative "session"
|
|
17
17
|
require_relative "owner_lock"
|
|
18
18
|
require_relative "bridge/card_store"
|
|
19
|
+
require_relative "bridge/pending_card"
|
|
19
20
|
require_relative "engine"
|
|
20
21
|
require_relative "guardrails/parent_approvals"
|
|
21
22
|
require_relative "tools/memory"
|
|
@@ -298,7 +299,10 @@ module Samagotchi
|
|
|
298
299
|
ensure
|
|
299
300
|
# However it ends (the early returns too; the Engine was built in
|
|
300
301
|
# #initialize): the anytime commands finish, the plugins' services
|
|
301
|
-
# stop (a server process).
|
|
302
|
+
# stop (a server process). Nothing resolves an open card any more, so
|
|
303
|
+
# its pending_card.json goes (a scratch session's folder is deleted
|
|
304
|
+
# below anyway; the others stay).
|
|
305
|
+
clear_pending_card
|
|
302
306
|
@engine&.shutdown
|
|
303
307
|
# A scratch session goes on every way out: /exit, Ctrl-D, an error,
|
|
304
308
|
# SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
|
|
@@ -308,6 +312,17 @@ module Samagotchi
|
|
|
308
312
|
|
|
309
313
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
310
314
|
|
|
315
|
+
# The session is over for this process (its exits and the ensure below):
|
|
316
|
+
# nothing will resolve the open card any more, so its pending_card.json
|
|
317
|
+
# goes (the Engine's TURN_END would clear it, but the REPL may end
|
|
318
|
+
# mid-turn).
|
|
319
|
+
def clear_pending_card
|
|
320
|
+
return unless @pending_card_handle
|
|
321
|
+
|
|
322
|
+
@pending_card_handle.observer.clear
|
|
323
|
+
@pending_card_handle = nil
|
|
324
|
+
end
|
|
325
|
+
|
|
311
326
|
# A -p --non-interactive turn that failed (bin/chi exits 1): the error,
|
|
312
327
|
# then how to go on. The Engine saved the failed turn with its prompt,
|
|
313
328
|
# so the session is kept and `chi --resume` takes it up again.
|
|
@@ -415,13 +430,28 @@ module Samagotchi
|
|
|
415
430
|
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
416
431
|
end
|
|
417
432
|
|
|
418
|
-
# The session's cards.json, kept as a worker's
|
|
419
|
-
# the session as a worker would): this REPL's
|
|
420
|
-
# the saved count, so a card an earlier worker
|
|
421
|
-
# turns back, and its own cards and notices are
|
|
433
|
+
# The session's cards.json and pending_card.json, kept as a worker's
|
|
434
|
+
# Bridge keeps them (it owns the session as a worker would): this REPL's
|
|
435
|
+
# kept turns count on from the saved count, so a card an earlier worker
|
|
436
|
+
# showed stays that many turns back, and its own cards and notices are
|
|
437
|
+
# saved for the next; an open card with actions is on disk, where
|
|
438
|
+
# `chi web`'s session hub reads it to mark the session as waiting.
|
|
422
439
|
def keep_cards(session)
|
|
423
440
|
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
424
441
|
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
442
|
+
keep_pending_card(session)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# A card a worker that died left isn't open: the file goes before the
|
|
446
|
+
# observer takes over, as Bridge#start does. Subscribed once: the REPL
|
|
447
|
+
# keeps one session, and a second observer would fold every card twice
|
|
448
|
+
# and clear the file at the first turn's end.
|
|
449
|
+
def keep_pending_card(session)
|
|
450
|
+
return if @pending_card_handle
|
|
451
|
+
|
|
452
|
+
pending = Bridge::PendingCard.new(Session.session_dir(session.id))
|
|
453
|
+
pending.clear
|
|
454
|
+
@pending_card_handle = @engine.subscribe(observer: pending)
|
|
425
455
|
end
|
|
426
456
|
|
|
427
457
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
@@ -12,6 +12,7 @@ require_relative "tools/edit"
|
|
|
12
12
|
require_relative "tools/task_create"
|
|
13
13
|
require_relative "tools/task_get"
|
|
14
14
|
require_relative "tools/task_list"
|
|
15
|
+
require_relative "tools/task_runtime"
|
|
15
16
|
require_relative "tools/task_stop"
|
|
16
17
|
require_relative "tools/task_wait"
|
|
17
18
|
require_relative "tools/web_fetch"
|
|
@@ -62,11 +63,13 @@ module Samagotchi
|
|
|
62
63
|
# command without its "cd … &&"): a file tool's path relative to +cwd+
|
|
63
64
|
# when it is under it, a command's description (the model's few words),
|
|
64
65
|
# else its first step and how many more (CommandSteps; its first line
|
|
65
|
-
# when it has no steps), a memory's name
|
|
66
|
-
# nil for the other tools (a plugin's row keeps its preview, the
|
|
66
|
+
# when it has no steps), a memory's name, a task tool's task command
|
|
67
|
+
# (task_title); nil for the other tools (a plugin's row keeps its preview, the
|
|
67
68
|
# params). +params+ stays the full key=value line (the TUI, the
|
|
68
69
|
# guardrails).
|
|
69
70
|
def tool_title(tool_name, call, cwd: nil)
|
|
71
|
+
return task_title(tool_name, call, cwd) if TASK_TOOLS.include?(tool_name)
|
|
72
|
+
|
|
70
73
|
if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
|
|
71
74
|
title = command_description(tool_name, call) || steps_title(call[:content])
|
|
72
75
|
return title if title
|
|
@@ -90,19 +93,42 @@ module Samagotchi
|
|
|
90
93
|
end
|
|
91
94
|
end
|
|
92
95
|
|
|
96
|
+
TASK_TOOLS = [Tools::TaskWait::NAME, Tools::TaskGet::NAME, Tools::TaskStop::NAME].freeze
|
|
97
|
+
|
|
98
|
+
# A task_wait / task_get / task_stop row's title: the task's command
|
|
99
|
+
# (its record under +cwd+, the process's directory when nil) as a
|
|
100
|
+
# command's title, and for a wait how long it waits ("npm test · up to
|
|
101
|
+
# 600s"). nil when there is no record: the row keeps its id.
|
|
102
|
+
def task_title(tool_name, call, cwd)
|
|
103
|
+
command = Tools::TaskRuntime.command_of(call[:content], root: cwd)
|
|
104
|
+
return nil unless command
|
|
105
|
+
|
|
106
|
+
suffix = ""
|
|
107
|
+
if tool_name == Tools::TaskWait::NAME
|
|
108
|
+
timeout = call[:timeout].to_s.strip
|
|
109
|
+
suffix = " · up to #{timeout.empty? ? Tools::TaskWait::TIMEOUT_DEFAULT : timeout.to_i}s"
|
|
110
|
+
end
|
|
111
|
+
room = TOOL_ACTIVITY_PREVIEW_LIMIT - suffix.length
|
|
112
|
+
text = steps_title(command, room) || command_title(command).gsub(/\s+/, " ")
|
|
113
|
+
return nil if text.empty?
|
|
114
|
+
|
|
115
|
+
text = "#{text[0, room - 1]}…" if text.length > room
|
|
116
|
+
"#{text}#{suffix}"
|
|
117
|
+
end
|
|
118
|
+
|
|
93
119
|
# A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
|
|
94
120
|
# eats the line.
|
|
95
121
|
LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
|
|
96
122
|
|
|
97
|
-
# "<first step> +N" (N the steps after it), cut to fit
|
|
98
|
-
# TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
|
|
99
|
-
def steps_title(command)
|
|
123
|
+
# "<first step> +N" (N the steps after it), cut to fit +limit+
|
|
124
|
+
# (TOOL_ACTIVITY_PREVIEW_LIMIT); nil when the command has no steps.
|
|
125
|
+
def steps_title(command, limit = TOOL_ACTIVITY_PREVIEW_LIMIT)
|
|
100
126
|
steps = CommandSteps.parse(command)&.steps
|
|
101
127
|
return nil unless steps
|
|
102
128
|
|
|
103
129
|
first = steps.first.text.gsub(/\s+/, " ")
|
|
104
130
|
more = steps.size > 1 ? " +#{steps.size - 1}" : ""
|
|
105
|
-
room =
|
|
131
|
+
room = limit - more.length
|
|
106
132
|
first = "#{first[0, room - 1]}…" if first.length > room
|
|
107
133
|
"#{first}#{more}"
|
|
108
134
|
end
|
|
@@ -150,10 +176,14 @@ module Samagotchi
|
|
|
150
176
|
# "error" for an "Error:" result, and for an execute whose command
|
|
151
177
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
152
178
|
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
153
|
-
# task_wait the user's Stop ended (the task itself runs on),
|
|
154
|
-
# whose task was stopped (the stop-task button, or the model's task_stop)
|
|
179
|
+
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
180
|
+
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
181
|
+
# and for a command the user's Stop killed or kept from starting: a
|
|
182
|
+
# cancel, not a failure (its text still starts "Error:" for the model).
|
|
155
183
|
def tool_activity_status(result, tool_name = nil)
|
|
156
184
|
text = result.to_s
|
|
185
|
+
return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
|
|
186
|
+
return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
|
|
157
187
|
return "error" if text.start_with?("Error:")
|
|
158
188
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
159
189
|
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|