samagotchi 0.79.0 → 0.80.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +7 -4
  6. data/docs/internals/anthropic.md +3 -1
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  29. data/lib/samagotchi/llm/http.rb +12 -7
  30. data/lib/samagotchi/plugin/card.rb +44 -0
  31. data/lib/samagotchi/plugin/context.rb +53 -8
  32. data/lib/samagotchi/plugin/http.rb +154 -0
  33. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  34. data/lib/samagotchi/plugin/loader.rb +29 -10
  35. data/lib/samagotchi/question_answer.rb +29 -0
  36. data/lib/samagotchi/question_desk.rb +20 -19
  37. data/lib/samagotchi/relay_verifier.rb +1 -1
  38. data/lib/samagotchi/testing/boundary.rb +156 -0
  39. data/lib/samagotchi/testing/bundle.rb +349 -0
  40. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  41. data/lib/samagotchi/testing/fake_http.rb +137 -0
  42. data/lib/samagotchi/testing/helpers.rb +56 -0
  43. data/lib/samagotchi/testing/host.rb +76 -0
  44. data/lib/samagotchi/testing/minitest.rb +31 -0
  45. data/lib/samagotchi/testing/recorder.rb +138 -0
  46. data/lib/samagotchi/testing/recording_api.rb +27 -0
  47. data/lib/samagotchi/testing/rspec.rb +31 -0
  48. data/lib/samagotchi/testing/sandbox.rb +163 -0
  49. data/lib/samagotchi/testing/sessions.rb +77 -0
  50. data/lib/samagotchi/testing/verdict.rb +28 -0
  51. data/lib/samagotchi/testing/waiting.rb +36 -0
  52. data/lib/samagotchi/testing.rb +26 -0
  53. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  54. data/lib/samagotchi/tools/tool_index.rb +3 -0
  55. data/lib/samagotchi/turn_state.rb +28 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/public/app.js +47 -23
  58. data/lib/samagotchi/web/public/images.js +33 -0
  59. data/lib/samagotchi/web/public/index.html +71 -38
  60. data/lib/samagotchi/web/public/level_chip.js +11 -11
  61. data/lib/samagotchi/web/public/lists_view.js +19 -14
  62. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  63. data/lib/samagotchi/web/public/model_picker.js +1 -1
  64. data/lib/samagotchi/web/public/scope.js +3 -3
  65. data/lib/samagotchi/web/public/strip.js +35 -9
  66. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  67. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  68. metadata +21 -1
@@ -27,6 +27,7 @@ require_relative "tools/tool_index"
27
27
  require_relative "peer_sources"
28
28
  require_relative "session_commands"
29
29
  require_relative "plugin/loader"
30
+ require_relative "plugin/card"
30
31
  require_relative "extension_load"
31
32
  require_relative "log"
32
33
  require_relative "log_subscriber"
@@ -784,9 +785,6 @@ module Samagotchi
784
785
 
785
786
  # ── Cards ───────────────────────────────────────────────────────────────
786
787
 
787
- CARD_LEVELS = %i[info warn].freeze
788
- MAX_CARD_ACTIONS = 6
789
-
790
788
  # Show a card in every UI (docs/plugins.md, Cards): {type: :card, id:,
791
789
  # source:, title:, body:, level:, actions: [{label:, command:}], in_turn:}.
792
790
  # Where it goes (held while the plugins load, an anytime command's, an
@@ -799,7 +797,7 @@ module Samagotchi
799
797
  # @return [String] the card's id
800
798
  # @raise [ArgumentError] a card without a title, or a bad action
801
799
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
802
- card = build_card(source: source, title: title, body: body, actions: actions, level: level, id: id)
800
+ card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
803
801
  @event_routes.route(card, **EventRoutes::CARD_MARKS)
804
802
  card[:id]
805
803
  end
@@ -963,32 +961,6 @@ module Samagotchi
963
961
  end
964
962
  private :announce_or_hold
965
963
 
966
- def build_card(source:, title:, body:, actions:, level:, id:)
967
- title = title.to_s.strip
968
- raise ArgumentError, "a card needs a title" if title.empty?
969
-
970
- level = level.to_s.to_sym
971
- raise ArgumentError, "card level must be one of #{CARD_LEVELS.join(", ")}" unless CARD_LEVELS.include?(level)
972
-
973
- actions = Array(actions)
974
- raise ArgumentError, "a card has at most #{MAX_CARD_ACTIONS} actions" if actions.size > MAX_CARD_ACTIONS
975
-
976
- actions = actions.map do |action|
977
- raise ArgumentError, "a card action is a Hash {label:, command:}" unless action.is_a?(Hash)
978
-
979
- action = action.transform_keys(&:to_sym)
980
- command = action[:command].to_s.strip
981
- raise ArgumentError, "a card action needs a command" if command.empty? || command.include?("\n")
982
-
983
- label = action[:label].to_s.strip
984
- { label: label.empty? ? command : label, command: command }
985
- end
986
- id = id.to_s.strip
987
- { type: :card, id: id.empty? ? SecureRandom.hex(4) : id, source: source.to_s, title: title, body: body.to_s,
988
- level: level, actions: actions }
989
- end
990
- private :build_card
991
-
992
964
  # ── Model switching ────────────────────────────────────────────────────────
993
965
 
994
966
  # @param typed [String, nil] the name the model was typed as when
@@ -1452,6 +1424,7 @@ module Samagotchi
1452
1424
  emit_event(turn.on_event, turn.tag(event))
1453
1425
  end
1454
1426
  turn.ended = true
1427
+ @turn_state.ended!
1455
1428
  save_quietly(turn.session) if save
1456
1429
  @metrics.persist(state_dir: session_state_dir)
1457
1430
  end
@@ -1582,7 +1555,8 @@ module Samagotchi
1582
1555
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
1583
1556
  # (as a Hash or as keywords)
1584
1557
  # @param watch [#call, nil] closes the question with the reason it returns
1585
- # @return [Hash, String] the answer, or {error:, …}
1558
+ # @return [QuestionAnswer, Hash, String] the answer, or {error:, …}, or a
1559
+ # sync handler's text
1586
1560
  def open_question(fields = nil, watch: nil, **more)
1587
1561
  @question_desk.open_question({ **fields.to_h, **more }, watch: watch)
1588
1562
  end
@@ -1601,7 +1575,7 @@ module Samagotchi
1601
1575
  end
1602
1576
 
1603
1577
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1604
- # @return [Hash] normalized answer
1578
+ # @return [QuestionAnswer]
1605
1579
  def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
1606
1580
  @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
1607
1581
  parent_agent: parent_agent)
@@ -2569,8 +2543,10 @@ module Samagotchi
2569
2543
  toolkit: -> { @toolkit_binding.name },
2570
2544
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2571
2545
  use_toolkit: ->(name) { use_toolkit(name) },
2572
- ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2573
- ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2546
+ cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
2547
+ ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil|
2548
+ ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
2549
+ model: model)
2574
2550
  },
2575
2551
  model_name: -> { effective_model_ref },
2576
2552
  model_key: -> { @model_key },
@@ -2585,6 +2561,15 @@ module Samagotchi
2585
2561
  )
2586
2562
  end
2587
2563
 
2564
+ # The controller a plugin's ctx.http call without cancel: stops with
2565
+ # (docs/plugins.md): an init task's own, else the running turn's for the
2566
+ # turn's own work until the turn has ended (TurnState#own_controller); nil
2567
+ # for an anytime command, a service or a thread of the plugin's.
2568
+ def plugin_cancel_controller(handler:)
2569
+ (task = current_init_task) ? task.cancel : @turn_state.own_controller(handler: handler)
2570
+ end
2571
+ private :plugin_cancel_controller
2572
+
2588
2573
  # A plugin's ctx.messages: the conversation without the system prompt
2589
2574
  # (the REPL's starts with it, a worker's new session doesn't), and,
2590
2575
  # while a turn runs, the turn so far from the Bridge (plan O1). Read
@@ -2601,14 +2586,15 @@ module Samagotchi
2601
2586
  private :plugin_messages
2602
2587
 
2603
2588
  # ctx.ask_model's request: the session's current model on its host, as
2604
- # a turn resolves them (a /model switch counts), through its own
2589
+ # a turn resolves them (a /model switch counts), or a configured +model+
2590
+ # (a host:id ref, an alias, a bare id one of a host's models), through its own
2605
2591
  # IdleClient, so it shares nothing with the turn's backend. The client
2606
2592
  # is kept while the target and timeout stay (what it learned, e.g. a
2607
2593
  # refused thinking-off, holds for the next request); the session's id
2608
2594
  # goes with the request.
2609
2595
  # @return [String] the answer
2610
- def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
2611
- target = session_model_recap_target
2596
+ def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil)
2597
+ target = model.nil? ? session_model_recap_target : configured_side_target(model)
2612
2598
  client = side_model_client(target, timeout)
2613
2599
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2614
2600
  answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
@@ -2618,6 +2604,23 @@ module Samagotchi
2618
2604
  end
2619
2605
  private :ask_side_model
2620
2606
 
2607
+ # The target of ctx.ask_model(model:): +model+ as a turn resolves it,
2608
+ # once it is known to name something configured. IdleTarget.of_model
2609
+ # never raises (an unknown host falls to the default host, an unknown id
2610
+ # to the default entry), so a typo would silently ask another model.
2611
+ # @raise [ArgumentError] +model+ names no configured host and is no
2612
+ # host's model (cached, declared or saved lists; nothing is asked)
2613
+ def configured_side_target(model)
2614
+ ref = @host_registry.model_ref(model)
2615
+ unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
2616
+ raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
2617
+ "or a model id one of your hosts lists)"
2618
+ end
2619
+
2620
+ IdleTarget.of_model(@host_registry, model)
2621
+ end
2622
+ private :configured_side_target
2623
+
2621
2624
  # The IdleClient for +target+ at +timeout+: the last one when both are
2622
2625
  # the same, else a new one.
2623
2626
  def side_model_client(target, timeout)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../question_answer"
6
+
5
7
  module Samagotchi
6
8
  module Guardrails
7
9
  # An ask as a question for the user (Engine#open_question), and the
@@ -67,22 +69,22 @@ module Samagotchi
67
69
  end
68
70
 
69
71
  # Settle +verdict+ from open_question's result.
70
- # @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
72
+ # @param answer [QuestionAnswer, Hash, String] the answer, or {error:} (anything else is a cancel)
71
73
  # @param scopes [Array<String>] the offered scopes, in option order
72
74
  # @return [Verdict]
73
75
  def settle(verdict, answer, scopes)
74
- unless answer.is_a?(Hash) && !answer[:error]
76
+ unless answer.is_a?(QuestionAnswer)
75
77
  return verdict.settle!(:deny, decided_by: "user", note: "The approval was cancelled.")
76
78
  end
77
79
 
78
- index = Array(answer[:selected_indices]).first
80
+ index = answer.selected_indices.first
79
81
  if index && index < scopes.size
80
82
  verdict.settle!(:allow, decided_by: "user")
81
83
  verdict.scope = scopes[index]
82
84
  return verdict
83
85
  end
84
86
 
85
- freeform = answer[:freeform].to_s.strip
87
+ freeform = answer.freeform.to_s.strip
86
88
  note = freeform.empty? ? "The user declined this call." : "The user declined this call: #{freeform.inspect}."
87
89
  verdict.settle!(:deny, decided_by: "user", note: note)
88
90
  end
@@ -49,10 +49,14 @@ module Samagotchi
49
49
  # @param call [Hash] the parsed tool call
50
50
  # @param iteration [Integer]
51
51
  # @param params [String] the call's one-line preview
52
+ # @param verdict [Verdict, nil] the one the voters vote on (the test
53
+ # kit's recording one, maybe with a vote already in); default a new
54
+ # one for +call+
52
55
  # @return [Verdict]
53
- def evaluate(call, iteration:, params:)
56
+ def evaluate(call, iteration:, params:, verdict: nil)
54
57
  context = @context_lookup.call
55
- verdict = Verdict.new(call: call)
58
+ verdict ||= Verdict.new(call: call)
59
+ verdict.call = call
56
60
  if @cancelled_lookup.call
57
61
  verdict.deny!("the turn was stopped", decided_by: "core")
58
62
  verdict.context = context
@@ -60,7 +64,7 @@ module Samagotchi
60
64
  return verdict
61
65
  end
62
66
  before = { type: :before_tool_call, iteration: iteration, call: call.dup, params: params,
63
- blocked: false, block_reason: nil, guardrail: verdict,
67
+ blocked: verdict.deny?, block_reason: (verdict.reason if verdict.deny?), guardrail: verdict,
64
68
  context: context.to_h, targets: targets_for(call, context).to_h }
65
69
  fire_each(:before_tool_call, before) do |event|
66
70
  verdict.legacy_deny!(event[:block_reason]) if event[:blocked]
@@ -70,10 +70,7 @@ module Samagotchi
70
70
  # A hook with no declared event cannot be auto-registered.
71
71
  next if hook.event.empty?
72
72
 
73
- event_sym = hook.event.to_sym
74
- on_error = hook.on_error
75
- priority = hook.priority
76
- fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
73
+ fail_closed = hook.on_error == "fail_closed" && hook.event == "before_tool_call"
77
74
  what = "hook #{basename} (bundle #{bundle_name})"
78
75
  file = hooks_dir && File.join(hooks_dir, basename)
79
76
  unless file && File.exist?(file)
@@ -87,15 +84,7 @@ module Samagotchi
87
84
  end
88
85
 
89
86
  begin
90
- plugin = instantiate(bundle_name, basename, file, settings: settings)
91
- policy = if fail_closed then :deny
92
- elsif on_error == "log" then :log
93
- else :skip
94
- end
95
- label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
96
- handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
97
- fields: { bundle: bundle_name }) { |event| plugin.call(event) }
98
- registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority, &handler)
87
+ load_hook_file(bundle_name, basename, file, hook, registry, settings: settings)
99
88
  loaded += 1
100
89
  rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
101
90
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
@@ -107,6 +96,31 @@ module Samagotchi
107
96
  loaded
108
97
  end
109
98
 
99
+ # Build one hook file and register it in +registry+ under its
100
+ # event, priority and error policy (a fail_closed before_tool_call
101
+ # one denies when it raises). No missing-file, sha or requires_chi
102
+ # check: #load makes them; the test kit loads a bundle's source
103
+ # this way.
104
+ # @param hook [MemoryBundle::BundleHook] its manifest entry
105
+ # @param namespace [Module, nil] the module the file is evaluated
106
+ # in (default: the bundle's, #namespace_for)
107
+ # @raise [Exception] what loading or building the file raised;
108
+ # nothing is registered then
109
+ def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil)
110
+ event_sym = hook.event.to_sym
111
+ fail_closed = hook.on_error == "fail_closed" && event_sym == :before_tool_call
112
+ plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
113
+ policy = if fail_closed then :deny
114
+ elsif hook.on_error == "log" then :log
115
+ else :skip
116
+ end
117
+ label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
118
+ handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
119
+ fields: { bundle: bundle_name }) { |event| plugin.call(event) }
120
+ registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
121
+ nil
122
+ end
123
+
110
124
  # Why the file doesn't match the sha256 recorded at install, or nil.
111
125
  # With none recorded, only a required hook counts as a mismatch.
112
126
  def sha_mismatch(file, recorded, required:)
@@ -119,12 +133,13 @@ module Samagotchi
119
133
  "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
120
134
  end
121
135
 
122
- # Evaluate a plugin file inside the bundle's namespace module and
136
+ # Evaluate a plugin file inside the bundle's namespace module (or
137
+ # +namespace+) and
123
138
  # return an instance that responds to #call. A class whose
124
139
  # initialize takes an argument gets the settings (one Hash, string
125
140
  # keys); one that takes none is built bare.
126
- def instantiate(bundle_name, basename, file, settings: {})
127
- ns = namespace_for(bundle_name)
141
+ def instantiate(bundle_name, basename, file, settings: {}, namespace: nil)
142
+ ns = namespace || namespace_for(bundle_name)
128
143
  content = File.read(file)
129
144
  ns.module_eval(content, file, 1)
130
145
  klass = Hooks.class_for(basename, ns)
@@ -49,6 +49,31 @@ module Samagotchi
49
49
  namespace.const_get(File.basename(file.to_s, ".rb").split("_").map(&:capitalize).join, false)
50
50
  end
51
51
 
52
+ # A hook that raised: its label, event and error (#capture_failures).
53
+ Failure = Data.define(:hook, :event, :error)
54
+
55
+ # Thread.current key: the Array #capture_failures fills, or nil.
56
+ FAILURES = :samagotchi_hook_failures
57
+
58
+ # Collect into +sink+ every hook that raises on this thread while the
59
+ # block runs, whatever its policy (:skip ones log nothing, a stream
60
+ # hook's log is rate-limited): the bundle test kit raises them. The
61
+ # policy still applies. Nests; threads the block starts don't inherit it.
62
+ # @param sink [Array<Failure>]
63
+ def self.capture_failures(sink)
64
+ previous = Thread.current[FAILURES]
65
+ Thread.current[FAILURES] = sink
66
+ yield
67
+ ensure
68
+ Thread.current[FAILURES] = previous
69
+ end
70
+
71
+ # Add a raising hook to the sink #capture_failures set, if any.
72
+ def self.record_failure(hook, event, error)
73
+ Thread.current[FAILURES]&.push(Failure.new(hook: hook.to_s, event: event.to_s, error: error))
74
+ nil
75
+ end
76
+
52
77
  # A hook's block wrapped in its error policy: what a loader registers.
53
78
  # When the block raises,
54
79
  # :deny denies the call (event[:guardrail], the Gate's Verdict),
@@ -70,6 +95,7 @@ module Samagotchi
70
95
  rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
71
96
  raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
72
97
 
98
+ Hooks.record_failure(label, event, e)
73
99
  failed = "#{e.class}: #{e.message}"
74
100
  if policy == :deny
75
101
  payload[:guardrail]&.deny!("#{label} raised #{failed}", rule: "guardrail-load", source: "core", decided_by: "core") if payload.is_a?(Hash)
@@ -273,6 +299,7 @@ module Samagotchi
273
299
  hook_proc.call(event)
274
300
  rescue StandardError => e
275
301
  # A failing hook must not break the turn.
302
+ Hooks.record_failure(label, name, e)
276
303
  Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
277
304
  hook: label, event: name.to_s)
278
305
  end
@@ -302,6 +329,7 @@ module Samagotchi
302
329
  hook_proc.call(event)
303
330
  rescue StandardError => e
304
331
  # A failing hook must not break the turn.
332
+ Hooks.record_failure(label, name, e)
305
333
  Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
306
334
  hook: label, event: name.to_s)
307
335
  end
@@ -309,6 +337,16 @@ module Samagotchi
309
337
  end
310
338
  end
311
339
 
340
+ # The bundle hooks registered for +name+, in the order #fire calls
341
+ # them (priority, bundle, hook name).
342
+ # @param name [Symbol]
343
+ # @return [Array<Array(String, String, Integer)>] [bundle, hook_name, priority]
344
+ def bundle_hook_names(name)
345
+ @mutex.synchronize do
346
+ sorted_bundle_hooks(name).map { |h| [h[:bundle], h[:hook_name], h[:priority]] }
347
+ end
348
+ end
349
+
312
350
  # @return [Integer] total number of registered hooks (bundle + plain)
313
351
  def size
314
352
  @mutex.synchronize do
@@ -323,15 +361,18 @@ module Samagotchi
323
361
  # turn-scoped hooks, each in registration order.
324
362
  def ordered_procs(name)
325
363
  @mutex.synchronize do
326
- bundle_procs = (@bundle_hooks[name] || [])
327
- .sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
328
- .map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
364
+ bundle_procs = sorted_bundle_hooks(name).map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
329
365
  config_procs = (@persistent_hooks[name] || []).map { |h| [h[:label], h[:proc]] }
330
366
  turn_procs = (@hooks[name] || []).map { |hook_proc| [TURN_HOOK_LABEL, hook_proc] }
331
367
  bundle_procs + config_procs + turn_procs
332
368
  end
333
369
  end
334
370
 
371
+ # The bundle hooks for an event in fire order; call under @mutex.
372
+ def sorted_bundle_hooks(name)
373
+ (@bundle_hooks[name] || []).sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
374
+ end
375
+
335
376
  # The helpers, once per fire; a fire site's own keys stay.
336
377
  def with_runtime(event)
337
378
  return unless event.is_a?(Hash)
@@ -229,15 +229,23 @@ module Samagotchi
229
229
  # ModelRef names a host only when it is one of ours
230
230
  return [find_entry(ref.host_name), ref.id] if ref.host_name
231
231
 
232
- down = ref.id.to_s.strip.downcase
233
- listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
234
- candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
235
- candidates = saved_hosts_for(down) if candidates.empty?
232
+ candidates = hosts_with_model(ref.id)
236
233
  default = default_entry
237
234
  name = candidates.include?(default.name) ? default.name : candidates.first
238
235
  [(name && find_entry(name)) || default, ref.id]
239
236
  end
240
237
 
238
+ # The hosts that list or declare the model id +id+, in hosts: order: by
239
+ # the cached lists and hosts.<name>.models, else by the saved lists
240
+ # (ModelListStore). None asked: no discovery here.
241
+ # @return [Array<String>] host names, empty when no host has it
242
+ def hosts_with_model(id)
243
+ down = id.to_s.strip.downcase
244
+ listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
245
+ names = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
246
+ names.empty? ? saved_hosts_for(down) : names
247
+ end
248
+
241
249
  # The chat adapter for a host (one per host, so its cached model list
242
250
  # serves the context window), as LLM::Adapters builds it for the host's
243
251
  # api. Only chat hosts use it for turns.
@@ -24,11 +24,13 @@ module Samagotchi
24
24
 
25
25
  # The adapter's LLM::HTTP: the timeouts (#timeouts), one attempt when
26
26
  # +retries+ is false and no +retry_policy+ is given.
27
- def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:)
27
+ def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:,
28
+ diagnosis_header: nil)
28
29
  open_timeout, read_timeout = timeouts(timeout, open_timeout)
29
30
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
30
31
  HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout, retry_policy: policy,
31
- sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key)
32
+ sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key,
33
+ diagnosis_header: diagnosis_header)
32
34
  end
33
35
 
34
36
  # [open, read] seconds: server.open_timeout / server.read_timeout,
@@ -27,6 +27,9 @@ module Samagotchi
27
27
  # The only drop the loop settles: a model_binding_mismatch (a /model
28
28
  # downgrade) keeps its blocks, a switch back reads them again.
29
29
  PREFIX_DROP = "prefix_binding_mismatch"
30
+ # The 400's undocumented diagnosis header, kept on the ProviderError and
31
+ # logged as `diagnosis`.
32
+ DIAGNOSIS_HEADER = "anthropic-thinking-prefix-mismatch"
30
33
 
31
34
  # One request's binding recovery (#advance): the step taken
32
35
  # (nil, :drop_block or :strip), the steps tried, where a strip starts
@@ -116,7 +116,8 @@ module Samagotchi
116
116
  @binding = AnthropicBindingRecovery.new(refusals: @refusals, host_name: @host_name, budget_form: method(:budget_form?))
117
117
  @http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
118
118
  sleeper: sleeper, first_token_timeout: first_token_timeout,
119
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
119
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth),
120
+ diagnosis_header: AnthropicBindingRecovery::DIAGNOSIS_HEADER)
120
121
  end
121
122
 
122
123
  # The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
@@ -43,8 +43,9 @@ module Samagotchi
43
43
 
44
44
  # Requests of these purposes are the ones a turn waits on (or, for the
45
45
  # turn-end warm-up, prepares the next one): INFO lines (and ERROR when
46
- # they fail). Probes and model lists are DEBUG.
47
- LOGGED_PURPOSES = %w[chat recap warmup].freeze
46
+ # they fail); so are a plugin's ctx.http calls ("plugin"). Probes and
47
+ # model lists are DEBUG.
48
+ LOGGED_PURPOSES = %w[chat recap warmup plugin].freeze
48
49
 
49
50
  # Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
50
51
  # for up to +max+ retries (max + 1 attempts).
@@ -103,10 +104,13 @@ module Samagotchi
103
104
  # show something (see #stream_lines); nil: no limit
104
105
  # @param api_key [ApiKey, nil] sent on every request; nil: no
105
106
  # Authorization header (a local server)
107
+ # @param diagnosis_header [String, nil] a response header the adapter
108
+ # wants kept on a ProviderError as its diagnosis; nil: none
106
109
  def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil,
107
- api_key: nil)
110
+ api_key: nil, diagnosis_header: nil)
108
111
  @label = label
109
112
  @api_key = api_key
113
+ @diagnosis_header = diagnosis_header
110
114
  @open_timeout = open_timeout
111
115
  @read_timeout = read_timeout
112
116
  @first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
@@ -187,11 +191,11 @@ module Samagotchi
187
191
  # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
188
192
  # without retries)
189
193
  def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
190
- cancel_controller: nil, log_fields: {})
194
+ write_timeout: nil, cancel_controller: nil, log_fields: {})
191
195
  identify(request)
192
196
  current = new_attempt_state(uri, request, log_fields, stream: false)
193
197
  attempt = lambda do |state|
194
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
198
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout, max_retries: 0) do |http|
195
199
  state[:http] = http
196
200
  http.request(request).tap do |response|
197
201
  state[:status] = response.code.to_i
@@ -221,8 +225,9 @@ module Samagotchi
221
225
  @api_key&.authorize(request)
222
226
  end
223
227
 
224
- def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &)
228
+ def start(uri, open_timeout: nil, read_timeout: nil, write_timeout: nil, max_retries: nil, &)
225
229
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
230
+ options[:write_timeout] = write_timeout if write_timeout
226
231
  # Net::HTTP retries an idempotent request once on its own (max_retries
227
232
  # defaults to 1) when the connection drops before an answer: chi's
228
233
  # RetryPolicy owns retries (and the log line says how many were sent),
@@ -418,7 +423,7 @@ module Samagotchi
418
423
 
419
424
  error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
420
425
  retry_after: response["Retry-After"],
421
- diagnosis: response["anthropic-thinking-prefix-mismatch"])
426
+ diagnosis: @diagnosis_header && response[@diagnosis_header])
422
427
  error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
423
428
  raise error
424
429
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Samagotchi
6
+ module Plugin
7
+ # A card's checked event (docs/plugins.md, Cards): what Engine#show_card
8
+ # routes and the bundle test kit records.
9
+ module Card
10
+ LEVELS = %i[info warn].freeze
11
+ MAX_ACTIONS = 6
12
+
13
+ module_function
14
+
15
+ # @return [Hash] {type: :card, id:, source:, title:, body:, level:,
16
+ # actions: [{label:, command:}]}; a new id when none is given
17
+ # @raise [ArgumentError] a card without a title, a bad level or action
18
+ def build(source:, title:, body:, actions:, level:, id:)
19
+ title = title.to_s.strip
20
+ raise ArgumentError, "a card needs a title" if title.empty?
21
+
22
+ level = level.to_s.to_sym
23
+ raise ArgumentError, "card level must be one of #{LEVELS.join(", ")}" unless LEVELS.include?(level)
24
+
25
+ actions = Array(actions)
26
+ raise ArgumentError, "a card has at most #{MAX_ACTIONS} actions" if actions.size > MAX_ACTIONS
27
+
28
+ actions = actions.map do |action|
29
+ raise ArgumentError, "a card action is a Hash {label:, command:}" unless action.is_a?(Hash)
30
+
31
+ action = action.transform_keys(&:to_sym)
32
+ command = action[:command].to_s.strip
33
+ raise ArgumentError, "a card action needs a command" if command.empty? || command.include?("\n")
34
+
35
+ label = action[:label].to_s.strip
36
+ { label: label.empty? ? command : label, command: command }
37
+ end
38
+ id = id.to_s.strip
39
+ { type: :card, id: id.empty? ? SecureRandom.hex(4) : id, source: source.to_s, title: title, body: body.to_s,
40
+ level: level, actions: actions }
41
+ end
42
+ end
43
+ end
44
+ end