samagotchi 0.13.0 → 0.15.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 +82 -1
- data/bin/chi +4 -7
- data/docs/configuration.md +50 -7
- data/docs/guardrails.md +2 -1
- data/docs/plugins.md +28 -13
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +31 -6
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/engine.rb +21 -7
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
- data/lib/samagotchi/host_registry.rb +31 -6
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/plugin_session_state.rb +30 -0
- data/lib/samagotchi/self_report.rb +24 -4
- data/lib/samagotchi/session_manager.rb +8 -0
- data/lib/samagotchi/session_metrics.rb +1 -1
- data/lib/samagotchi/session_retention.rb +6 -1
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +65 -13
- data/lib/samagotchi/terminal_ui.rb +27 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -10
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -7
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +46 -10
- data/lib/samagotchi/web/public/index.html +10 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +45 -0
- data/lib/samagotchi/web/public/strip.js +8 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- data/lib/samagotchi/web/public/turn_view.js +10 -10
- metadata +3 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
|
|
11
11
|
requires_chi: ">= 0.8.0"
|
|
@@ -13,15 +13,18 @@
|
|
|
13
13
|
# cwd: ~/scratch # default: where chi runs
|
|
14
14
|
# tools: [echo, add] # optional: only these (globs work)
|
|
15
15
|
# timeout: 120 # optional: this server's per-call timeout
|
|
16
|
-
# attach_image_paths: true # default:
|
|
17
|
-
# #
|
|
16
|
+
# attach_image_paths: true # default: an image path in the text, in the
|
|
17
|
+
# # temp dir or cwd, attaches it
|
|
18
18
|
#
|
|
19
19
|
# Image blocks in a result go to the model as images (text: "[image 1:
|
|
20
|
-
# image/png, attached]"); so does
|
|
21
|
-
#
|
|
22
|
-
# (chrome-devtools-mcp --slim answers a screenshot that way)
|
|
20
|
+
# image/png, attached]"); so does an image file named in a text block,
|
|
21
|
+
# under the system temp dir or the server's cwd: the block is only its
|
|
22
|
+
# absolute path (chrome-devtools-mcp --slim answers a screenshot that way),
|
|
23
|
+
# or it says one ending in an image extension ("Saved it to /tmp/a.png.").
|
|
23
24
|
# A server that doesn't start, answer or list its tools is skipped with a
|
|
24
|
-
# notice; the rest of chi works.
|
|
25
|
+
# notice; the rest of chi works. One that exits mid-session starts again on
|
|
26
|
+
# its next call, at most MAX_RESTARTS times a session. /mcp lists the
|
|
27
|
+
# servers and their tools.
|
|
25
28
|
require "digest"
|
|
26
29
|
require "json"
|
|
27
30
|
require "open3"
|
|
@@ -38,6 +41,8 @@ class Plugin
|
|
|
38
41
|
NAME_CHARS = 48
|
|
39
42
|
# A cached tool list older than this is refreshed in the background.
|
|
40
43
|
CACHE_TTL = 24 * 60 * 60
|
|
44
|
+
# Restarts of a server that exited, per session.
|
|
45
|
+
MAX_RESTARTS = 3
|
|
41
46
|
IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
|
|
42
47
|
|
|
43
48
|
# A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
|
|
@@ -60,9 +65,13 @@ class Plugin
|
|
|
60
65
|
# @param log [#call] (event, fields) for stderr lines and protocol noise
|
|
61
66
|
# @param on_exit [#call, nil] called once, with the reason, when the
|
|
62
67
|
# process ends while the client is open
|
|
63
|
-
|
|
68
|
+
# @param on_notification [#call, nil] (method, params) for each
|
|
69
|
+
# notification from the server, on the reader thread: it must not
|
|
70
|
+
# wait for an answer there
|
|
71
|
+
def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil, on_notification: nil)
|
|
64
72
|
@log = log
|
|
65
73
|
@on_exit = on_exit
|
|
74
|
+
@on_notification = on_notification
|
|
66
75
|
@mutex = Mutex.new
|
|
67
76
|
@write_mutex = Mutex.new
|
|
68
77
|
@pending = {}
|
|
@@ -179,8 +188,11 @@ class Plugin
|
|
|
179
188
|
def dispatch(message)
|
|
180
189
|
if message.key?("method")
|
|
181
190
|
# A request from the server (ping, roots/list, …): ping is answered,
|
|
182
|
-
# the rest aren't supported. A notification is logged.
|
|
183
|
-
|
|
191
|
+
# the rest aren't supported. A notification is logged and handed on.
|
|
192
|
+
unless message.key?("id")
|
|
193
|
+
@log.call("server_notification", method: message["method"])
|
|
194
|
+
return @on_notification&.call(message["method"], message["params"])
|
|
195
|
+
end
|
|
184
196
|
|
|
185
197
|
answer = if message["method"] == "ping"
|
|
186
198
|
{ jsonrpc: "2.0", id: message["id"], result: {} }
|
|
@@ -227,8 +239,10 @@ class Plugin
|
|
|
227
239
|
# One configured server: its service, state and tools. +listed+ is its
|
|
228
240
|
# tools/list as the server answers it (what the cache keeps), +tools+
|
|
229
241
|
# the ones chi offers (the tools: filter applied, each with chi_name).
|
|
242
|
+
# +client+ is the running process's (a restart replaces it; the
|
|
243
|
+
# service's stop closes the current one), +restarts+ how many it had.
|
|
230
244
|
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
231
|
-
:digest, :cached_at, keyword_init: true)
|
|
245
|
+
:digest, :cached_at, :relist, :relisting, :client, :restarts, keyword_init: true)
|
|
232
246
|
|
|
233
247
|
def initialize(settings = {})
|
|
234
248
|
@settings = settings
|
|
@@ -236,6 +250,8 @@ class Plugin
|
|
|
236
250
|
@startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
|
|
237
251
|
@servers = []
|
|
238
252
|
@publish = Mutex.new
|
|
253
|
+
@relist = Mutex.new
|
|
254
|
+
@restart = Mutex.new
|
|
239
255
|
# The tools left out so far (a name clash): said once, not at each
|
|
240
256
|
# publish.
|
|
241
257
|
@left_out = []
|
|
@@ -255,7 +271,8 @@ class Plugin
|
|
|
255
271
|
configs = @settings["servers"]
|
|
256
272
|
configs = {} unless configs.is_a?(Hash)
|
|
257
273
|
@servers = configs.map do |name, config|
|
|
258
|
-
server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: []
|
|
274
|
+
server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [],
|
|
275
|
+
restarts: 0)
|
|
259
276
|
server.timeout = positive(server.config["timeout"]) || @timeout
|
|
260
277
|
resolve(server, ctx)
|
|
261
278
|
server.service = chi.service(name) { |svc| start(server, svc, ctx) }
|
|
@@ -306,16 +323,29 @@ class Plugin
|
|
|
306
323
|
server.digest = Digest::SHA256.hexdigest(JSON.generate([server.command, server.env.sort, server.cwd]))
|
|
307
324
|
end
|
|
308
325
|
|
|
309
|
-
# The service's start: spawn
|
|
310
|
-
#
|
|
326
|
+
# The service's start: spawn (#spawn). Its stop closes the server's
|
|
327
|
+
# current process, a restarted one too.
|
|
311
328
|
def start(server, svc, ctx)
|
|
312
329
|
raise Client::Error, "no command (bundles: mcp: servers: #{server.name}: command: [...])" if server.command.empty?
|
|
313
330
|
|
|
331
|
+
svc.on_stop { server.client&.close }
|
|
332
|
+
spawn(server, ctx)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Spawn the server's process (it is server.client from then on),
|
|
336
|
+
# initialize, list the tools (cancelled with the turn that waits for
|
|
337
|
+
# it), then cache the list.
|
|
338
|
+
# @return [Client]
|
|
339
|
+
def spawn(server, ctx)
|
|
314
340
|
cancelled = -> { ctx.cancelled? }
|
|
315
|
-
client =
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
341
|
+
client = server.client = Client.new(
|
|
342
|
+
server.command, env: server.env, cwd: server.cwd,
|
|
343
|
+
log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
|
|
344
|
+
on_exit: ->(reason) { exited(server, reason, ctx) },
|
|
345
|
+
on_notification: lambda { |method, _params|
|
|
346
|
+
tools_changed(server, ctx) if method == "notifications/tools/list_changed"
|
|
347
|
+
}
|
|
348
|
+
)
|
|
319
349
|
client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
|
|
320
350
|
clientInfo: { name: "chi", version: Samagotchi::VERSION } },
|
|
321
351
|
timeout: @startup_timeout, cancelled: cancelled)
|
|
@@ -380,6 +410,46 @@ class Plugin
|
|
|
380
410
|
nil
|
|
381
411
|
end
|
|
382
412
|
|
|
413
|
+
# notifications/tools/list_changed from a running server: list its tools
|
|
414
|
+
# again (on a thread of its own: the answer comes on the reader thread
|
|
415
|
+
# that told us), save them and replace the tools for the next turn.
|
|
416
|
+
# Notices that come together list once more after the one running.
|
|
417
|
+
def tools_changed(server, ctx)
|
|
418
|
+
@relist.synchronize do
|
|
419
|
+
server.relist = true
|
|
420
|
+
return if server.relisting
|
|
421
|
+
|
|
422
|
+
server.relisting = true
|
|
423
|
+
end
|
|
424
|
+
Thread.new do
|
|
425
|
+
relist(server, ctx) while relist_pending?(server)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Takes the server's pending relist: true once per notice; false (and
|
|
430
|
+
# the relisting thread ends) when none is left.
|
|
431
|
+
def relist_pending?(server)
|
|
432
|
+
@relist.synchronize do
|
|
433
|
+
pending = server.relist
|
|
434
|
+
server.relist = false
|
|
435
|
+
server.relisting = false unless pending
|
|
436
|
+
pending
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def relist(server, ctx)
|
|
441
|
+
server.service.value # raises once stopped
|
|
442
|
+
listed = list_tools(server.client, nil)
|
|
443
|
+
return if listed == server.listed
|
|
444
|
+
|
|
445
|
+
server.listed = listed
|
|
446
|
+
save_cache(server, ctx)
|
|
447
|
+
ctx.log.info("mcp_tools_changed", server: server.name, tools: listed.size)
|
|
448
|
+
publish(ctx)
|
|
449
|
+
rescue StandardError => e
|
|
450
|
+
ctx.log.warn("mcp_tools_not_relisted", server: server.name, error: e.class.name, msg: e.message)
|
|
451
|
+
end
|
|
452
|
+
|
|
383
453
|
# tools/list, every page.
|
|
384
454
|
def list_tools(client, cancelled)
|
|
385
455
|
tools = []
|
|
@@ -456,7 +526,7 @@ class Plugin
|
|
|
456
526
|
def publish(ctx)
|
|
457
527
|
@publish.synchronize do
|
|
458
528
|
@chi.replace_tools do |set|
|
|
459
|
-
@servers.each { |server| declare(set, server, ctx) if %i[running cached].include?(server.state) }
|
|
529
|
+
@servers.each { |server| declare(set, server, ctx) if %i[running cached exited].include?(server.state) }
|
|
460
530
|
end
|
|
461
531
|
end
|
|
462
532
|
end
|
|
@@ -483,7 +553,13 @@ class Plugin
|
|
|
483
553
|
def call(server, tool, args, ctx)
|
|
484
554
|
return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
|
|
485
555
|
|
|
486
|
-
client = server.state
|
|
556
|
+
client = case server.state
|
|
557
|
+
when :cached then lazy_start(server, ctx)
|
|
558
|
+
when :exited then restart(server, ctx)
|
|
559
|
+
else
|
|
560
|
+
server.service.value # raises once stopped
|
|
561
|
+
server.client
|
|
562
|
+
end
|
|
487
563
|
return client if client.is_a?(String)
|
|
488
564
|
|
|
489
565
|
result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
|
|
@@ -529,6 +605,36 @@ class Plugin
|
|
|
529
605
|
"Error: MCP server #{server.name} didn't start: #{e.message}"
|
|
530
606
|
end
|
|
531
607
|
|
|
608
|
+
# Start a server that exited again, for a call: at most MAX_RESTARTS
|
|
609
|
+
# times a session. A start that fails or is cancelled leaves it exited
|
|
610
|
+
# (the next call tries again, while restarts are left).
|
|
611
|
+
# @return [Client, String] the client, or the call's error text
|
|
612
|
+
def restart(server, ctx)
|
|
613
|
+
@restart.synchronize do
|
|
614
|
+
server.service.value # raises once stopped
|
|
615
|
+
return server.client if server.state == :running # another call restarted it
|
|
616
|
+
|
|
617
|
+
if server.restarts >= MAX_RESTARTS
|
|
618
|
+
return "Error: MCP server #{server.name} is not running (#{server.error}; restarted #{MAX_RESTARTS} times this session)"
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
server.restarts += 1
|
|
622
|
+
cached = server.listed
|
|
623
|
+
server.client&.close
|
|
624
|
+
begin
|
|
625
|
+
client = spawn(server, ctx)
|
|
626
|
+
rescue StandardError => e
|
|
627
|
+
server.client&.close
|
|
628
|
+
ctx.log.warn("mcp_server_not_restarted", server: server.name, error: e.class.name, msg: e.message)
|
|
629
|
+
return "Error: MCP server #{server.name} didn't restart: #{e.message}"
|
|
630
|
+
end
|
|
631
|
+
server.state = :running
|
|
632
|
+
ctx.log.info("mcp_server_restarted", server: server.name, pid: client.pid, restarts: server.restarts)
|
|
633
|
+
publish(ctx) if server.listed != cached
|
|
634
|
+
client
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
532
638
|
# The answer's text, and the images to attach: its image blocks, and a
|
|
533
639
|
# text block that is only an image's path (#image_path).
|
|
534
640
|
# @return [Array(String, Array<Hash>)]
|
|
@@ -541,11 +647,14 @@ class Plugin
|
|
|
541
647
|
case block["type"]
|
|
542
648
|
when "text"
|
|
543
649
|
line = block["text"].to_s
|
|
544
|
-
|
|
545
|
-
next line
|
|
650
|
+
paths = image_paths(line, server).reject { |path| images.any? { |image| image[:path] == path } }
|
|
651
|
+
next line if paths.empty?
|
|
546
652
|
|
|
547
|
-
|
|
548
|
-
|
|
653
|
+
notes = paths.map do |path|
|
|
654
|
+
images << { path: path, name: File.basename(path) }
|
|
655
|
+
"[image #{images.size}: #{File.basename(path)}, attached]"
|
|
656
|
+
end
|
|
657
|
+
[line, *notes].join("\n")
|
|
549
658
|
when "image"
|
|
550
659
|
mime = block["mimeType"] || "unknown type"
|
|
551
660
|
bytes = block["data"].to_s.unpack1("m")
|
|
@@ -564,14 +673,31 @@ class Plugin
|
|
|
564
673
|
[text, images]
|
|
565
674
|
end
|
|
566
675
|
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
676
|
+
# An absolute path ending in an image extension, inside a sentence
|
|
677
|
+
# ("Saved screenshot to /tmp/shot.png."): no spaces, quotes or brackets.
|
|
678
|
+
IMAGE_PATH_IN_TEXT = %r{(?<![\w/.~-])/[^\s"'`<>()\[\]{},;]+?\.(?:png|jpe?g|gif|webp)(?![\w/-])}i
|
|
679
|
+
|
|
680
|
+
# The images +text+ names, when the server's attach_image_paths isn't
|
|
681
|
+
# off: the whole text when it is one absolute path (any name), else each
|
|
682
|
+
# absolute path in it that ends in an image extension. Each must be an
|
|
683
|
+
# image file (by its bytes) under the system temp dir or the server's
|
|
684
|
+
# cwd: a server's text can't pull in any image on disk.
|
|
685
|
+
# @return [Array<String>] real paths, each once
|
|
686
|
+
def image_paths(text, server)
|
|
687
|
+
return [] if server.config["attach_image_paths"] == false
|
|
688
|
+
|
|
689
|
+
whole = text.strip
|
|
690
|
+
candidates = if whole.start_with?("/") && !whole.include?("\n") && File.file?(whole)
|
|
691
|
+
[whole]
|
|
692
|
+
else
|
|
693
|
+
text.scan(IMAGE_PATH_IN_TEXT)
|
|
694
|
+
end
|
|
695
|
+
candidates.filter_map { |path| image_path(path, server) }.uniq
|
|
696
|
+
end
|
|
572
697
|
|
|
573
|
-
|
|
574
|
-
|
|
698
|
+
# +path+'s real path when it is an image file under a root, else nil.
|
|
699
|
+
def image_path(path, server)
|
|
700
|
+
return nil unless File.file?(path)
|
|
575
701
|
|
|
576
702
|
real = File.realpath(path)
|
|
577
703
|
roots = [Dir.tmpdir, server.cwd].compact.map { |dir| File.realpath(dir) rescue nil }.compact
|
|
@@ -589,14 +715,20 @@ class Plugin
|
|
|
589
715
|
(head.start_with?("RIFF") && head[8, 4] == "WEBP")
|
|
590
716
|
end
|
|
591
717
|
|
|
592
|
-
# The process ended while chi runs: one notice;
|
|
718
|
+
# The process ended while chi runs: one notice; its next call starts it
|
|
719
|
+
# again (#restart) while restarts are left.
|
|
593
720
|
def exited(server, reason, ctx)
|
|
594
721
|
return unless server.state == :running
|
|
595
722
|
|
|
596
723
|
server.state = :exited
|
|
597
724
|
server.error = reason
|
|
598
|
-
ctx.log.warn("mcp_server_exited", server: server.name, msg: reason)
|
|
599
|
-
|
|
725
|
+
ctx.log.warn("mcp_server_exited", server: server.name, msg: reason, restarts: server.restarts)
|
|
726
|
+
after = if server.restarts < MAX_RESTARTS
|
|
727
|
+
"it restarts on its next call"
|
|
728
|
+
else
|
|
729
|
+
"it was restarted #{MAX_RESTARTS} times, its tools fail until chi restarts"
|
|
730
|
+
end
|
|
731
|
+
ctx.notify("MCP server #{server.name} stopped: #{reason}; #{after}", level: :warn)
|
|
600
732
|
end
|
|
601
733
|
|
|
602
734
|
def listing
|
|
@@ -614,7 +746,7 @@ class Plugin
|
|
|
614
746
|
when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
|
|
615
747
|
when :running
|
|
616
748
|
count = server.tools.count { |tool| tool["chi_name"] }
|
|
617
|
-
"running (pid #{server.
|
|
749
|
+
"running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}"
|
|
618
750
|
when :starting then "starting"
|
|
619
751
|
else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
|
|
620
752
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -187,8 +187,9 @@ module Samagotchi
|
|
|
187
187
|
# Maps of named entries and the keys an entry may hold
|
|
188
188
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
189
189
|
MAP_ENTRY_KEYS = {
|
|
190
|
-
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
|
|
191
|
-
|
|
190
|
+
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
|
|
191
|
+
remote window_tokens].freeze,
|
|
192
|
+
"models" => %w[profile vision sampling thinking window_tokens].freeze
|
|
192
193
|
}.freeze
|
|
193
194
|
# Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
|
|
194
195
|
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
|
|
@@ -603,7 +604,23 @@ module Samagotchi
|
|
|
603
604
|
|
|
604
605
|
# A `vision:` setting (hosts entry or models: entry): true, false, or nil
|
|
605
606
|
# when unset. Anything else warns once and counts as unset.
|
|
606
|
-
def vision_flag(value, where)
|
|
607
|
+
def vision_flag(value, where) = bool_flag(value, where, "vision")
|
|
608
|
+
|
|
609
|
+
# A `window_tokens:` setting (hosts entry or models: entry): a positive
|
|
610
|
+
# integer, nil when unset; anything else warns once and counts as unset.
|
|
611
|
+
def window_tokens(value, where)
|
|
612
|
+
return nil if value.nil?
|
|
613
|
+
|
|
614
|
+
tokens = Integer(value.to_s.strip, exception: false)
|
|
615
|
+
return tokens if tokens&.positive?
|
|
616
|
+
|
|
617
|
+
warn_once "Warning: #{where}: window_tokens must be a positive number of tokens; ignored"
|
|
618
|
+
nil
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# A true/false setting +key+ of a map entry, nil when unset; anything
|
|
622
|
+
# else warns once and counts as unset.
|
|
623
|
+
def bool_flag(value, where, key)
|
|
607
624
|
return nil if value.nil?
|
|
608
625
|
return value if value == true || value == false
|
|
609
626
|
|
|
@@ -611,7 +628,7 @@ module Samagotchi
|
|
|
611
628
|
return true if text == "true"
|
|
612
629
|
return false if text == "false"
|
|
613
630
|
|
|
614
|
-
warn_once "Warning: #{where}:
|
|
631
|
+
warn_once "Warning: #{where}: #{key} must be true or false; ignored"
|
|
615
632
|
nil
|
|
616
633
|
end
|
|
617
634
|
|
|
@@ -729,6 +746,10 @@ module Samagotchi
|
|
|
729
746
|
profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
|
|
730
747
|
first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
|
|
731
748
|
vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
|
|
749
|
+
remote = ConfigFile.bool_flag(raw_cfg.key?("remote") ? raw_cfg["remote"] : raw_cfg[:remote], "hosts entry '#{name}'",
|
|
750
|
+
"remote")
|
|
751
|
+
window = ConfigFile.window_tokens(raw_cfg.key?("window_tokens") ? raw_cfg["window_tokens"] : raw_cfg[:window_tokens],
|
|
752
|
+
"hosts entry '#{name}'")
|
|
732
753
|
sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
|
|
733
754
|
thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
|
|
734
755
|
unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
|
|
@@ -794,7 +815,8 @@ module Samagotchi
|
|
|
794
815
|
api: api_val&.to_sym, original_name: name, scheme: scheme,
|
|
795
816
|
url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
|
|
796
817
|
profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
|
|
797
|
-
vision: vision, sampling: sampling, thinking: thinking
|
|
818
|
+
vision: vision, sampling: sampling, thinking: thinking, remote: remote,
|
|
819
|
+
window_tokens: window }
|
|
798
820
|
end
|
|
799
821
|
end
|
|
800
822
|
|
|
@@ -935,7 +957,8 @@ module Samagotchi
|
|
|
935
957
|
location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
|
|
936
958
|
location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
|
|
937
959
|
"profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
|
|
938
|
-
"vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s
|
|
960
|
+
"vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s,
|
|
961
|
+
"remote" => v[:remote], "window_tokens" => v[:window_tokens]).compact
|
|
939
962
|
end
|
|
940
963
|
# Disabled hosts travel as just that, so a worker refuses "box:x"
|
|
941
964
|
# the way its parent does instead of sending it to the default host.
|
|
@@ -1023,6 +1046,8 @@ module Samagotchi
|
|
|
1023
1046
|
result[key][:sampling] = sampling if sampling
|
|
1024
1047
|
thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
|
|
1025
1048
|
result[key][:thinking] = thinking if thinking
|
|
1049
|
+
window = window_tokens(v.key?("window_tokens") ? v["window_tokens"] : v[:window_tokens], "models: #{key}")
|
|
1050
|
+
result[key][:window_tokens] = window if window
|
|
1026
1051
|
end
|
|
1027
1052
|
rescue StandardError
|
|
1028
1053
|
{}
|
|
@@ -9,11 +9,16 @@ module Samagotchi
|
|
|
9
9
|
# 1. the running server (Client#context_window, e.g. llama.cpp's n_ctx)
|
|
10
10
|
# 2. the host's model list (a chat adapter's #context_window, e.g. a
|
|
11
11
|
# provider's context_length)
|
|
12
|
-
# 3.
|
|
13
|
-
#
|
|
14
|
-
# 4.
|
|
12
|
+
# 3. models.<key>.window_tokens, else hosts.<name>.window_tokens
|
|
13
|
+
# (#setting: the most specific wins)
|
|
14
|
+
# 4. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
|
|
15
|
+
# config file)
|
|
16
|
+
# 5. DEFAULT_TOKENS
|
|
17
|
+
# Settings (3 and 4) only fill in when the server and its list report
|
|
18
|
+
# nothing.
|
|
15
19
|
#
|
|
16
|
-
# Sources: :server, :model_list, :
|
|
20
|
+
# Sources: :server, :model_list, :model_setting, :host_setting, :config
|
|
21
|
+
# (CLI or file), :env, :default.
|
|
17
22
|
module ContextWindow
|
|
18
23
|
DEFAULT_TOKENS = 256_000
|
|
19
24
|
|
|
@@ -22,7 +27,8 @@ module Samagotchi
|
|
|
22
27
|
module_function
|
|
23
28
|
|
|
24
29
|
# @param adapter [#context_window, nil] the host's chat adapter
|
|
25
|
-
|
|
30
|
+
# @param setting [Resolved, nil] the model's or host's window (#setting)
|
|
31
|
+
def resolve(client: nil, model: nil, adapter: nil, setting: nil)
|
|
26
32
|
server_tokens = client.context_window(model: model) if client.respond_to?(:context_window)
|
|
27
33
|
if positive_integer?(server_tokens)
|
|
28
34
|
@last_server = Resolved.new(tokens: server_tokens, source: :server)
|
|
@@ -34,10 +40,29 @@ module Samagotchi
|
|
|
34
40
|
@last_server = Resolved.new(tokens: listed, source: :model_list)
|
|
35
41
|
return @last_server
|
|
36
42
|
end
|
|
43
|
+
return @last_server = setting if setting
|
|
37
44
|
|
|
38
45
|
configured
|
|
39
46
|
end
|
|
40
47
|
|
|
48
|
+
# The window config.yml gives this model or its host:
|
|
49
|
+
# models.<key>.window_tokens (by the model's lookup names, as sampling
|
|
50
|
+
# and vision look a model up), else hosts.<name>.window_tokens.
|
|
51
|
+
# @param target [HostRegistry::ModelTarget]
|
|
52
|
+
# @param names [Array<String>] HostRegistry#lookup_names
|
|
53
|
+
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
54
|
+
# @return [Resolved, nil]
|
|
55
|
+
def setting(target, names:, models: nil)
|
|
56
|
+
models ||= ConfigFile.model_settings
|
|
57
|
+
_key, tokens = ConfigFile.model_setting(names, :window_tokens, models: models)
|
|
58
|
+
return Resolved.new(tokens: tokens, source: :model_setting) if positive_integer?(tokens)
|
|
59
|
+
|
|
60
|
+
tokens = target&.entry&.window_tokens
|
|
61
|
+
positive_integer?(tokens) ? Resolved.new(tokens: tokens, source: :host_setting) : nil
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
41
66
|
# The window without asking a server: config, env, then the default.
|
|
42
67
|
# `env` resolves against that env's config file instead of the live
|
|
43
68
|
# process config (for `chi self`).
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1362,8 +1362,10 @@ module Samagotchi
|
|
|
1362
1362
|
# +ended+: the end event is out (what follows is post-turn work).
|
|
1363
1363
|
# +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
|
|
1364
1364
|
# set on the kernel in #generate.
|
|
1365
|
+
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1366
|
+
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1365
1367
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1366
|
-
:settings) do
|
|
1368
|
+
:settings, :id) do
|
|
1367
1369
|
# The turn's boundary events carry the origin only when there is one,
|
|
1368
1370
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1369
1371
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1478,7 +1480,8 @@ module Samagotchi
|
|
|
1478
1480
|
@guardrail_wiring.begin_turn(origin)
|
|
1479
1481
|
|
|
1480
1482
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
1481
|
-
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1483
|
+
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
1484
|
+
id: SecureRandom.uuid)
|
|
1482
1485
|
end
|
|
1483
1486
|
private :begin_turn
|
|
1484
1487
|
|
|
@@ -1492,7 +1495,7 @@ module Samagotchi
|
|
|
1492
1495
|
image_refs, image_error = turn_image_refs(session, images)
|
|
1493
1496
|
# Emit turn_started event
|
|
1494
1497
|
bind_metrics(session)
|
|
1495
|
-
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
|
|
1498
|
+
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
1496
1499
|
turn_started[:continue] = true if turn.continue
|
|
1497
1500
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1498
1501
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
@@ -1565,7 +1568,8 @@ module Samagotchi
|
|
|
1565
1568
|
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1566
1569
|
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1567
1570
|
announce_thinking_level(*@turn_thinking)
|
|
1568
|
-
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil
|
|
1571
|
+
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
|
|
1572
|
+
window_setting: nil)
|
|
1569
1573
|
end
|
|
1570
1574
|
private :turn_settings
|
|
1571
1575
|
|
|
@@ -1594,7 +1598,7 @@ module Samagotchi
|
|
|
1594
1598
|
|
|
1595
1599
|
return if turn.continue
|
|
1596
1600
|
|
|
1597
|
-
user_message = { role: "user", content: turn.prompt }
|
|
1601
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
|
|
1598
1602
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
1599
1603
|
turn.messages << user_message
|
|
1600
1604
|
session.last_prompt = turn.prompt
|
|
@@ -1610,7 +1614,7 @@ module Samagotchi
|
|
|
1610
1614
|
# The turn's settings, set once here. The chat loop dispatches tools
|
|
1611
1615
|
# through the kernel without its #run: tag those dumps with this
|
|
1612
1616
|
# turn's model, not the last native one.
|
|
1613
|
-
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
|
|
1617
|
+
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
|
|
1614
1618
|
|
|
1615
1619
|
backend.complete(
|
|
1616
1620
|
messages: turn.messages,
|
|
@@ -2244,7 +2248,17 @@ module Samagotchi
|
|
|
2244
2248
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2245
2249
|
client = nil if adapter.remote?
|
|
2246
2250
|
end
|
|
2247
|
-
ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter
|
|
2251
|
+
ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter,
|
|
2252
|
+
setting: ContextWindow.setting(target, names: model_lookup_names(target)))
|
|
2253
|
+
rescue StandardError
|
|
2254
|
+
nil
|
|
2255
|
+
end
|
|
2256
|
+
|
|
2257
|
+
# The effective model's models:/hosts: window_tokens, read per turn
|
|
2258
|
+
# (after the hooks that may switch the model).
|
|
2259
|
+
def turn_window_setting
|
|
2260
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
2261
|
+
ContextWindow.setting(target, names: model_lookup_names(target))
|
|
2248
2262
|
rescue StandardError
|
|
2249
2263
|
nil
|
|
2250
2264
|
end
|
|
@@ -33,8 +33,11 @@ module Samagotchi
|
|
|
33
33
|
# chi started there answers as a parent (#parent_process?).
|
|
34
34
|
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
35
35
|
# Environment variables that say an agent runs this process: Claude
|
|
36
|
-
# Code's,
|
|
37
|
-
|
|
36
|
+
# Code's, the AI_AGENT convention, Codex CLI's (CODEX_THREAD_ID, which
|
|
37
|
+
# Codex sets on every command its shell tool runs), and chi's own.
|
|
38
|
+
# They survive a PTY wrapper (script, expect). The docs list them too
|
|
39
|
+
# (guardrails.md, sub-agent.md).
|
|
40
|
+
AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", "CODEX_THREAD_ID", PARENT_SESSION_ENV].freeze
|
|
38
41
|
# Rules that ask about chi's own config and hooks: the core
|
|
39
42
|
# ProtectedPaths asks and the guardrails bundle's shell rule.
|
|
40
43
|
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ipaddr"
|
|
3
4
|
require_relative "config"
|
|
4
5
|
require_relative "model_ref"
|
|
5
6
|
require_relative "client"
|
|
@@ -31,9 +32,12 @@ module Samagotchi
|
|
|
31
32
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
32
33
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
33
34
|
# sampling: the configured request parameters (SamplingSettings), nil when unset;
|
|
34
|
-
# thinking: the configured level (Thinking), nil when unset
|
|
35
|
+
# thinking: the configured level (Thinking), nil when unset;
|
|
36
|
+
# remote: the configured true/false (#remote?), nil when unset;
|
|
37
|
+
# window_tokens: the configured context window (ContextWindow.setting), nil when unset.
|
|
35
38
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
36
|
-
:first_token_timeout, :vision, :sampling, :thinking,
|
|
39
|
+
:first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
|
|
40
|
+
keyword_init: true) do
|
|
37
41
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
38
42
|
# raw-prompt loop.
|
|
39
43
|
def chat? = api == :openai
|
|
@@ -45,9 +49,12 @@ module Samagotchi
|
|
|
45
49
|
# configured, else the root's /v1.
|
|
46
50
|
def openai_base_url = url || "#{root_url}/v1"
|
|
47
51
|
|
|
48
|
-
# A provider on the network rather than a local server:
|
|
49
|
-
#
|
|
50
|
-
|
|
52
|
+
# A provider on the network rather than a local server: hosts.<name>.remote
|
|
53
|
+
# when set, else by its address (HostRegistry.remote_address?). An API
|
|
54
|
+
# key doesn't decide: a llama.cpp on the LAN can have one. A remote
|
|
55
|
+
# host's model list is cached longer, it gets a first-token limit, and
|
|
56
|
+
# it isn't asked for llama.cpp's /props.
|
|
57
|
+
def remote? = remote.nil? ? HostRegistry.remote_address?(scheme, host) : remote
|
|
51
58
|
|
|
52
59
|
def models_ttl = remote? ? REMOTE_CACHE_TTL_SECONDS : CACHE_TTL_SECONDS
|
|
53
60
|
|
|
@@ -89,7 +96,8 @@ module Samagotchi
|
|
|
89
96
|
entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
|
|
90
97
|
api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
|
|
91
98
|
profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
|
|
92
|
-
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking]
|
|
99
|
+
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
|
|
100
|
+
remote: cfg[:remote], window_tokens: cfg[:window_tokens])
|
|
93
101
|
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
94
102
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
95
103
|
api_key_env: entry.api_key_env, env: env)
|
|
@@ -113,6 +121,23 @@ module Samagotchi
|
|
|
113
121
|
@entries
|
|
114
122
|
end
|
|
115
123
|
|
|
124
|
+
# Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
|
|
125
|
+
LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
|
|
126
|
+
::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
|
|
127
|
+
|
|
128
|
+
# Whether an address is remote: https, or an http IP address outside
|
|
129
|
+
# the local nets (LOCAL_NETS). An http host named by a name (localhost,
|
|
130
|
+
# box, mac.local, gpu.lan) counts as local: chi doesn't look names up.
|
|
131
|
+
def self.remote_address?(scheme, host)
|
|
132
|
+
return true if scheme.to_s == "https"
|
|
133
|
+
|
|
134
|
+
address = IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
|
|
135
|
+
address = address.native if address.ipv4_mapped?
|
|
136
|
+
LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
|
|
137
|
+
rescue IPAddr::Error
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
116
141
|
# server.first_token_timeout, or nil when unset or unreadable.
|
|
117
142
|
def self.configured_first_token_timeout
|
|
118
143
|
Config.get("server.first_token_timeout")
|