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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +7 -4
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- metadata +21 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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 =
|
|
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 [
|
|
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
|
-
|
|
2573
|
-
|
|
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),
|
|
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]
|
|
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?(
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -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)
|
|
47
|
-
|
|
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[
|
|
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
|