samagotchi 0.97.0 → 0.99.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 +50 -1
- data/docs/architecture.md +11 -3
- data/docs/bundle-authoring.md +5 -3
- data/docs/cli.md +8 -3
- data/docs/configuration.md +8 -0
- data/docs/guardrails.md +8 -0
- data/docs/hooks.md +64 -0
- data/docs/internals/anthropic.md +79 -8
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +31 -3
- data/docs/internals/llm-context-forget.md +2 -1
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/plugins.md +24 -2
- data/docs/sessions.md +11 -1
- data/docs/testing.md +27 -0
- data/lib/samagotchi/answer_refs.rb +16 -9
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +91 -10
- data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
- data/lib/samagotchi/hooks.rb +4 -1
- data/lib/samagotchi/kernel_loop.rb +15 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
- data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
- data/lib/samagotchi/llm/chat_loop.rb +76 -7
- data/lib/samagotchi/llm/dialect.rb +8 -5
- data/lib/samagotchi/llm/errors.rb +51 -17
- data/lib/samagotchi/llm/refusal_memo.rb +6 -0
- data/lib/samagotchi/llm/turn_settings.rb +13 -5
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
- data/lib/samagotchi/message_parts.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +8 -2
- data/lib/samagotchi/pending_question.rb +7 -3
- data/lib/samagotchi/plugin/api.rb +6 -1
- data/lib/samagotchi/plugin/context.rb +21 -2
- data/lib/samagotchi/prompt.rb +3 -0
- data/lib/samagotchi/question_asked.rb +83 -0
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/testing/bundle.rb +20 -1
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/testing/recorder.rb +13 -2
- data/lib/samagotchi/tool_loads.rb +62 -0
- data/lib/samagotchi/tool_response.rb +5 -1
- data/lib/samagotchi/tool_runner.rb +29 -7
- data/lib/samagotchi/tools/builtin_calls.rb +16 -0
- data/lib/samagotchi/tools/deferred_tools.rb +80 -0
- data/lib/samagotchi/tools/registry.rb +7 -2
- data/lib/samagotchi/tools/tool_index.rb +88 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -9
- data/lib/samagotchi/web/public/app.js +44 -11
- data/lib/samagotchi/web/public/index.html +36 -13
- data/lib/samagotchi/web/public/info_bar_model.js +0 -2
- data/lib/samagotchi/web/public/lists_view.js +32 -8
- data/lib/samagotchi/web/public/markdown_place.js +25 -8
- data/lib/samagotchi/web/public/question_cards.js +23 -6
- data/lib/samagotchi/web/public/session_rename.js +88 -15
- data/lib/samagotchi/web/public/session_title.js +6 -0
- data/lib/samagotchi/web/public/stage_frame.js +24 -14
- data/lib/samagotchi/web/public/stage_model.js +4 -2
- data/lib/samagotchi/web/public/stage_slots.js +41 -9
- data/lib/samagotchi/web/public/stage_view.js +149 -8
- data/lib/samagotchi/web/public/stream_handlers.js +5 -1
- data/lib/samagotchi/web/public/turn_model.js +20 -0
- data/lib/samagotchi/web/public/turn_view.js +22 -9
- metadata +4 -1
|
@@ -191,12 +191,24 @@ module Samagotchi
|
|
|
191
191
|
elsif tools_unsupported?
|
|
192
192
|
"host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
|
|
193
193
|
else
|
|
194
|
-
"host #{host} rejected the request: #{
|
|
194
|
+
"host #{host} rejected the request: #{hinted_detail}"
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
private
|
|
199
199
|
|
|
200
|
+
# The detail with the hint, ahead of a model list the detail has (a
|
|
201
|
+
# 404's "Available models: …", cut at the message's limit), else at
|
|
202
|
+
# its end.
|
|
203
|
+
def hinted_detail
|
|
204
|
+
return detail unless hint
|
|
205
|
+
|
|
206
|
+
list_at = detail.index(ProviderErrors::AVAILABLE_MODELS_START_RE)
|
|
207
|
+
return "#{detail}; #{hint}" unless list_at
|
|
208
|
+
|
|
209
|
+
"#{detail[0...list_at].sub(/[\s.;:]+\z/, "")}; #{hint} #{detail[list_at..].lstrip}"
|
|
210
|
+
end
|
|
211
|
+
|
|
200
212
|
# The detail up to the end of the sentence that says the model takes no
|
|
201
213
|
# tools: what a provider adds after it (OpenRouter: "Try disabling
|
|
202
214
|
# "execute"…" and a routing docs link) is advice for its own UI.
|
|
@@ -395,6 +407,8 @@ module Samagotchi
|
|
|
395
407
|
# third of the name, in a name at least HINT_FUZZY_MIN long.
|
|
396
408
|
HINT_MAX_EDITS = 2
|
|
397
409
|
HINT_FUZZY_MIN = 6
|
|
410
|
+
# The most ids a hint names.
|
|
411
|
+
HINT_SUGGESTIONS = 3
|
|
398
412
|
# A 404's model it couldn't find: "Model 'X' not found", "model `X`
|
|
399
413
|
# does not exist", "model X-1 not found" (unquoted, an id-like word,
|
|
400
414
|
# checked after the match). Bounded quantifiers: a server's long token
|
|
@@ -402,6 +416,8 @@ module Samagotchi
|
|
|
402
416
|
MODEL_NOT_FOUND_RE = /model\s+(?:["'`]([^"'`\s]{1,#{HINT_ID_MAX}})["'`]|([^"'`\s]{1,#{HINT_ID_MAX}}))\s+(?:was\s+)?(?:not found|does not exist)/i
|
|
403
417
|
# The ids a 404 lists after "Available models:".
|
|
404
418
|
AVAILABLE_MODELS_RE = /available models?:\s*(.+)\z/im
|
|
419
|
+
# Where that list starts, for the summary to put its hint ahead of it.
|
|
420
|
+
AVAILABLE_MODELS_START_RE = /\s*\bavailable models?:/i
|
|
405
421
|
# The HTTP status an Anthropic error.type stands for, for an error
|
|
406
422
|
# object that carries no numeric code (a Messages stream's error event,
|
|
407
423
|
# an OpenAI error whose code is null or a name).
|
|
@@ -528,11 +544,10 @@ module Samagotchi
|
|
|
528
544
|
|
|
529
545
|
# "did you mean 'ID'?" for a 404 that names the model it couldn't find
|
|
530
546
|
# and lists the ones it has (oMLX: "Model 'X' not found. Available
|
|
531
|
-
# models: a, b, …")
|
|
532
|
-
#
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
# HINT_* bounds; nil when there is no list, nothing close or a tie.
|
|
547
|
+
# models: a, b, …"), naming up to HINT_SUGGESTIONS ids
|
|
548
|
+
# (#closest_model_ids: "did you mean 'A' or 'B'?"). Read from the
|
|
549
|
+
# message past the summary's cut (MESSAGE_LIMIT), within the HINT_*
|
|
550
|
+
# bounds; nil when there is no list or nothing close.
|
|
536
551
|
def model_hint(message)
|
|
537
552
|
message = message[0, HINT_SCAN_CHARS]
|
|
538
553
|
match = MODEL_NOT_FOUND_RE.match(message)
|
|
@@ -547,26 +562,45 @@ module Samagotchi
|
|
|
547
562
|
.reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
|
|
548
563
|
return nil if ids.empty? || ids.include?(requested)
|
|
549
564
|
|
|
550
|
-
close =
|
|
551
|
-
|
|
565
|
+
close = closest_model_ids(requested, ids)
|
|
566
|
+
"did you mean #{close.map { |id| "'#{id}'" }.join(" or ")}?" unless close.empty?
|
|
552
567
|
end
|
|
553
568
|
|
|
554
|
-
# The
|
|
555
|
-
#
|
|
556
|
-
|
|
569
|
+
# The listed ids closest to +requested+, at most HINT_SUGGESTIONS, in
|
|
570
|
+
# the first tier that has any: the ids that are +requested+ but for
|
|
571
|
+
# case, or whose last segment (after "/" or "--") is; else the ids a
|
|
572
|
+
# typo away (#typo_distance, whole or last segment), the nearest
|
|
573
|
+
# only; else the ids that contain +requested+ (HINT_FUZZY_MIN
|
|
574
|
+
# characters or more) or whose last segment it contains, the
|
|
575
|
+
# shortest first.
|
|
576
|
+
def closest_model_ids(requested, ids)
|
|
557
577
|
probe = requested.downcase
|
|
558
|
-
exact = ids.
|
|
559
|
-
return exact
|
|
578
|
+
exact = ids.select { |id| id.downcase == probe || last_segment(id) == probe }
|
|
579
|
+
return exact.first(HINT_SUGGESTIONS) unless exact.empty?
|
|
560
580
|
|
|
561
581
|
scored = ids.filter_map do |id|
|
|
562
582
|
distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
|
|
563
583
|
[distance, id] if distance
|
|
564
584
|
end
|
|
565
|
-
|
|
585
|
+
unless scored.empty?
|
|
586
|
+
best = scored.map(&:first).min
|
|
587
|
+
return scored.select { |distance, _| distance == best }.map(&:last).first(HINT_SUGGESTIONS)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
containing_ids(probe, ids)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# The ids that contain +probe+ or whose last segment +probe+ contains
|
|
594
|
+
# (either one HINT_FUZZY_MIN characters or more), closest in length
|
|
595
|
+
# first, at most HINT_SUGGESTIONS.
|
|
596
|
+
def containing_ids(probe, ids)
|
|
597
|
+
return [] if probe.length < HINT_FUZZY_MIN
|
|
566
598
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
599
|
+
ids.select do |id|
|
|
600
|
+
segment = last_segment(id)
|
|
601
|
+
id.downcase.include?(probe) || (segment.length >= HINT_FUZZY_MIN && probe.include?(segment))
|
|
602
|
+
end.each_with_index.sort_by { |id, index| [(id.length - probe.length).abs, index] }
|
|
603
|
+
.first(HINT_SUGGESTIONS).map(&:first)
|
|
570
604
|
end
|
|
571
605
|
|
|
572
606
|
# The edit distance of +a+ and +b+ when it is a typo's: at most
|
|
@@ -23,6 +23,12 @@ module Samagotchi
|
|
|
23
23
|
# Whether +kind+ is remembered for +model+ (nil: the host).
|
|
24
24
|
def refused?(kind, model = nil) = @mutex.synchronize { @facts.key?([kind, model]) }
|
|
25
25
|
|
|
26
|
+
# Add +item+ to the list remembered for +kind+ and +model+ (a value
|
|
27
|
+
# that grows: the deferred tools a model's host refused, one by one).
|
|
28
|
+
def add(kind, model, item)
|
|
29
|
+
@mutex.synchronize { (@facts[[kind, model]] ||= []) << item unless Array(@facts[[kind, model]]).include?(item) }
|
|
30
|
+
end
|
|
31
|
+
|
|
26
32
|
# The value remembered for +kind+ and +model+, or nil.
|
|
27
33
|
def [](kind, model = nil) = @mutex.synchronize { @facts[[kind, model]] }
|
|
28
34
|
end
|
|
@@ -15,14 +15,22 @@ module Samagotchi
|
|
|
15
15
|
# window (ContextWindow.setting, nil for none) and LLM context strategy
|
|
16
16
|
# (LLMContextStrategy.resolve, nil for none), and its price on its host
|
|
17
17
|
# (hosts.<name>.models.<id>.price, a ModelPrice; nil for none), what
|
|
18
|
-
# SessionMetrics estimates an unreported cost from
|
|
19
|
-
#
|
|
18
|
+
# SessionMetrics estimates an unreported cost from, and whether the turn's
|
|
19
|
+
# host takes tools it doesn't declare up front (+loads_tools+:
|
|
20
|
+
# AnthropicMessages#loads_tools?; a direct call of a reachable tool then
|
|
21
|
+
# runs). The Engine sets them once per turn, in Engine#generate, after
|
|
22
|
+
# the model's host is synced.
|
|
20
23
|
TurnSettings = Data.define(:vision, :sampling, :thinking, :reasoning_history, :model_name, :window_setting,
|
|
21
|
-
:llm_context, :price) do
|
|
24
|
+
:llm_context, :price, :loads_tools) do
|
|
25
|
+
def initialize(vision:, sampling:, thinking:, reasoning_history:, model_name:, window_setting:, llm_context:,
|
|
26
|
+
price:, loads_tools: false)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
22
30
|
# No vision, no sampling, the default thinking, no reasoning sent
|
|
23
|
-
# back, no model name.
|
|
31
|
+
# back, no model name, no tools loaded.
|
|
24
32
|
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, reasoning_history: :drop,
|
|
25
|
-
model_name: nil, window_setting: nil, llm_context: nil, price: nil)
|
|
33
|
+
model_name: nil, window_setting: nil, llm_context: nil, price: nil, loads_tools: false)
|
|
26
34
|
end
|
|
27
35
|
end
|
|
28
36
|
end
|
|
@@ -192,7 +192,7 @@ module Samagotchi
|
|
|
192
192
|
id = entry[:tool_call_id].to_s
|
|
193
193
|
matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
|
|
194
194
|
call = matches.one? ? matches.first.last : nil
|
|
195
|
-
call &&= Tools::BuiltinCalls.unwrap_saved(call, entry[:content])
|
|
195
|
+
call &&= Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, entry[:content]), entry[:content])
|
|
196
196
|
texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
|
|
197
197
|
return texts ? [[refs.first, call, texts.first.text]] : []
|
|
198
198
|
end
|
|
@@ -201,7 +201,7 @@ module Samagotchi
|
|
|
201
201
|
# built-in (BuiltinCalls.unwrap_saved).
|
|
202
202
|
calls = batch.map(&:last)
|
|
203
203
|
calls = calls.zip(ToolResponse.runs(entry[:content], calls.size)).map do |call, run|
|
|
204
|
-
Tools::BuiltinCalls.unwrap_saved(call, run&.text)
|
|
204
|
+
Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, run&.text), run&.text)
|
|
205
205
|
end
|
|
206
206
|
texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
|
|
207
207
|
return [] unless texts && texts.size == refs.size
|
|
@@ -6,15 +6,22 @@ module Samagotchi
|
|
|
6
6
|
module MemoryBundle
|
|
7
7
|
# One hook's metadata, as a bundle's manifest.yml declares it and its
|
|
8
8
|
# installed record (manifest.json) keeps it: the file's sha256
|
|
9
|
-
# ("sha256:<hex>", "" when none is recorded), the
|
|
10
|
-
# for (
|
|
11
|
-
#
|
|
12
|
-
|
|
9
|
+
# ("sha256:<hex>", "" when none is recorded), the events it registers
|
|
10
|
+
# for (manifest `event:` for one, `events:` a list; none: it isn't
|
|
11
|
+
# loaded), on_error (skip, log or fail_closed) and priority. One
|
|
12
|
+
# instance of the hook serves every event it lists. The one place the
|
|
13
|
+
# defaults live.
|
|
14
|
+
class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
|
|
13
15
|
DEFAULT_ON_ERROR = "skip"
|
|
14
16
|
DEFAULT_PRIORITY = 100
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
# @param event [String, nil] one event (manifest `event:`)
|
|
19
|
+
# @param events [Array<String>, String, nil] the events (manifest
|
|
20
|
+
# `events:`); `event:` and `events:` together are joined, blanks
|
|
21
|
+
# and repeats dropped
|
|
22
|
+
def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
|
|
23
|
+
names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
24
|
+
super(sha256: sha256, events: names, on_error: on_error, priority: priority)
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
# @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
|
|
@@ -25,7 +32,7 @@ module Samagotchi
|
|
|
25
32
|
|
|
26
33
|
h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
|
|
27
34
|
on_error = h["on_error"].to_s.strip
|
|
28
|
-
new(sha256: prefixed(h["sha256"]), event: h["event"]
|
|
35
|
+
new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
|
|
29
36
|
on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
|
|
30
37
|
priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
|
|
31
38
|
end
|
|
@@ -44,8 +51,20 @@ module Samagotchi
|
|
|
44
51
|
# The sha's hex, "" when none is recorded.
|
|
45
52
|
def hex = sha256.delete_prefix("sha256:")
|
|
46
53
|
|
|
47
|
-
#
|
|
48
|
-
|
|
54
|
+
# Whether the hook fails closed: on_error fail_closed applies to its
|
|
55
|
+
# before_tool_call registration only.
|
|
56
|
+
def fail_closed?(event) = on_error == "fail_closed" && event.to_s == "before_tool_call"
|
|
57
|
+
|
|
58
|
+
# How `chi bundle status` / `diff` name its events: `event=x`, or
|
|
59
|
+
# `events=x,y` for more than one.
|
|
60
|
+
def events_field = events.size > 1 ? "events=#{events.join(",")}" : "event=#{events.first}"
|
|
61
|
+
|
|
62
|
+
# The string-keyed form manifest.yml and manifest.json hold: `event`
|
|
63
|
+
# for one event (as an older chi reads it), `events` for more.
|
|
64
|
+
def to_record
|
|
65
|
+
listed = events.size > 1 ? { "events" => events.dup } : { "event" => events.first.to_s }
|
|
66
|
+
{ "sha256" => sha256, **listed, "on_error" => on_error, "priority" => priority }
|
|
67
|
+
end
|
|
49
68
|
end
|
|
50
69
|
end
|
|
51
70
|
end
|
|
@@ -375,9 +375,26 @@ module Samagotchi
|
|
|
375
375
|
next unless k.is_a?(String) && !k.empty?
|
|
376
376
|
next unless v.is_a?(Hash)
|
|
377
377
|
|
|
378
|
+
check_hook_events(k, v)
|
|
378
379
|
acc[k] = BundleHook.parse(v)
|
|
379
380
|
end
|
|
380
381
|
end
|
|
382
|
+
|
|
383
|
+
HOOK_EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
384
|
+
|
|
385
|
+
# A hook's events: list holds event names (one instance registered
|
|
386
|
+
# for each); it and event: are one or the other.
|
|
387
|
+
def check_hook_events(file, entry)
|
|
388
|
+
return unless entry.key?("events")
|
|
389
|
+
|
|
390
|
+
raw = entry["events"]
|
|
391
|
+
raise ValidationError, "hooks: #{file}: give event: or events:, not both" if entry.key?("event")
|
|
392
|
+
raise ValidationError, "hooks: #{file}: events: must be a list of event names" unless raw.is_a?(Array) && !raw.empty?
|
|
393
|
+
|
|
394
|
+
raw.each do |item|
|
|
395
|
+
raise ValidationError, "hooks: #{file}: events: #{item.inspect} is not an event name" unless item.is_a?(String) && item.strip.match?(HOOK_EVENT_NAME)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
381
398
|
end
|
|
382
399
|
end
|
|
383
400
|
end
|
|
@@ -237,7 +237,8 @@ module Samagotchi
|
|
|
237
237
|
tool_id: nil, marks: {})
|
|
238
238
|
# A built-in reached through call_tool reloads as that built-in's
|
|
239
239
|
# row, with via (its saved params and label are the built-in's).
|
|
240
|
-
call
|
|
240
|
+
# A direct call of an MCP tool reloads as the call_tool row it ran as.
|
|
241
|
+
call = Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, output), output)
|
|
241
242
|
name = call[:name].to_s
|
|
242
243
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
243
244
|
part = { tool: name, params: params.to_s }
|
|
@@ -65,6 +65,10 @@ module Samagotchi
|
|
|
65
65
|
# Qwen tool_call XML blocks that survive when a tool call is rendered as text
|
|
66
66
|
# e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
|
|
67
67
|
QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
|
|
68
|
+
# Gemma 4's closed tool-call block, <|tool_call>call:NAME{…}<tool_call|>:
|
|
69
|
+
# a native step's call, whose body #strip_markup drops (#strip keeps it:
|
|
70
|
+
# a streamed chunk may hold only one marker).
|
|
71
|
+
GEMMA_TOOL_CALL_RE = /<\|tool_call>.*?<tool_call\|>/m
|
|
68
72
|
|
|
69
73
|
module_function
|
|
70
74
|
|
|
@@ -85,9 +89,11 @@ module Samagotchi
|
|
|
85
89
|
|
|
86
90
|
# The same tokens as #strip, but the text keeps its layout (indentation,
|
|
87
91
|
# runs of spaces); only the blank lines removed blocks leave collapse.
|
|
88
|
-
# For showing a whole saved message (the attached TUI's join, the web history)
|
|
92
|
+
# For showing a whole saved message (the attached TUI's join, the web history):
|
|
93
|
+
# a native Gemma step's closed tool-call blocks go with their bodies, as
|
|
94
|
+
# Qwen's do (a step that only calls a tool has no text).
|
|
89
95
|
def strip_markup(text)
|
|
90
|
-
remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
|
|
96
|
+
remove_tokens(text.to_s.gsub(GEMMA_TOOL_CALL_RE, "")).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
|
|
91
97
|
end
|
|
92
98
|
|
|
93
99
|
def remove_tokens(text)
|
|
@@ -17,15 +17,19 @@ module Samagotchi
|
|
|
17
17
|
# relayed approval's facts (Relay.card); limit, context: the step-limit
|
|
18
18
|
# question's turn limit and offer context (ContinueOffer); status:
|
|
19
19
|
# "pending" or "cancelled"; created_at: iso8601; relayed_to: the relay
|
|
20
|
-
# mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate)
|
|
21
|
-
#
|
|
20
|
+
# mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate);
|
|
21
|
+
# refs: the refs on the reply that asks ({match:, kind:, target:, …},
|
|
22
|
+
# the :question_asked hooks', QuestionAsked; display only, checked again
|
|
23
|
+
# where a saved one reaches a UI).
|
|
24
|
+
# approval, relay, context, relayed_to and refs stay as given (string
|
|
22
25
|
# keys inside after a load). A field may be nil (unset).
|
|
23
26
|
#
|
|
24
27
|
# Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
|
|
25
28
|
# a UI (symbol keys), #to_file for session.json (string keys); both in
|
|
26
29
|
# member order with unset fields left out. .from_file reads either back.
|
|
27
30
|
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :effects, :user_only,
|
|
28
|
-
:kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to
|
|
31
|
+
:kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to,
|
|
32
|
+
:refs) do
|
|
29
33
|
def initialize(**fields)
|
|
30
34
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
31
35
|
end
|
|
@@ -27,6 +27,11 @@ module Samagotchi
|
|
|
27
27
|
# one (ctx.sessions.children) is listed when it came later. Keys have
|
|
28
28
|
# no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
|
|
29
29
|
# model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
|
|
30
|
+
# Events aren't listed: chi.on takes any symbol and an older chi never
|
|
31
|
+
# fires one it doesn't know (harmless; chi bundle check warns on it),
|
|
32
|
+
# and a manifest hook's events: list (BundleHook) needs the bundle's
|
|
33
|
+
# requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
|
|
34
|
+
# scan covers manifest.yml.
|
|
30
35
|
SINCE = {
|
|
31
36
|
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
32
37
|
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
@@ -35,7 +40,7 @@ module Samagotchi
|
|
|
35
40
|
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
36
41
|
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
37
42
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
38
|
-
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
|
|
43
|
+
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0"
|
|
39
44
|
}.freeze
|
|
40
45
|
|
|
41
46
|
# @param bundle [String]
|
|
@@ -48,7 +48,9 @@ module Samagotchi
|
|
|
48
48
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
49
49
|
# +toolkit+ returns the session's toolkit name, +declared_tools+ the
|
|
50
50
|
# tool names its next turn declares, +use_toolkit+ takes a name and
|
|
51
|
-
# returns whether it staged it (Engine#use_toolkit)
|
|
51
|
+
# returns whether it staged it (Engine#use_toolkit); +load_tools+ takes
|
|
52
|
+
# (names, bundle) and returns the wire names it staged
|
|
53
|
+
# (Engine#load_tools).
|
|
52
54
|
# +cancel_controller+ takes (handler:) and returns the controller a
|
|
53
55
|
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
54
56
|
# else the running turn's for work done for it (the turn's thread, or a
|
|
@@ -56,7 +58,7 @@ module Samagotchi
|
|
|
56
58
|
# +cost+ returns the session's spend now (SessionMetrics::Cost).
|
|
57
59
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
58
60
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
59
|
-
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
61
|
+
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
|
|
60
62
|
:cancel_controller, :cost, keyword_init: true)
|
|
61
63
|
|
|
62
64
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
@@ -182,6 +184,23 @@ module Samagotchi
|
|
|
182
184
|
# @raise [ArgumentError] a toolkit no one declared
|
|
183
185
|
def use_toolkit(name) = !!@host.use_toolkit&.call(name.to_s)
|
|
184
186
|
|
|
187
|
+
# Load tools the session reaches but doesn't declare, so the model
|
|
188
|
+
# calls them by name (docs/plugins.md): built-in names, MCP tools'
|
|
189
|
+
# acts_as (mcp_fs_read) or <group>/<tool>. A hint, not access: the
|
|
190
|
+
# tools stay behind the same guardrails, and only a host that takes
|
|
191
|
+
# tool changes (api: anthropic) gets one; elsewhere nothing is sent.
|
|
192
|
+
# Called from before_turn, they load for that turn; anywhere else,
|
|
193
|
+
# from the next turn's start. Idempotent: a declared tool, one loaded
|
|
194
|
+
# before, a removed or unknown one is skipped.
|
|
195
|
+
# @param names [Array<String>]
|
|
196
|
+
# @return [Array<String>] the names staged (wire names)
|
|
197
|
+
# @raise [ArgumentError] +names+ isn't an Array
|
|
198
|
+
def load_tools(names)
|
|
199
|
+
raise ArgumentError, "load_tools takes an Array of tool names" unless names.is_a?(Array)
|
|
200
|
+
|
|
201
|
+
Array(@host.load_tools&.call(names.map(&:to_s), @bundle))
|
|
202
|
+
end
|
|
203
|
+
|
|
185
204
|
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
186
205
|
def scratch? = !!@host.scratch&.call
|
|
187
206
|
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "prompt_literal_guard"
|
|
|
5
5
|
require_relative "vision_context"
|
|
6
6
|
require_relative "tool_response"
|
|
7
7
|
require_relative "steer"
|
|
8
|
+
require_relative "tool_loads"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# Formats a message list into a prompt string ready for the /completion endpoint.
|
|
@@ -48,6 +49,8 @@ module Samagotchi
|
|
|
48
49
|
# the model's answer (Thinking.native: Qwen's empty thought)
|
|
49
50
|
# @return [Array(String, Array<String>)]
|
|
50
51
|
def self.format_with_images(messages, profile: ModelProfile.default, vision: nil, prefill: "")
|
|
52
|
+
# A plugin's tool change (ToolLoads) is for a Messages host only.
|
|
53
|
+
messages = messages.reject { |message| ToolLoads.entry?(message) }
|
|
51
54
|
plan = ImagePlan.new(messages, vision, data: :base64)
|
|
52
55
|
images = []
|
|
53
56
|
suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "answer_refs"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "output_formatter"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The :question_asked hook event: a question of the model's own (its
|
|
9
|
+
# ask_user_question, kind "question") or an approval of its tool call
|
|
10
|
+
# (kind "approval") is about to open. Fired from the two Engine seams
|
|
11
|
+
# (Engine#request_question, the guardrail wiring's ask:), never from
|
|
12
|
+
# QuestionDesk, so a relayed delegate approval, a hook's ask_user and the
|
|
13
|
+
# step-limit question never fire it.
|
|
14
|
+
#
|
|
15
|
+
# A hook attaches refs to the reply that asks (event[:refs], after_turn's
|
|
16
|
+
# call shape and checks, AnswerRefs): they are kept as `display_refs` on
|
|
17
|
+
# the step's model message (the live conversation's last model entry,
|
|
18
|
+
# changed in place: the loops and the forget layer hold it by identity),
|
|
19
|
+
# display only, and go on the pending question (PendingQuestion#refs), so
|
|
20
|
+
# a UI pairs them in the asking text, on the card and in the history.
|
|
21
|
+
#
|
|
22
|
+
# The hooks run on the turn thread before the question opens: their time
|
|
23
|
+
# is the question's wait. A raising hook is logged (Registry#fire) and the
|
|
24
|
+
# question opens without its refs; event[:ask_user] is a no-op (nil).
|
|
25
|
+
module QuestionAsked
|
|
26
|
+
EVENT = :question_asked
|
|
27
|
+
KIND_QUESTION = "question"
|
|
28
|
+
KIND_APPROVAL = "approval"
|
|
29
|
+
# Lower than an answer's (AnswerRefs): cards.json holds up to 20 cards.
|
|
30
|
+
MAX_REFS = 50
|
|
31
|
+
MAX_BYTES = 32 * 1024
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Fire the event and return the question's fields with the step's refs
|
|
36
|
+
# (+fields+ as given when it has none, or with no subscriber: then
|
|
37
|
+
# nothing is built).
|
|
38
|
+
# @param hooks [Hooks::Registry]
|
|
39
|
+
# @param conversation [Array<Hash>, nil] the running turn's live
|
|
40
|
+
# conversation (Engine#live_conversation)
|
|
41
|
+
# @param kind [String] KIND_QUESTION or KIND_APPROVAL
|
|
42
|
+
# @param fields [Hash] the question's fields (no id yet)
|
|
43
|
+
# @param cwd [String, nil] the session's folder, for file refs
|
|
44
|
+
# @param synchronize [#call, nil] runs the step's change under the
|
|
45
|
+
# event lock (a snapshot reads the conversation under it)
|
|
46
|
+
# @param visible [#call, nil] (content) → the step's text as the asking
|
|
47
|
+
# slot shows it; default OutputFormatter.strip_markup (the Engine's
|
|
48
|
+
# drops the native loop's tool-call bodies first)
|
|
49
|
+
# @return [Hash] the fields to open the question with
|
|
50
|
+
def fire(hooks:, conversation:, kind:, fields:, cwd:, synchronize: nil, visible: nil)
|
|
51
|
+
return fields unless hooks&.any?(EVENT)
|
|
52
|
+
|
|
53
|
+
step = Array(conversation).reverse_each.find { |m| m.is_a?(Hash) && (m[:role] || m["role"]).to_s == "model" }
|
|
54
|
+
refs = AnswerRefs.new(step ? [step] : [], cwd: cwd, max_refs: MAX_REFS, max_bytes: MAX_BYTES)
|
|
55
|
+
visible ||= ->(content) { OutputFormatter.strip_markup(content) }
|
|
56
|
+
event = { type: EVENT, kind: kind, text: step ? visible.call((step[:content] || step["content"]).to_s) : "",
|
|
57
|
+
question: deep_freeze(Marshal.load(Marshal.dump(fields.to_h))),
|
|
58
|
+
# A question from here would wait inside the one opening.
|
|
59
|
+
ask_user: ->(**) {} }
|
|
60
|
+
event[:refs] = refs.adder(event)
|
|
61
|
+
hooks.fire(EVENT, event)
|
|
62
|
+
refs.close!
|
|
63
|
+
list = refs.to_a
|
|
64
|
+
if refs.added?
|
|
65
|
+
store = -> { step[AnswerRefs::KEY] = list }
|
|
66
|
+
synchronize ? synchronize.call(&store) : store.call
|
|
67
|
+
end
|
|
68
|
+
# The step's whole list: a second question in it gets the first's too.
|
|
69
|
+
list.empty? ? fields : fields.merge(refs: list)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Log.warn(:hooks, "question_asked_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
72
|
+
fields
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_freeze(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash then value.each_value { |v| deep_freeze(v) }
|
|
78
|
+
when Array then value.each { |v| deep_freeze(v) }
|
|
79
|
+
end
|
|
80
|
+
value.freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -120,10 +120,11 @@ module Samagotchi
|
|
|
120
120
|
# question_handler, called on the turn thread with the payload
|
|
121
121
|
# Tools::AskUserQuestion.validate made. Opens it (#open_question) and
|
|
122
122
|
# returns the answer as JSON for the tool result.
|
|
123
|
-
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
123
|
+
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:},
|
|
124
|
+
# and refs: (the asking step's, QuestionAsked) when it has any
|
|
124
125
|
# @return [String] normalized answer JSON
|
|
125
126
|
def request(payload)
|
|
126
|
-
result = open_question(payload.slice(:question, :options, :header)
|
|
127
|
+
result = open_question(payload.slice(:question, :options, :header, :refs)
|
|
127
128
|
.merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
|
|
128
129
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
129
130
|
# tool failure the model learns to avoid the tool from.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../plugin/loader"
|
|
4
4
|
require_relative "../plugin/tool_spec"
|
|
5
5
|
require_relative "../hooks"
|
|
6
|
+
require_relative "../question_asked"
|
|
6
7
|
require_relative "../commands/registry"
|
|
7
8
|
require_relative "../session_commands"
|
|
8
9
|
require_relative "../tools/builtins"
|
|
@@ -179,7 +180,7 @@ module Samagotchi
|
|
|
179
180
|
end
|
|
180
181
|
@hooks_namespace = Module.new
|
|
181
182
|
@manifest.hooks.each do |basename, hook|
|
|
182
|
-
next if hook.
|
|
183
|
+
next if hook.events.empty?
|
|
183
184
|
|
|
184
185
|
Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
|
|
185
186
|
settings: @settings, namespace: @hooks_namespace)
|
|
@@ -203,6 +204,22 @@ module Samagotchi
|
|
|
203
204
|
payload
|
|
204
205
|
end
|
|
205
206
|
|
|
207
|
+
# Fire :question_asked as chi's two seams do (QuestionAsked.fire): a
|
|
208
|
+
# question (+kind+ "question") or an approval ("approval") asked by a
|
|
209
|
+
# step whose text is +text+, with the question's +question+ fields.
|
|
210
|
+
# The hooks' refs are checked as chi checks them (the question caps,
|
|
211
|
+
# file refs under +cwd+, default the session's folder).
|
|
212
|
+
# @return [Array<Hash>] the refs the step and its pending question
|
|
213
|
+
# get, as stored; [] for none
|
|
214
|
+
def question_asked(text, kind: "question", question: {}, cwd: nil)
|
|
215
|
+
step = { role: "model", content: text.to_s }
|
|
216
|
+
fields = failing do
|
|
217
|
+
QuestionAsked.fire(hooks: hooks, conversation: [step], kind: kind.to_s, fields: question.to_h,
|
|
218
|
+
cwd: cwd || @recorder[:cwd])
|
|
219
|
+
end
|
|
220
|
+
Array(fields[:refs])
|
|
221
|
+
end
|
|
222
|
+
|
|
206
223
|
# Build the before_tool_call payload from the real Gate, without firing
|
|
207
224
|
# this bundle's hooks.
|
|
208
225
|
# @param call [Hash] the parsed tool call
|
|
@@ -335,6 +352,8 @@ module Samagotchi
|
|
|
335
352
|
def questions = @recorder.questions
|
|
336
353
|
def model_requests = @recorder.model_requests
|
|
337
354
|
def toolkit_uses = @recorder.toolkit_uses
|
|
355
|
+
# Every ctx.load_tools call (ToolLoad: names, bundle).
|
|
356
|
+
def tool_loads = @recorder.tool_loads
|
|
338
357
|
# Queue ask_model answers: a String, an exception, or a callable.
|
|
339
358
|
def model_answers = @recorder.model_answers
|
|
340
359
|
|
|
@@ -47,6 +47,7 @@ module Samagotchi
|
|
|
47
47
|
toolkit: -> { recorder[:toolkit] },
|
|
48
48
|
declared_tools: -> { recorder[:declared_tools] || tool_names.call },
|
|
49
49
|
use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
|
|
50
|
+
load_tools: ->(names, bundle) { recorder.load_tools(names, bundle) },
|
|
50
51
|
cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
|
|
51
52
|
cost: -> { cost(recorder[:cost]) }
|
|
52
53
|
)
|
|
@@ -12,6 +12,8 @@ module Samagotchi
|
|
|
12
12
|
Notice = Data.define(:text, :level, :hook, :fallback_for)
|
|
13
13
|
# Text put into the running turn (ctx.steer, event[:steer]).
|
|
14
14
|
Steer = Data.define(:text, :hook)
|
|
15
|
+
# A ctx.load_tools call: the names given, and the bundle.
|
|
16
|
+
ToolLoad = Data.define(:names, :bundle)
|
|
15
17
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
16
18
|
Stop = Data.define(:kind, :reason, :hook)
|
|
17
19
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
@@ -32,13 +34,13 @@ module Samagotchi
|
|
|
32
34
|
DEFAULTS = {
|
|
33
35
|
session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
|
|
34
36
|
model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
|
|
35
|
-
prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true,
|
|
37
|
+
prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true, load_tools_staged: nil,
|
|
36
38
|
# ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
|
|
37
39
|
# (reported:, estimated:, side:, known:) or a callable giving one.
|
|
38
40
|
cost: nil
|
|
39
41
|
}.freeze
|
|
40
42
|
|
|
41
|
-
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
|
|
43
|
+
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
|
|
42
44
|
:model_answers, :values
|
|
43
45
|
|
|
44
46
|
def initialize(**values)
|
|
@@ -53,6 +55,7 @@ module Samagotchi
|
|
|
53
55
|
@questions = []
|
|
54
56
|
@model_requests = []
|
|
55
57
|
@toolkit_uses = []
|
|
58
|
+
@tool_loads = []
|
|
56
59
|
@answers = []
|
|
57
60
|
@model_answers = []
|
|
58
61
|
@mutex = Mutex.new
|
|
@@ -127,6 +130,14 @@ module Samagotchi
|
|
|
127
130
|
answer.respond_to?(:call) ? answer.call(entry) : answer
|
|
128
131
|
end
|
|
129
132
|
|
|
133
|
+
# ctx.load_tools: recorded; every name is staged unless the test set
|
|
134
|
+
# load_tools_staged: (a list: those only).
|
|
135
|
+
def load_tools(names, bundle)
|
|
136
|
+
record(@tool_loads, ToolLoad.new(names: names, bundle: bundle))
|
|
137
|
+
staged = self[:load_tools_staged]
|
|
138
|
+
staged ? names & Array(staged) : names
|
|
139
|
+
end
|
|
140
|
+
|
|
130
141
|
# ctx.use_toolkit: +known+ the toolkits chi and the plugins declare.
|
|
131
142
|
# Staged (toolkit_staged:, true by default), the session is on it at
|
|
132
143
|
# once, as chi saves it on the session (ToolkitBinding#use).
|