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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +11 -6
- data/docs/internals/anthropic.md +12 -5
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +15 -5
- 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 +8 -4
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- 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 +30 -22
- data/lib/samagotchi/reasoning_history.rb +2 -1
- 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 +79 -245
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +73 -40
- 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 +5 -6
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- 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
- data/lib/samagotchi/web/public/toasts.js +5 -4
- 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
|
|
18
|
-
# picks the model, sends one
|
|
19
|
-
# file, or a hosts: entry
|
|
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] [--
|
|
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
|
|
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
|
|
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
|
-
|
|
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" =>
|
|
420
|
+
{ "url" => url_field(result) }
|
|
357
421
|
else
|
|
358
422
|
{ "host" => candidate.host, "port" => candidate.port }
|
|
359
423
|
end
|
|
360
|
-
|
|
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
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
|
214
|
+
text = answer.freeform.to_s.gsub(/\s+/, " ").strip
|
|
215
215
|
@continue_steer = nil
|
|
216
|
-
if
|
|
217
|
-
@continue_steer = { text: answer
|
|
218
|
-
source: answer
|
|
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
|
|
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
|
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)
|