samagotchi 0.97.0 → 0.98.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 +30 -1
- data/docs/architecture.md +10 -2
- data/docs/bundle-authoring.md +5 -3
- data/docs/cli.md +8 -3
- data/docs/hooks.md +59 -0
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +18 -0
- data/docs/plugins.md +8 -1
- data/docs/sessions.md +1 -1
- 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 +26 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
- data/lib/samagotchi/hooks.rb +4 -1
- data/lib/samagotchi/llm/errors.rb +51 -17
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
- data/lib/samagotchi/output_formatter.rb +8 -2
- data/lib/samagotchi/pending_question.rb +7 -3
- data/lib/samagotchi/plugin/api.rb +5 -0
- data/lib/samagotchi/question_asked.rb +83 -0
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/testing/bundle.rb +18 -1
- 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 +2 -1
|
@@ -69,9 +69,9 @@ module Samagotchi
|
|
|
69
69
|
|
|
70
70
|
hook = MemoryBundle::BundleHook.parse(raw_meta)
|
|
71
71
|
# A hook with no declared event cannot be auto-registered.
|
|
72
|
-
next if hook.
|
|
72
|
+
next if hook.events.empty?
|
|
73
73
|
|
|
74
|
-
fail_closed = hook.
|
|
74
|
+
fail_closed = hook.events.any? { |event| hook.fail_closed?(event) }
|
|
75
75
|
what = "hook #{basename} (bundle #{bundle_name})"
|
|
76
76
|
file = hooks_dir && File.join(hooks_dir, basename)
|
|
77
77
|
unless file && File.exist?(file)
|
|
@@ -98,9 +98,10 @@ module Samagotchi
|
|
|
98
98
|
loaded
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
# Build one hook file and register it in +registry+ under
|
|
102
|
-
#
|
|
103
|
-
#
|
|
101
|
+
# Build one hook file and register it in +registry+ under each of
|
|
102
|
+
# its events (one instance for all: its state is shared), with its
|
|
103
|
+
# priority and error policy (a fail_closed before_tool_call one
|
|
104
|
+
# denies when it raises). No missing-file, sha or requires_chi
|
|
104
105
|
# check: #load makes them; the test kit loads a bundle's source
|
|
105
106
|
# this way.
|
|
106
107
|
# @param hook [MemoryBundle::BundleHook] its manifest entry
|
|
@@ -109,17 +110,19 @@ module Samagotchi
|
|
|
109
110
|
# @raise [Exception] what loading or building the file raised;
|
|
110
111
|
# nothing is registered then
|
|
111
112
|
def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil)
|
|
112
|
-
event_sym = hook.event.to_sym
|
|
113
|
-
fail_closed = hook.on_error == "fail_closed" && event_sym == :before_tool_call
|
|
114
113
|
plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
114
|
+
hook.events.each do |event_name|
|
|
115
|
+
event_sym = event_name.to_sym
|
|
116
|
+
fail_closed = hook.fail_closed?(event_sym)
|
|
117
|
+
policy = if fail_closed then :deny
|
|
118
|
+
elsif hook.on_error == "log" then :log
|
|
119
|
+
else :skip
|
|
120
|
+
end
|
|
121
|
+
label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
|
|
122
|
+
handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
|
|
123
|
+
fields: { bundle: bundle_name }) { |event| plugin.call(event) }
|
|
124
|
+
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
|
|
125
|
+
end
|
|
123
126
|
nil
|
|
124
127
|
end
|
|
125
128
|
|
data/lib/samagotchi/hooks.rb
CHANGED
|
@@ -24,12 +24,15 @@ module Samagotchi
|
|
|
24
24
|
# :before_tool_call — before a tool is dispatched: vote with event[:guardrail].deny!/ask!
|
|
25
25
|
# (or the older event[:blocked]=true + event[:block_reason]); the
|
|
26
26
|
# event also has context: and targets: (docs/guardrails.md)
|
|
27
|
+
# :question_asked — a question of the model's (ask_user_question) or an approval of its
|
|
28
|
+
# call is about to open: event[:refs] attaches refs to the asking
|
|
29
|
+
# reply (QuestionAsked)
|
|
27
30
|
# :after_tool_call — after tool execution, before result injection
|
|
28
31
|
module Hooks
|
|
29
32
|
# Every event a hook can be registered for, in a turn's order
|
|
30
33
|
# (docs/hooks.md). Plugin::Api#on takes any symbol: `chi bundle check`
|
|
31
34
|
# warns on one not here.
|
|
32
35
|
EVENTS = %i[session_start before_turn before_generation generation_progress after_generation before_tool_call
|
|
33
|
-
after_tool_call after_turn session_end].freeze
|
|
36
|
+
question_asked after_tool_call after_turn session_end].freeze
|
|
34
37
|
end
|
|
35
38
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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",
|
|
@@ -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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "../client_id"
|
|
|
14
14
|
require_relative "../delivery"
|
|
15
15
|
require_relative "../answer_tail"
|
|
16
16
|
require_relative "../answer_refs"
|
|
17
|
+
require_relative "../question_asked"
|
|
17
18
|
require_relative "../bridge_client"
|
|
18
19
|
require_relative "../bridge/bounded_queue"
|
|
19
20
|
require_relative "../bridge/card_store"
|
|
@@ -839,7 +840,7 @@ module Samagotchi
|
|
|
839
840
|
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
840
841
|
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
841
842
|
init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
|
|
842
|
-
cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
|
|
843
|
+
cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
|
|
843
844
|
# The composer's / autocomplete; the built-ins until a worker
|
|
844
845
|
# names its plugins' too.
|
|
845
846
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
@@ -871,13 +872,28 @@ module Samagotchi
|
|
|
871
872
|
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
872
873
|
# REPL's (it shares nothing with the web).
|
|
873
874
|
# A question between turns (the step-limit one) is the snapshot's own.
|
|
875
|
+
# Its refs (the asking reply's, QuestionAsked) are checked again.
|
|
874
876
|
def shown_pending_question(session, turn_snapshot, owner)
|
|
875
|
-
if turn_snapshot
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
877
|
+
pending = if turn_snapshot
|
|
878
|
+
current_turn = turn_snapshot["current_turn"]
|
|
879
|
+
current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
|
|
880
|
+
elsif owner&.worker?
|
|
881
|
+
session.pending_question&.to_h
|
|
882
|
+
end
|
|
883
|
+
with_question_refs(pending, cwd: session.working_directory)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# A pending question (a snapshot's, the session file's or a saved
|
|
887
|
+
# card's) with its refs checked again, under a question's caps
|
|
888
|
+
# (QuestionAsked): a stored file is never trusted, and what isn't
|
|
889
|
+
# valid any more is dropped. The question as it was without refs.
|
|
890
|
+
def with_question_refs(pending, cwd:)
|
|
891
|
+
return pending unless pending.is_a?(Hash)
|
|
892
|
+
|
|
893
|
+
key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
|
|
894
|
+
refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
|
|
895
|
+
max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
|
|
896
|
+
refs.empty? ? pending.except(key) : pending.merge(key => refs)
|
|
881
897
|
end
|
|
882
898
|
|
|
883
899
|
# The full show's session part: the file's, with what a live worker's
|
|
@@ -1031,11 +1047,16 @@ module Samagotchi
|
|
|
1031
1047
|
# cards: CardStore#list), else (no live worker) the ones the last
|
|
1032
1048
|
# worker saved in the session's folder (#saved_cards), a card's body as
|
|
1033
1049
|
# body_html: rendered markdown when the renderer is on, else the
|
|
1034
|
-
# escaped text in a <pre
|
|
1050
|
+
# escaped text in a <pre>, and a question card's refs checked again
|
|
1051
|
+
# (#with_question_refs; +cwd+ the session's folder, nil where the
|
|
1052
|
+
# session file isn't read: a file ref then stays only absolute).
|
|
1035
1053
|
# @return [Array<Hash>]
|
|
1036
|
-
def cards_for_display(cards)
|
|
1054
|
+
def cards_for_display(cards, cwd: nil)
|
|
1037
1055
|
Array(cards).filter_map do |card|
|
|
1038
1056
|
next unless card.is_a?(Hash)
|
|
1057
|
+
if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
|
|
1058
|
+
next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
|
|
1059
|
+
end
|
|
1039
1060
|
next card unless card["type"].to_s == "card"
|
|
1040
1061
|
|
|
1041
1062
|
card.merge("body_html" => card_body_html(card["body"].to_s))
|