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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +11 -3
  4. data/docs/bundle-authoring.md +5 -3
  5. data/docs/cli.md +8 -3
  6. data/docs/configuration.md +8 -0
  7. data/docs/guardrails.md +8 -0
  8. data/docs/hooks.md +64 -0
  9. data/docs/internals/anthropic.md +79 -8
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +31 -3
  12. data/docs/internals/llm-context-forget.md +2 -1
  13. data/docs/internals/prompt-caching.md +9 -0
  14. data/docs/plugins.md +24 -2
  15. data/docs/sessions.md +11 -1
  16. data/docs/testing.md +27 -0
  17. data/lib/samagotchi/answer_refs.rb +16 -9
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +91 -10
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/kernel_loop.rb +15 -2
  27. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  28. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  29. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  30. data/lib/samagotchi/llm/dialect.rb +8 -5
  31. data/lib/samagotchi/llm/errors.rb +51 -17
  32. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  33. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  34. data/lib/samagotchi/llm_context_stale.rb +2 -2
  35. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  36. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  37. data/lib/samagotchi/message_parts.rb +2 -1
  38. data/lib/samagotchi/output_formatter.rb +8 -2
  39. data/lib/samagotchi/pending_question.rb +7 -3
  40. data/lib/samagotchi/plugin/api.rb +6 -1
  41. data/lib/samagotchi/plugin/context.rb +21 -2
  42. data/lib/samagotchi/prompt.rb +3 -0
  43. data/lib/samagotchi/question_asked.rb +83 -0
  44. data/lib/samagotchi/question_desk.rb +3 -2
  45. data/lib/samagotchi/testing/bundle.rb +20 -1
  46. data/lib/samagotchi/testing/host.rb +1 -0
  47. data/lib/samagotchi/testing/recorder.rb +13 -2
  48. data/lib/samagotchi/tool_loads.rb +62 -0
  49. data/lib/samagotchi/tool_response.rb +5 -1
  50. data/lib/samagotchi/tool_runner.rb +29 -7
  51. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  52. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  53. data/lib/samagotchi/tools/registry.rb +7 -2
  54. data/lib/samagotchi/tools/tool_index.rb +88 -6
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +30 -9
  57. data/lib/samagotchi/web/public/app.js +44 -11
  58. data/lib/samagotchi/web/public/index.html +36 -13
  59. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  60. data/lib/samagotchi/web/public/lists_view.js +32 -8
  61. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  62. data/lib/samagotchi/web/public/question_cards.js +23 -6
  63. data/lib/samagotchi/web/public/session_rename.js +88 -15
  64. data/lib/samagotchi/web/public/session_title.js +6 -0
  65. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  66. data/lib/samagotchi/web/public/stage_model.js +4 -2
  67. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  68. data/lib/samagotchi/web/public/stage_view.js +149 -8
  69. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  70. data/lib/samagotchi/web/public/turn_model.js +20 -0
  71. data/lib/samagotchi/web/public/turn_view.js +22 -9
  72. 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: #{detail}#{"; #{hint}" if hint}"
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, …"): the listed id that is X but for case, the one
532
- # whose last segment (after "/" or "--") is X, else the one id a
533
- # typo away (#typo_distance, whole or its last segment). Read from
534
- # the message past the summary's cut (MESSAGE_LIMIT), within the
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 = closest_model_id(requested, ids)
551
- close && "did you mean '#{close}'?"
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 id that is +requested+ but for case, or whose last segment is,
555
- # else the one id a typo away (#typo_distance); nil on a tie.
556
- def closest_model_id(requested, ids)
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.find { |id| id.downcase == probe } || ids.find { |id| last_segment(id) == probe }
559
- return exact if 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
- return nil if scored.empty?
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
- best = scored.map(&:first).min
568
- closest = scored.select { |distance, _| distance == best }
569
- closest.size == 1 ? closest.first.last : nil
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. The Engine sets them once
19
- # per turn, in Engine#generate, after the model's host is synced.
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 event it registers
10
- # for ("" when none: it isn't loaded), on_error (skip, log or
11
- # fail_closed) and priority. The one place the defaults live.
12
- class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
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
- def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
17
- super
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"].to_s.strip,
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
- # The string-keyed form manifest.yml and manifest.json hold.
48
- def to_record = to_h.transform_keys(&:to_s)
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 = Tools::BuiltinCalls.unwrap_saved(call, output)
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
- # approval, relay, context and relayed_to stay Hashes as given (string
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) do
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
 
@@ -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.event.empty?
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).