samagotchi 0.121.1 → 0.123.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 +67 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +19 -7
- data/docs/cli.md +32 -1
- data/docs/configuration.md +55 -5
- data/docs/guardrails.md +119 -7
- data/docs/internals/engine.md +11 -2
- data/docs/internals/participants.md +76 -20
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/memory.md +33 -0
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +14 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +8 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
- data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/engine.rb +289 -12
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +46 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +85 -0
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/plugin/api.rb +7 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +42 -10
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +14 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_access/remote.rb +32 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
- data/lib/samagotchi/session_manager.rb +21 -7
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +13 -6
- data/lib/samagotchi/system_one/client.rb +16 -7
- data/lib/samagotchi/system_one/log.rb +25 -5
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +11 -1
- data/lib/samagotchi/toolkit.rb +27 -8
- data/lib/samagotchi/toolkit_binding.rb +149 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +320 -0
- data/lib/samagotchi/toolkit_suggest.rb +377 -0
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/update_command.rb +7 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -13
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_access.rb +4 -1
- data/lib/samagotchi/web/participant_review.rb +8 -4
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +34 -10
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +12 -13
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +7 -5
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +31 -2
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +7 -3
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_hub.rb +24 -3
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/web/update_check.rb +179 -0
- data/lib/samagotchi/worker.rb +60 -8
- data/lib/samagotchi/worker_link.rb +1 -0
- metadata +8 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -41,6 +41,8 @@ require_relative "llm/openai_chat"
|
|
|
41
41
|
require_relative "llm/turn_settings"
|
|
42
42
|
require_relative "session"
|
|
43
43
|
require_relative "toolkit_binding"
|
|
44
|
+
require_relative "participant_mode"
|
|
45
|
+
require_relative "toolkit_suggest"
|
|
44
46
|
require_relative "tool_loads"
|
|
45
47
|
require_relative "answer_tail"
|
|
46
48
|
require_relative "archive_store"
|
|
@@ -156,6 +158,8 @@ module Samagotchi
|
|
|
156
158
|
@no_interrupt = no_interrupt
|
|
157
159
|
@chat_backend = nil
|
|
158
160
|
@chat_backend_mutex = Mutex.new
|
|
161
|
+
@toolkit_audit_thread = nil
|
|
162
|
+
@toolkit_warmup_thread = nil
|
|
159
163
|
@host_registry = host_registry || HostRegistry.new
|
|
160
164
|
# A model given here (a worker's session model) is the one it runs,
|
|
161
165
|
# so its host is checked; the config default is only checked once it
|
|
@@ -237,7 +241,7 @@ module Samagotchi
|
|
|
237
241
|
# start: their names for the messages they wrote.
|
|
238
242
|
@participants = SessionParticipants.new(state_dir: -> { session_state_dir })
|
|
239
243
|
# Whether the system prompt carries the participants line.
|
|
240
|
-
@participants_line =
|
|
244
|
+
@participants_line = nil
|
|
241
245
|
# Who is in the review now, and who came or went (#announce_participants).
|
|
242
246
|
@participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
243
247
|
@session = nil
|
|
@@ -378,6 +382,9 @@ module Samagotchi
|
|
|
378
382
|
@kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
|
|
379
383
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
380
384
|
@kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
|
|
385
|
+
@kernel.participant_mode = -> { @guardrail_wiring.turn_mode }
|
|
386
|
+
# A pair turn stops once its participant passed their cap.
|
|
387
|
+
@kernel.generation_guard = -> { guard_participant_spend }
|
|
381
388
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
382
389
|
@prompt_warmup = PromptWarmup.new
|
|
383
390
|
@kernel.warmup = @prompt_warmup
|
|
@@ -407,7 +414,10 @@ module Samagotchi
|
|
|
407
414
|
# from config on every ask, and the running turn's cancel controller
|
|
408
415
|
# closes a judgment that is no longer wanted.
|
|
409
416
|
system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
|
|
410
|
-
cancel_controller: -> { active_cancel_controller }
|
|
417
|
+
cancel_controller: -> { active_cancel_controller },
|
|
418
|
+
# A participant's turn: their name on the owner's card, their
|
|
419
|
+
# grant's mode for the turn's (GuardrailWiring#turn_mode).
|
|
420
|
+
participant: ->(author) { @participants.entry_for(author, session: @session) }
|
|
411
421
|
)
|
|
412
422
|
end
|
|
413
423
|
private :build_guardrail_wiring
|
|
@@ -1082,6 +1092,10 @@ module Samagotchi
|
|
|
1082
1092
|
|
|
1083
1093
|
# How long #shutdown waits for the running anytime commands, in all.
|
|
1084
1094
|
SHUTDOWN_JOIN_SECONDS = 3.0
|
|
1095
|
+
# How long #shutdown waits for a running toolkit audit ask (a one-shot
|
|
1096
|
+
# -p --non-interactive exits right after its turn); a slower ask goes
|
|
1097
|
+
# unlogged.
|
|
1098
|
+
TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
|
|
1085
1099
|
|
|
1086
1100
|
# The REPL or the session's worker is leaving (/exit, an idle exit, a
|
|
1087
1101
|
# crash, TERM): stop the idle jobs, give the running anytime commands
|
|
@@ -1107,6 +1121,9 @@ module Samagotchi
|
|
|
1107
1121
|
|
|
1108
1122
|
thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
1109
1123
|
end
|
|
1124
|
+
@toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
|
|
1125
|
+
# The warm-up's answer is dropped anyway.
|
|
1126
|
+
stop_toolkit_warmup
|
|
1110
1127
|
left = threads.count(&:alive?)
|
|
1111
1128
|
Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
|
|
1112
1129
|
@plugin_tasks.kill_leftovers
|
|
@@ -1387,6 +1404,34 @@ module Samagotchi
|
|
|
1387
1404
|
|
|
1388
1405
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1389
1406
|
|
|
1407
|
+
# Whether a participant's pair turn must stop, read before each of its
|
|
1408
|
+
# generations (#guard_participant_spend): author ("p:<id>") → why (their
|
|
1409
|
+
# link ended, or they passed their cap), nil while it may go on. The
|
|
1410
|
+
# Worker sets it (Worker#participant_turn_stop); without one (the REPL)
|
|
1411
|
+
# nothing is checked mid-turn.
|
|
1412
|
+
attr_writer :participant_stop
|
|
1413
|
+
|
|
1414
|
+
# What a participant turn's stop is attributed to (HookRoutes#stop_turn).
|
|
1415
|
+
PARTICIPANTS_HOOK = "participants"
|
|
1416
|
+
|
|
1417
|
+
# Before each generation (KernelLoop#generation_guard): a pair turn whose
|
|
1418
|
+
# participant's link ended, or whose spend (Engine#metrics: their
|
|
1419
|
+
# finished turns, this one's finished generations, their side requests)
|
|
1420
|
+
# passed their cap, stops on the plugins' stop path (cost-cap's,
|
|
1421
|
+
# work-cap's), with its notice. A generation's own cost is known only
|
|
1422
|
+
# once it ends, so a turn can pass the cap by its last generation. The
|
|
1423
|
+
# turn-start check is the Worker's (Worker#participant_refusal); a
|
|
1424
|
+
# review turn only reads, and keeps to it.
|
|
1425
|
+
def guard_participant_spend
|
|
1426
|
+
author = turn_author
|
|
1427
|
+
return if author.nil? || @participant_stop.nil?
|
|
1428
|
+
return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
|
|
1429
|
+
|
|
1430
|
+
reason = @participant_stop.call(author) or return
|
|
1431
|
+
hook_stop_turn(reason, PARTICIPANTS_HOOK)
|
|
1432
|
+
end
|
|
1433
|
+
private :guard_participant_spend
|
|
1434
|
+
|
|
1390
1435
|
# The running turn's metrics so far, saved as a turn a worker died in
|
|
1391
1436
|
# would leave them (SessionMetrics#persist's running_turn): a worker's
|
|
1392
1437
|
# turn checkpoint (TurnCheckpoint) calls it when it writes.
|
|
@@ -1492,14 +1537,15 @@ module Samagotchi
|
|
|
1492
1537
|
change
|
|
1493
1538
|
end
|
|
1494
1539
|
|
|
1495
|
-
# Reads the session's participants at a turn's start; the first to join
|
|
1496
|
-
#
|
|
1497
|
-
#
|
|
1540
|
+
# Reads the session's participants at a turn's start; the first to join,
|
|
1541
|
+
# the first invited to act (a pair participant: the line's words) and
|
|
1542
|
+
# the last to leave rebuild the system prompt (its participants line),
|
|
1543
|
+
# once each: a turn in between sends the same prefix.
|
|
1498
1544
|
def refresh_participants!(session)
|
|
1499
|
-
|
|
1500
|
-
return if
|
|
1545
|
+
line = @participants.refresh!(session).prompt_mode(session)
|
|
1546
|
+
return if line == @participants_line
|
|
1501
1547
|
|
|
1502
|
-
@participants_line =
|
|
1548
|
+
@participants_line = line
|
|
1503
1549
|
@prompt_builder.reset!
|
|
1504
1550
|
end
|
|
1505
1551
|
private :refresh_participants!
|
|
@@ -1516,6 +1562,14 @@ module Samagotchi
|
|
|
1516
1562
|
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1517
1563
|
end
|
|
1518
1564
|
|
|
1565
|
+
# A participant's opening message keeps the rules it was delivered under
|
|
1566
|
+
# (ParticipantMode), stamped before any request carries it, so its header
|
|
1567
|
+
# never changes when the session's mode does.
|
|
1568
|
+
def participant_mode_fields(author)
|
|
1569
|
+
mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
|
|
1570
|
+
mode ? { participant_mode: mode.to_s } : {}
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1519
1573
|
# Why a turn stopped (TurnEndings#stop_facts).
|
|
1520
1574
|
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
1521
1575
|
private :stop_facts
|
|
@@ -2124,10 +2178,16 @@ module Samagotchi
|
|
|
2124
2178
|
turn_started[:cut_after] = steer_cut_after
|
|
2125
2179
|
# A participant's turn names them (author, author_name) for the UIs'
|
|
2126
2180
|
# badge; the owner's turn_started stays as it was.
|
|
2127
|
-
turn_started.merge!(author_fields(turn.origin.author, session: session)
|
|
2181
|
+
turn_started.merge!(author_fields(turn.origin.author, session: session),
|
|
2182
|
+
participant_mode_fields(turn.origin.author))
|
|
2128
2183
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2129
2184
|
raise image_error if image_error
|
|
2130
2185
|
|
|
2186
|
+
# On mode's ask starts here too; the turn joins it before
|
|
2187
|
+
# apply_toolkit! below, so its latency hides behind the probes and
|
|
2188
|
+
# the init wait.
|
|
2189
|
+
toolkit_suggestion = start_toolkit_suggestion(turn)
|
|
2190
|
+
|
|
2131
2191
|
# The window and the profile probes run after run_turn's probe-cancel
|
|
2132
2192
|
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
2133
2193
|
# The cache itself was dropped at the previous turn's end, so this
|
|
@@ -2142,7 +2202,10 @@ module Samagotchi
|
|
|
2142
2202
|
refuse_images!(turn.settings.vision) unless image_refs.empty?
|
|
2143
2203
|
# A Stop during the probes: the turn ends before the load-failure
|
|
2144
2204
|
# warnings, which show once, so the next turn still shows them.
|
|
2145
|
-
|
|
2205
|
+
if turn.controller.cancelled?
|
|
2206
|
+
toolkit_suggestion&.abandon(:canceled)
|
|
2207
|
+
return false
|
|
2208
|
+
end
|
|
2146
2209
|
|
|
2147
2210
|
announce_guardrail_failures(turn.on_event)
|
|
2148
2211
|
# Plugins' slow setup that brings tools (an MCP server's first
|
|
@@ -2155,9 +2218,12 @@ module Samagotchi
|
|
|
2155
2218
|
# session's toolkit (a /toolkit change since the last turn): what the
|
|
2156
2219
|
# prompt built next declares, unchanged until the next turn's start.
|
|
2157
2220
|
apply_staged_tools!
|
|
2221
|
+
# On mode: the suggested kit, before anything is declared (a Stop
|
|
2222
|
+
# during the wait is caught by the check below).
|
|
2223
|
+
settle_toolkit_suggestion(turn, toolkit_suggestion)
|
|
2158
2224
|
apply_toolkit!(session, turn.on_event)
|
|
2159
2225
|
refresh_participants!(session)
|
|
2160
|
-
# A Stop during the
|
|
2226
|
+
# A Stop during the waits above: the turn ends here,
|
|
2161
2227
|
# before the hooks run or a reminder is used up.
|
|
2162
2228
|
return false if turn.controller.cancelled?
|
|
2163
2229
|
|
|
@@ -2174,6 +2240,10 @@ module Samagotchi
|
|
|
2174
2240
|
|
|
2175
2241
|
turn_messages(turn, image_refs)
|
|
2176
2242
|
true
|
|
2243
|
+
ensure
|
|
2244
|
+
# A turn that ended (a failure, an Interrupt) before it settled the
|
|
2245
|
+
# ask: its line says so. Nothing once it was settled.
|
|
2246
|
+
toolkit_suggestion&.abandon(:failed)
|
|
2177
2247
|
end
|
|
2178
2248
|
private :prepare_turn
|
|
2179
2249
|
|
|
@@ -2289,7 +2359,7 @@ module Samagotchi
|
|
|
2289
2359
|
|
|
2290
2360
|
unless turn.continue
|
|
2291
2361
|
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2292
|
-
MessageTime::KEY => turn.at }
|
|
2362
|
+
**participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
|
|
2293
2363
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2294
2364
|
turn.messages << user_message
|
|
2295
2365
|
session.last_prompt = turn.prompt
|
|
@@ -2712,6 +2782,213 @@ module Samagotchi
|
|
|
2712
2782
|
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
2713
2783
|
private :apply_toolkit!
|
|
2714
2784
|
|
|
2785
|
+
# Start the toolkit classifier after :turn_started, for audit and on
|
|
2786
|
+
# mode alike (one eligibility, one snapshot, one thread). All session
|
|
2787
|
+
# and tool-registry reads happen here; the thread receives only this
|
|
2788
|
+
# snapshot. Audit never blocks: the thread logs its line. On mode
|
|
2789
|
+
# returns the ask's Pending, which the turn joins before apply_toolkit!
|
|
2790
|
+
# (#settle_toolkit_suggestion), with the turn's controller as the
|
|
2791
|
+
# request's cancel controller.
|
|
2792
|
+
# @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
|
|
2793
|
+
def start_toolkit_suggestion(turn)
|
|
2794
|
+
mode = Config.get("toolkit.suggest.mode").to_s
|
|
2795
|
+
return if mode == "off"
|
|
2796
|
+
|
|
2797
|
+
joined = mode == "on"
|
|
2798
|
+
session = turn.session
|
|
2799
|
+
default = Toolkit.of(session.toolkit)
|
|
2800
|
+
log = toolkit_suggestion_log
|
|
2801
|
+
# The predicate checks the session first; the kits (memory kits are
|
|
2802
|
+
# read from disk) and the log (read whole) only when it gets there.
|
|
2803
|
+
kits = nil
|
|
2804
|
+
candidates = nil
|
|
2805
|
+
return unless ToolkitSuggest.eligible?(
|
|
2806
|
+
session: session, prompt: turn.prompt, author: turn.origin.author,
|
|
2807
|
+
candidate_count: lambda {
|
|
2808
|
+
kits = @toolkit_binding.kits
|
|
2809
|
+
candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
|
|
2810
|
+
candidates.size
|
|
2811
|
+
},
|
|
2812
|
+
last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
|
|
2813
|
+
defer_turns: Config.get("toolkit.suggest.defer_turns"),
|
|
2814
|
+
defer_tokens: Config.get("toolkit.suggest.defer_tokens")
|
|
2815
|
+
)
|
|
2816
|
+
|
|
2817
|
+
# One ask at a time: an eligible turn while the last one still runs
|
|
2818
|
+
# goes unasked, and says so.
|
|
2819
|
+
if @toolkit_audit_thread&.alive?
|
|
2820
|
+
Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
|
|
2821
|
+
return
|
|
2822
|
+
end
|
|
2823
|
+
|
|
2824
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2825
|
+
return unless client
|
|
2826
|
+
|
|
2827
|
+
# D7: on a shared single-slot server audit's free ask would compete
|
|
2828
|
+
# with the turn's prefill, and any ask evicts the session's cached
|
|
2829
|
+
# prefix: on mode asks only on a first turn there (nothing is cached
|
|
2830
|
+
# yet worth keeping), never on the deferred path.
|
|
2831
|
+
if (!joined || ToolkitSuggest.model_reply?(session.messages)) && toolkit_judge_shares_local_host?(client.host)
|
|
2832
|
+
Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
|
|
2833
|
+
return
|
|
2834
|
+
end
|
|
2835
|
+
|
|
2836
|
+
# The warm-up's only value is the cache: the real ask never queues
|
|
2837
|
+
# behind it.
|
|
2838
|
+
stop_toolkit_warmup
|
|
2839
|
+
|
|
2840
|
+
# Rules, not tool lists: this runs before await_init_tasks and
|
|
2841
|
+
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
2842
|
+
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
2843
|
+
snapshots = candidates.map do |kit|
|
|
2844
|
+
ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
|
|
2845
|
+
rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
|
|
2846
|
+
end.freeze
|
|
2847
|
+
default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
|
|
2848
|
+
rules: ToolkitSuggest.rules(default.resolve(kits)))
|
|
2849
|
+
core_tools = @tools.entries(toolkit: nil).select(&:core?).map { |entry| entry.name.dup.freeze }.freeze
|
|
2850
|
+
state = ToolkitSuggest.question(
|
|
2851
|
+
default: default, kits: kits, project: File.basename(session.project_root.to_s),
|
|
2852
|
+
prompt: turn.prompt
|
|
2853
|
+
).last
|
|
2854
|
+
snapshot = ToolkitSuggest::Snapshot.new(
|
|
2855
|
+
session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
|
|
2856
|
+
default: default_snapshot, candidates: snapshots, core_tools: core_tools,
|
|
2857
|
+
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
2858
|
+
)
|
|
2859
|
+
threshold = Config.get("toolkit.suggest.threshold")
|
|
2860
|
+
pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
|
|
2861
|
+
timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
|
|
2862
|
+
# On mode: cut by a Stop while the turn waits (Pending#wait), not
|
|
2863
|
+
# by one after it went on; audit's ask is never cut.
|
|
2864
|
+
controller = joined ? pending.cancel_controller : nil
|
|
2865
|
+
@toolkit_audit_thread = Thread.new do
|
|
2866
|
+
judged = nil
|
|
2867
|
+
begin
|
|
2868
|
+
judged = ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
|
|
2869
|
+
cancel_controller: controller)
|
|
2870
|
+
rescue LLM::RequestCancelled
|
|
2871
|
+
nil
|
|
2872
|
+
rescue StandardError => e
|
|
2873
|
+
ToolkitSuggest.warn(e)
|
|
2874
|
+
ensure
|
|
2875
|
+
pending.finish(judged)
|
|
2876
|
+
end
|
|
2877
|
+
end
|
|
2878
|
+
joined ? pending : nil
|
|
2879
|
+
rescue StandardError => e
|
|
2880
|
+
ToolkitSuggest.warn(e)
|
|
2881
|
+
nil
|
|
2882
|
+
end
|
|
2883
|
+
private :start_toolkit_suggestion
|
|
2884
|
+
|
|
2885
|
+
# On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
|
|
2886
|
+
# suggestion) up to System 1's timeout from its start, or until a Stop,
|
|
2887
|
+
# and put the session on a candidate picked with confidence ≥ threshold
|
|
2888
|
+
# (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
|
|
2889
|
+
# other outcome (the default, below threshold, undecided, no judgment,
|
|
2890
|
+
# a timeout, a Stop) leaves the session on its default. Nothing here
|
|
2891
|
+
# sends a request; prepare_turn's cancel check ends a stopped turn. A
|
|
2892
|
+
# Stop wins over an answer already in (Pending#wait checks it first).
|
|
2893
|
+
def settle_toolkit_suggestion(turn, pending)
|
|
2894
|
+
return if pending.nil?
|
|
2895
|
+
|
|
2896
|
+
outcome, judged = pending.wait(turn.controller)
|
|
2897
|
+
unless outcome == :answered
|
|
2898
|
+
Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
|
|
2899
|
+
return
|
|
2900
|
+
end
|
|
2901
|
+
return if judged.nil?
|
|
2902
|
+
|
|
2903
|
+
pending.record(toolkit_suggestion_outcome(turn.session, judged))
|
|
2904
|
+
rescue StandardError => e
|
|
2905
|
+
ToolkitSuggest.warn(e)
|
|
2906
|
+
pending&.record("failed")
|
|
2907
|
+
end
|
|
2908
|
+
private :settle_toolkit_suggestion
|
|
2909
|
+
|
|
2910
|
+
# What the turn does with an answered ask, as the log line's did:
|
|
2911
|
+
# suggested (switched), chosen (the toolkit isn't the default any
|
|
2912
|
+
# more), unavailable (the kit is gone), defer or default.
|
|
2913
|
+
def toolkit_suggestion_outcome(session, judged)
|
|
2914
|
+
case judged.would
|
|
2915
|
+
when "suggested"
|
|
2916
|
+
return "chosen" unless session.toolkit_origin == "default"
|
|
2917
|
+
return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
|
|
2918
|
+
|
|
2919
|
+
switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
|
|
2920
|
+
deferred: ToolkitSuggest.model_reply?(session.messages))
|
|
2921
|
+
switched ? "suggested" : "chosen"
|
|
2922
|
+
when "defer" then "defer"
|
|
2923
|
+
else "default"
|
|
2924
|
+
end
|
|
2925
|
+
end
|
|
2926
|
+
private :toolkit_suggestion_outcome
|
|
2927
|
+
|
|
2928
|
+
# On mode, as an idle worker starts (Worker#start_background): one ask
|
|
2929
|
+
# with the real question and a stand-in prompt, on its own thread,
|
|
2930
|
+
# never logged and never waited for, so System 1's server has the
|
|
2931
|
+
# question's prefix cached for the first prompt. Only for a session a
|
|
2932
|
+
# first prompt could be asked for: origin default, no model reply, not
|
|
2933
|
+
# a delegate child or a review session, at least two candidates; and
|
|
2934
|
+
# not when the worker starts on a first prompt (that ask comes at once).
|
|
2935
|
+
def warm_toolkit_suggestion!
|
|
2936
|
+
return unless Config.get("toolkit.suggest.mode").to_s == "on"
|
|
2937
|
+
|
|
2938
|
+
session = @session
|
|
2939
|
+
return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
|
|
2940
|
+
return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
|
|
2941
|
+
|
|
2942
|
+
default = Toolkit.of(session.toolkit)
|
|
2943
|
+
kits = @toolkit_binding.kits
|
|
2944
|
+
return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
|
|
2945
|
+
|
|
2946
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2947
|
+
return unless client
|
|
2948
|
+
# D7: on the model's own single-slot server it would evict the
|
|
2949
|
+
# session's cached prefix for a question that may never come.
|
|
2950
|
+
return if toolkit_judge_shares_local_host?(client.host)
|
|
2951
|
+
|
|
2952
|
+
project = File.basename(session.project_root.to_s)
|
|
2953
|
+
@toolkit_warmup_thread = Thread.new do
|
|
2954
|
+
ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
|
|
2955
|
+
end
|
|
2956
|
+
rescue StandardError => e
|
|
2957
|
+
ToolkitSuggest.warn(e)
|
|
2958
|
+
nil
|
|
2959
|
+
end
|
|
2960
|
+
public :warm_toolkit_suggestion!
|
|
2961
|
+
|
|
2962
|
+
# A still-running warm-up, stopped: a real ask (or the worker leaving)
|
|
2963
|
+
# doesn't wait behind it.
|
|
2964
|
+
def stop_toolkit_warmup
|
|
2965
|
+
@toolkit_warmup_thread&.kill
|
|
2966
|
+
@toolkit_warmup_thread = nil
|
|
2967
|
+
end
|
|
2968
|
+
private :stop_toolkit_warmup
|
|
2969
|
+
|
|
2970
|
+
def toolkit_suggestion_log
|
|
2971
|
+
path = ToolkitSuggest.log_path(session_state_dir)
|
|
2972
|
+
SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2973
|
+
warn: lambda { |reason|
|
|
2974
|
+
Log.warn(:toolkit, "toolkit_suggest_log_failed", echo: "[samagotchi:toolkit] #{reason}")
|
|
2975
|
+
})
|
|
2976
|
+
end
|
|
2977
|
+
private :toolkit_suggestion_log
|
|
2978
|
+
|
|
2979
|
+
# D7: System 1's host is the session's model server, and a local one
|
|
2980
|
+
# (a single-slot Splash or llama.cpp): the same name, or two hosts:
|
|
2981
|
+
# entries for one server root (scheme, host, port).
|
|
2982
|
+
def toolkit_judge_shares_local_host?(judge_name)
|
|
2983
|
+
model_host, = @host_registry.host_for_model(effective_model_name)
|
|
2984
|
+
return false if model_host.nil? || model_host.remote?
|
|
2985
|
+
return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
|
|
2986
|
+
|
|
2987
|
+
judge = @host_registry.find_entry(judge_name)
|
|
2988
|
+
!judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
|
|
2989
|
+
end
|
|
2990
|
+
private :toolkit_judge_shares_local_host?
|
|
2991
|
+
|
|
2715
2992
|
# The session's toolkit (see #initialize for when).
|
|
2716
2993
|
def build_toolkit_binding
|
|
2717
2994
|
ToolkitBinding.new(
|
|
@@ -56,7 +56,6 @@ module Samagotchi
|
|
|
56
56
|
@announce_or_hold = announce_or_hold
|
|
57
57
|
@working_set_emitted = nil
|
|
58
58
|
end
|
|
59
|
-
# rubocop:enable Metrics/ParameterLists
|
|
60
59
|
|
|
61
60
|
# Always return a handler so raw kernel loop events reach the persistent
|
|
62
61
|
# SessionObserver (and thus the analytics collector) even when there is no
|
|
@@ -5,6 +5,7 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "rbconfig"
|
|
7
7
|
require "rubygems/package"
|
|
8
|
+
require "time"
|
|
8
9
|
require_relative "version"
|
|
9
10
|
require_relative "redacted_inspect"
|
|
10
11
|
|
|
@@ -22,12 +23,14 @@ module Samagotchi
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
|
|
26
|
+
RELEASES_URL = URI("https://rubygems.org/api/v1/versions/samagotchi.json")
|
|
25
27
|
# Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
|
|
26
28
|
LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
|
|
27
29
|
TIMEOUT = 5
|
|
28
30
|
|
|
29
31
|
# source is what `gem install` takes: the gem's name, or a .gem file.
|
|
30
32
|
Latest = Struct.new(:version, :source, keyword_init: true)
|
|
33
|
+
Release = Data.define(:version, :created_at, :prerelease)
|
|
31
34
|
|
|
32
35
|
# Runs a command without a shell; [output, success].
|
|
33
36
|
class Runner
|
|
@@ -57,7 +60,7 @@ module Samagotchi
|
|
|
57
60
|
local = @env[LOCAL_ENV].to_s
|
|
58
61
|
return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
|
|
59
62
|
|
|
60
|
-
version = JSON.parse(
|
|
63
|
+
version = JSON.parse(fetch_body(LATEST_URL).to_s)["version"].to_s
|
|
61
64
|
raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
|
|
62
65
|
|
|
63
66
|
Latest.new(version: version, source: "samagotchi")
|
|
@@ -67,6 +70,29 @@ module Samagotchi
|
|
|
67
70
|
raise Error, "#{e.class}: #{e.message}".lines.first.strip
|
|
68
71
|
end
|
|
69
72
|
|
|
73
|
+
# @return [Array<Release>] rubygems releases, or the one local smoke gem
|
|
74
|
+
def releases
|
|
75
|
+
local = @env[LOCAL_ENV].to_s
|
|
76
|
+
return [local_release(local)] unless local.empty?
|
|
77
|
+
|
|
78
|
+
records = JSON.parse(fetch_body(RELEASES_URL).to_s)
|
|
79
|
+
raise Error, "rubygems.org answered without a releases list" unless records.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
records.map do |record|
|
|
82
|
+
version = record["number"].to_s
|
|
83
|
+
created_at = record["created_at"].to_s
|
|
84
|
+
unless Gem::Version.correct?(version) && !created_at.empty? && [true, false].include?(record["prerelease"])
|
|
85
|
+
raise Error, "rubygems.org answered with an invalid release"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Release.new(version: version, created_at: Time.iso8601(created_at), prerelease: record["prerelease"])
|
|
89
|
+
end
|
|
90
|
+
rescue Error
|
|
91
|
+
raise
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
raise Error, "#{e.class}: #{e.message}".lines.first.strip
|
|
94
|
+
end
|
|
95
|
+
|
|
70
96
|
def newer?(latest, current = VERSION)
|
|
71
97
|
Gem::Version.new(latest.version) > Gem::Version.new(current)
|
|
72
98
|
end
|
|
@@ -83,9 +109,20 @@ module Samagotchi
|
|
|
83
109
|
|
|
84
110
|
private
|
|
85
111
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
def local_release(path)
|
|
113
|
+
version = Gem::Package.new(path).spec.version
|
|
114
|
+
Release.new(version: version.to_s, created_at: File.mtime(path), prerelease: version.prerelease?)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fetch_body(url)
|
|
118
|
+
return @fetcher.call if @fetcher.arity.zero?
|
|
119
|
+
|
|
120
|
+
@fetcher.call(url)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fetch(url = LATEST_URL)
|
|
124
|
+
Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
125
|
+
request = Net::HTTP::Get.new(url)
|
|
89
126
|
request["User-Agent"] = USER_AGENT
|
|
90
127
|
response = http.request(request)
|
|
91
128
|
raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
|
|
@@ -15,6 +15,7 @@ require_relative "edit_preview"
|
|
|
15
15
|
require_relative "system_one"
|
|
16
16
|
require_relative "tool_activity"
|
|
17
17
|
require_relative "turn_origin"
|
|
18
|
+
require_relative "participant_mode"
|
|
18
19
|
|
|
19
20
|
module Samagotchi
|
|
20
21
|
# An Engine's tool guardrails: who can approve (the interface), the YAML
|
|
@@ -44,8 +45,11 @@ module Samagotchi
|
|
|
44
45
|
# @param system_one [#call] → SystemOne::Client or nil: the judgment client
|
|
45
46
|
# (nil while `guardrails.system_one.mode` is off or no host is set)
|
|
46
47
|
# @param cancel_controller [#call] → the running turn's CancellationController, nil
|
|
48
|
+
# @param participant [#call] (author) → the participant's
|
|
49
|
+
# Participants::Entry (participants.json: their name, their grant's
|
|
50
|
+
# mode), nil for none
|
|
47
51
|
def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
|
|
48
|
-
notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {})
|
|
52
|
+
notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {}, participant: ->(_author) {})
|
|
49
53
|
@scratch = scratch
|
|
50
54
|
@hooks_lookup = hooks
|
|
51
55
|
@tools_lookup = tools
|
|
@@ -57,6 +61,8 @@ module Samagotchi
|
|
|
57
61
|
@notify = notify
|
|
58
62
|
@system_one = system_one
|
|
59
63
|
@cancel_controller = cancel_controller
|
|
64
|
+
@participant = participant
|
|
65
|
+
@turn_mode = nil
|
|
60
66
|
# Bundle name → the rules last loaded from it whole, kept while the
|
|
61
67
|
# installed version needs a newer chi; [name, version] said so once.
|
|
62
68
|
@loaded_bundle_rules = {}
|
|
@@ -95,12 +101,24 @@ module Samagotchi
|
|
|
95
101
|
end
|
|
96
102
|
|
|
97
103
|
# A turn starts: its origin (Engine#run_turn's TurnOrigin; nil or a
|
|
98
|
-
# Hash is read as one, TurnOrigin.of),
|
|
104
|
+
# Hash is read as one, TurnOrigin.of), the git facts read afresh, and
|
|
105
|
+
# a participant's turn's mode decided once for the whole turn
|
|
106
|
+
# (#turn_mode).
|
|
99
107
|
def begin_turn(origin)
|
|
100
108
|
@origin = TurnOrigin.of(origin)
|
|
101
109
|
@git = Guardrails::GitInfo.new
|
|
110
|
+
author = turn_author
|
|
111
|
+
@turn_entry = author && @participant.call(author)
|
|
112
|
+
@turn_mode = author && ParticipantMode.for_turn(@session_lookup.call, @turn_entry)
|
|
102
113
|
end
|
|
103
114
|
|
|
115
|
+
# The running participant turn's mode (ParticipantMode.for_turn: :pair
|
|
116
|
+
# only in a working session for a participant invited to act), decided
|
|
117
|
+
# at its start; nil for the owner's turns. The boundary in #checks, the
|
|
118
|
+
# Engine's stamp and mid-turn stop, and ToolRunner read it.
|
|
119
|
+
# @return [Symbol, nil]
|
|
120
|
+
attr_reader :turn_mode
|
|
121
|
+
|
|
104
122
|
# Who wrote the running turn when it isn't the session's owner (its
|
|
105
123
|
# origin's participant author); nil for the owner's turns and every
|
|
106
124
|
# other origin.
|
|
@@ -125,23 +143,34 @@ module Samagotchi
|
|
|
125
143
|
|
|
126
144
|
# The gate's core checks, in order. A delegate child's boundary comes
|
|
127
145
|
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
128
|
-
# its once/session scopes) when a strict rule asks too. The
|
|
129
|
-
# boundary comes before both
|
|
130
|
-
#
|
|
131
|
-
#
|
|
146
|
+
# its once/session scopes) when a strict rule asks too. The
|
|
147
|
+
# participant's boundary comes before both, by the session's
|
|
148
|
+
# ParticipantMode: a review turn's (ReviewBoundary) or a pair turn's
|
|
149
|
+
# (PairBoundary). Either denies what a participant's turn may not do,
|
|
150
|
+
# never asks about it; both are no-ops for the owner's turns.
|
|
132
151
|
def checks
|
|
133
152
|
rules = self.rules
|
|
134
|
-
[@failures,
|
|
153
|
+
[@failures, participant_boundary, rules.hook_asks, protected_paths,
|
|
135
154
|
(@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
|
|
136
155
|
end
|
|
137
156
|
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
157
|
+
# The boundary for a participant's turn in this session (#checks).
|
|
158
|
+
def participant_boundary
|
|
159
|
+
@turn_mode == ParticipantMode::PAIR ? pair_boundary : review_boundary
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A participant's review turn's boundary (Guardrails::ReviewBoundary): a
|
|
163
|
+
# no-op unless the running turn's author is a participant's.
|
|
141
164
|
def review_boundary
|
|
142
165
|
@review_boundary ||= Guardrails::ReviewBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
|
|
143
166
|
end
|
|
144
167
|
|
|
168
|
+
# A participant's pair turn's boundary (Guardrails::PairBoundary): a
|
|
169
|
+
# no-op unless the running turn's author is a participant's.
|
|
170
|
+
def pair_boundary
|
|
171
|
+
@pair_boundary ||= Guardrails::PairBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
|
|
172
|
+
end
|
|
173
|
+
|
|
145
174
|
# The delegate child's boundary (Guardrails::ChildBoundary); nil for
|
|
146
175
|
# any other session.
|
|
147
176
|
def child_boundary
|
|
@@ -263,8 +292,12 @@ module Samagotchi
|
|
|
263
292
|
label = verdict.targets&.label ||
|
|
264
293
|
ToolActivity.plugin_label(acts_as || verdict.call[:name].to_s, registry: @tools_lookup.call)
|
|
265
294
|
# verdict.call is the call that will run (a hook may have replaced it).
|
|
295
|
+
# A participant's turn's ask names them and offers once only
|
|
296
|
+
# (Approval.payload's author:), whichever session it is.
|
|
297
|
+
author = turn_author
|
|
266
298
|
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
|
|
267
|
-
only: (SCRATCH_SCOPES if @scratch)
|
|
299
|
+
only: (SCRATCH_SCOPES if @scratch), author: author,
|
|
300
|
+
author_name: (@turn_entry&.name if author))
|
|
268
301
|
# A call on chi's own config, hooks or guardrails is the user's to
|
|
269
302
|
# allow, never a parent agent's (Guardrails::ParentPolicy).
|
|
270
303
|
payload[:user_only] = Guardrails::ParentApprovals.protected_facts?(payload[:approval])
|
|
@@ -310,7 +343,8 @@ module Samagotchi
|
|
|
310
343
|
# System 1's judgment of an ask, written to the log before the user is
|
|
311
344
|
# asked, with the decision it led to: "auto" (it answered for the user),
|
|
312
345
|
# "asked" (on mode, the user decides) or "audit" (audit mode). A
|
|
313
|
-
# participant's review
|
|
346
|
+
# participant's turn (review or pair) is never judged, so never
|
|
347
|
+
# auto-allowed: its asks go to the owner. No client (mode off, no
|
|
314
348
|
# host) means no line at all. A judgment that fails is logged with p nil,
|
|
315
349
|
# so the failures are in the log too.
|
|
316
350
|
# @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
|