samagotchi 0.78.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  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 +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. metadata +24 -1
@@ -14,9 +14,10 @@ require_relative "redacted_inspect"
14
14
 
15
15
  module Samagotchi
16
16
  # `chi bootstrap [TARGET]`: the first setup. It names a model server, works
17
- # out what it is (llama.cpp's native API or an OpenAI-compatible one),
18
- # picks the model, sends one test request and writes config.yml: a fresh
19
- # file, or a hosts: entry added to an existing one. Then it installs the
17
+ # out what it is (llama.cpp's native API, an OpenAI-compatible one or
18
+ # Anthropic's Messages API, Bootstrap::Probe), picks the model, sends one
19
+ # test request and writes config.yml: a fresh file, or a hosts: entry
20
+ # added to an existing one. Then it installs the
20
21
  # system bundle and the core profile, and on a terminal asks about dev.
21
22
  # Prompts only on a terminal; a script gets the list and exit 2 instead.
22
23
  class BootstrapCommand
@@ -24,11 +25,14 @@ module Samagotchi
24
25
  include RedactedInspect
25
26
 
26
27
  USAGE = <<~TEXT
27
- Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
28
+ Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--api API] [--dialect NAME]
29
+ [--no-test] [--dry-run]
28
30
  Finds the model server at TARGET and writes config.yml for it.
29
31
  chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
30
32
  chi bootstrap localhost:11434 Ollama
31
33
  chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
34
+ chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY
35
+ chi bootstrap https://api.fireworks.ai/inference/v1 --key-env FIREWORKS_API_KEY --api anthropic
32
36
  chi bootstrap look on this machine's usual ports
33
37
  TARGET host[:port] (port 8080 when none), or a URL; a URL's
34
38
  path is the OpenAI API base
@@ -37,6 +41,12 @@ module Samagotchi
37
41
  --model ID the model, when the server has several
38
42
  --key-env VAR the environment variable holding the API key (the
39
43
  key itself is never written)
44
+ --api API openai or anthropic (Anthropic's Messages API) instead
45
+ of detecting it; detection takes anthropic only when
46
+ the OpenAI model list is refused
47
+ --dialect NAME the Messages API's dialect: standard or fireworks
48
+ (implies --api anthropic; default: fireworks when the
49
+ host or the model ids say so)
40
50
  --no-test skip the test request
41
51
  --dry-run show what would be written; write nothing
42
52
  With no config.yml it writes one; an existing one gets a hosts:
@@ -50,10 +60,13 @@ module Samagotchi
50
60
  f.value "--name"
51
61
  f.value "--model"
52
62
  f.value "--key-env"
63
+ f.value "--api"
64
+ f.value "--dialect"
53
65
  f.switch "--no-test"
54
66
  f.switch "--dry-run"
55
67
  end
56
68
 
69
+ APIS = %i[openai anthropic].freeze
57
70
  PICK_SHOWN = 20
58
71
  # A test request slower than this says it is waiting (a model loading).
59
72
  LOADING_AFTER = 3
@@ -92,6 +105,8 @@ module Samagotchi
92
105
  return @exit
93
106
  end
94
107
 
108
+ @api = options[:api]
109
+ @dialect = options[:dialect]
95
110
  result = find(options, key_env) or return @exit
96
111
  unlocked = with_key(result, key_env) or return @exit
97
112
  result, key_env = unlocked
@@ -124,7 +139,25 @@ module Samagotchi
124
139
  return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
125
140
  end
126
141
 
127
- options
142
+ parse_api(options)
143
+ end
144
+
145
+ # --api as a Symbol, --dialect downcased (implying --api anthropic).
146
+ def parse_api(options)
147
+ if options[:api]
148
+ api = options[:api].strip.downcase.to_sym
149
+ return usage_error("--api must be #{APIS.join(" or ")} (got #{options[:api]})") unless APIS.include?(api)
150
+
151
+ options[:api] = api
152
+ end
153
+ return options unless options[:dialect]
154
+
155
+ dialect = options[:dialect].strip.downcase
156
+ names = LLM::Dialect::BY_NAME.keys
157
+ return usage_error("--dialect must be #{names.join(" or ")} (got #{options[:dialect]})") unless names.include?(dialect)
158
+ return usage_error("--dialect is for --api anthropic, not --api openai") if options[:api] == :openai
159
+
160
+ options.merge(dialect: dialect, api: :anthropic)
128
161
  end
129
162
 
130
163
  # The server's Probe::Result, or nil with @exit set.
@@ -137,17 +170,26 @@ module Samagotchi
137
170
  @exit = usage_error(e.message)
138
171
  return nil
139
172
  end
140
- result = @probe.classify_target(candidates, key_env: key_env)
173
+ result = @probe.classify_target(candidates, key_env: key_env, api: @api, dialect: @dialect)
141
174
  case result.kind
142
175
  when :unreachable
143
176
  fail!("can't reach #{result.candidate.label} (#{result.reason})")
144
177
  when :unknown
145
178
  detail = result.status ? "HTTP #{result.status}" : result.reason
146
- fail!("reached #{result.candidate.label} but it answers neither llama.cpp /props nor /v1/models (#{detail})")
179
+ fail!("reached #{result.candidate.label} but it answers #{unknown_asked} (#{detail})")
147
180
  else result
148
181
  end
149
182
  end
150
183
 
184
+ # What an :unknown server didn't answer, by what --api let the probe ask.
185
+ def unknown_asked
186
+ case @api
187
+ when :anthropic then "no Anthropic /v1/models"
188
+ when :openai then "neither llama.cpp /props nor /v1/models"
189
+ else "neither llama.cpp /props, /v1/models nor the Anthropic /v1/models"
190
+ end
191
+ end
192
+
151
193
  def scan
152
194
  found = @probe.scan_local
153
195
  ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
@@ -190,7 +232,7 @@ module Samagotchi
190
232
  return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
191
233
  return fail!("#{key_env} is not set; #{export_hint(key_env, "and run chi bootstrap again")}") if unset?(key_env)
192
234
 
193
- result = @probe.classify(result.candidate, key_env: key_env)
235
+ result = @probe.classify(result.candidate, key_env: key_env, api: @api, dialect: @dialect)
194
236
  return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
195
237
  return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
196
238
 
@@ -201,8 +243,15 @@ module Samagotchi
201
243
  if result.native?
202
244
  build = result.props["build_info"].to_s
203
245
  @stdout.puts("found: llama.cpp at #{result.candidate.root}#{" (build #{build})" unless build.empty?}")
246
+ elsif result.anthropic?
247
+ dialect = " (dialect: #{result.dialect})" if result.dialect
248
+ @stdout.puts("found: an Anthropic Messages API at #{Bootstrap::Probe.api_root(result.candidate)}#{dialect}")
204
249
  else
205
250
  @stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
251
+ # By the host only: a proxy listing accounts/fireworks/ ids may not serve /v1/messages.
252
+ if @api.nil? && Bootstrap::Probe.fireworks_host?(result.candidate.host)
253
+ @stdout.puts("Fireworks also serves the Anthropic Messages API: --api anthropic")
254
+ end
206
255
  end
207
256
  end
208
257
 
@@ -248,17 +297,28 @@ module Samagotchi
248
297
  @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
249
298
  end
250
299
  end
251
- seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
300
+ seconds = @probe.test_turn(result.candidate, model, key_env: key_env, api: result.anthropic? ? :anthropic : :openai,
301
+ dialect: result.dialect, auth: result.auth)
252
302
  @stdout.puts(format("test: answered in %.1f s", seconds))
253
303
  true
254
304
  rescue StandardError => e
255
- @stdout.puts("test: failed: #{e.message}")
305
+ @stdout.puts("test: failed: #{e.message}#{messages_hint(result, e)}")
256
306
  false
257
307
  ensure
258
308
  done&.push(true)
259
309
  waiting&.join
260
310
  end
261
311
 
312
+ # A detected OpenAI server whose model list worked but whose
313
+ # /chat/completions is 404: maybe a Messages-only url (a gateway's
314
+ # Anthropic route that also lists the OpenAI way).
315
+ def messages_hint(result, error)
316
+ return "" unless @api.nil? && result.kind == :openai && error.is_a?(Bootstrap::Probe::TestFailed)
317
+ return "" unless error.status == 404 && error.path.to_s.end_with?("/chat/completions")
318
+
319
+ " (an Anthropic Messages API? try --api anthropic)"
320
+ end
321
+
262
322
  # The "loading the model?" hint fits a local server (a llama.cpp loading
263
323
  # weights); a remote provider's slow answer is the network, not a load.
264
324
  def loading_hint?(result)
@@ -268,7 +328,7 @@ module Samagotchi
268
328
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
269
329
  def write(result, model, key_env, options)
270
330
  writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
271
- fields = host_fields(result.candidate, result.native?, key_env)
331
+ fields = host_fields(result, key_env)
272
332
  ids = result.models.map(&:id)
273
333
  base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
274
334
  name, reason = begin
@@ -350,18 +410,35 @@ module Samagotchi
350
410
  end
351
411
 
352
412
  # The hosts entry: host/port for a plain http server (api: openai when it
353
- # isn't llama.cpp), url for https or a URL with its own path.
354
- def host_fields(candidate, native, key_env)
413
+ # isn't llama.cpp), url for https or a URL with its own path (an
414
+ # Anthropic one without /v1: the API root, as the hand-written claude
415
+ # host has it). Then api, dialect, auth (Bearer only when the dialect's
416
+ # own header isn't), api_key_env.
417
+ def host_fields(result, key_env)
418
+ candidate = result.candidate
355
419
  fields = if candidate.url || candidate.scheme == "https"
356
- { "url" => native ? candidate.root : candidate.base }
420
+ { "url" => url_field(result) }
357
421
  else
358
422
  { "host" => candidate.host, "port" => candidate.port }
359
423
  end
360
- fields["api"] = "openai" unless native
424
+ if result.anthropic?
425
+ fields["api"] = "anthropic"
426
+ fields["dialect"] = result.dialect if result.dialect
427
+ fields["auth"] = result.auth.to_s if result.auth && result.auth != LLM::Dialect.for(result.dialect).auth
428
+ elsif !result.native?
429
+ fields["api"] = "openai"
430
+ end
361
431
  fields["api_key_env"] = key_env if key_env
362
432
  fields
363
433
  end
364
434
 
435
+ def url_field(result)
436
+ return result.candidate.root if result.native?
437
+ return Bootstrap::Probe.api_root(result.candidate) if result.anthropic?
438
+
439
+ result.candidate.base
440
+ end
441
+
365
442
  def report(outcome, model)
366
443
  path = outcome.path
367
444
  case outcome.kind
@@ -658,7 +658,7 @@ module Samagotchi
658
658
 
659
659
  begin
660
660
  result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
661
- [{}, 200, { status: "answered", session_id: session_id, answer: result }]
661
+ [{}, 200, { status: "answered", session_id: session_id, answer: result.to_h }]
662
662
  rescue Engine::QuestionNotPending => e
663
663
  # Another client answered first, or the question was cancelled.
664
664
  [{}, 409, { error: "question_not_pending", detail: e.message }]
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.78.0
3
+ version: 0.80.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:b5b3166c91f6f2af2563dd9db5f9959cda66747d907017acd0776fcd2fe29049
8
+ self_map.md: sha256:4ff5708e51f55089de47b107643ae0b74aee04cb96111226823a8d44fb4bc33d
9
9
  config_modification_protocol.md: sha256:66792f788cdbb8ee0252f2c387c96bd75266817bb75f721aaa181b602db97137
10
10
  memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -48,7 +48,7 @@
48
48
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
49
49
  - Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
50
50
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
51
- cards, side answers (`ask_model`),
51
+ cards, side answers (`ask_model`), HTTP calls (`ctx.http`),
52
52
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
53
53
  `mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture; a toolkit's undeclared built-ins too, as chi/<tool>), `guardrails` (rules), `known-names` (typo guard), `source-links`
54
54
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
@@ -211,13 +211,13 @@ module Samagotchi
211
211
  def answered(answer, client_id:)
212
212
  # The /continue line takes the text on one line; the steer keeps its
213
213
  # line breaks.
214
- text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
214
+ text = answer.freeform.to_s.gsub(/\s+/, " ").strip
215
215
  @continue_steer = nil
216
- if Array(answer[:selected]).include?(CONTINUE) && !text.empty?
217
- @continue_steer = { text: answer[:freeform].to_s.strip,
218
- source: answer[:by] == "parent_agent" ? "parent_agent" : "user" }
216
+ if answer.selected.include?(CONTINUE) && !text.empty?
217
+ @continue_steer = { text: answer.freeform.to_s.strip,
218
+ source: answer.parent_agent? ? "parent_agent" : "user" }
219
219
  end
220
- line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
220
+ line = if answer.selected.include?(CONTINUE) then "/continue yes"
221
221
  elsif text.empty? then "/continue no"
222
222
  else "/continue no, #{text}"
223
223
  end
@@ -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)