samagotchi 0.153.0 → 0.154.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 +28 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundles.md +9 -12
- data/docs/cli.md +80 -96
- data/docs/configuration.md +27 -26
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +8 -10
- data/docs/hooks.md +9 -9
- data/docs/internals/attached-context.md +2 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +3 -3
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +33 -7
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +7 -7
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +23 -28
- data/docs/sessions.md +24 -24
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +16 -6
- data/lib/samagotchi/answer_text.rb +34 -0
- data/lib/samagotchi/bridge.rb +43 -32
- data/lib/samagotchi/bridge_client.rb +10 -9
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +62 -2646
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +4 -5
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +10 -11
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +59 -36
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +3 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +102 -109
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +14 -16
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +4 -5
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +39 -50
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -3
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +100 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- metadata +29 -6
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -17,7 +17,7 @@ require_relative "turn_endings"
|
|
|
17
17
|
require_relative "turn_origin"
|
|
18
18
|
require_relative "revise_anchor"
|
|
19
19
|
require_relative "verify_anchor"
|
|
20
|
-
require_relative "
|
|
20
|
+
require_relative "redact"
|
|
21
21
|
require_relative "verdict"
|
|
22
22
|
require_relative "model_profile"
|
|
23
23
|
require_relative "effective_model"
|
|
@@ -108,6 +108,26 @@ require_relative "prompt_warmup"
|
|
|
108
108
|
require_relative "working_set"
|
|
109
109
|
require_relative "participants_watch"
|
|
110
110
|
require_relative "session_participants"
|
|
111
|
+
require_relative "engine/steering"
|
|
112
|
+
require_relative "engine/side_requests"
|
|
113
|
+
require_relative "engine/recap"
|
|
114
|
+
require_relative "engine/models"
|
|
115
|
+
require_relative "engine/model_settings"
|
|
116
|
+
require_relative "engine/events"
|
|
117
|
+
require_relative "engine/cards"
|
|
118
|
+
require_relative "engine/objectives"
|
|
119
|
+
require_relative "engine/messages"
|
|
120
|
+
require_relative "engine/plugins"
|
|
121
|
+
require_relative "engine/hooks"
|
|
122
|
+
require_relative "engine/guardrails"
|
|
123
|
+
require_relative "engine/participants"
|
|
124
|
+
require_relative "engine/session"
|
|
125
|
+
require_relative "engine/verify"
|
|
126
|
+
require_relative "engine/questions"
|
|
127
|
+
require_relative "engine/toolkit"
|
|
128
|
+
require_relative "engine/context_judge"
|
|
129
|
+
require_relative "engine/turn_helpers"
|
|
130
|
+
require_relative "engine/turn_end"
|
|
111
131
|
|
|
112
132
|
module Samagotchi
|
|
113
133
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -115,7 +135,36 @@ module Samagotchi
|
|
|
115
135
|
#
|
|
116
136
|
# It exposes an event-based API (`on_event`) so that any UI can run
|
|
117
137
|
# turns without coupling to terminal rendering.
|
|
138
|
+
#
|
|
139
|
+
# This file keeps the wiring (initialize, the build_* methods, the
|
|
140
|
+
# kernel) and the turn skeleton (run_turn, begin_turn, prepare_turn's
|
|
141
|
+
# ordered steps, generate, end_turn) and shutdown. The rest is in parts
|
|
142
|
+
# by concern: lib/samagotchi/engine/<concern>.rb holds module
|
|
143
|
+
# Engine::<Concern>Part (the Part suffix keeps a part's name off the
|
|
144
|
+
# Samagotchi constants Engine's code names bare: Toolkit, Objectives…),
|
|
145
|
+
# included here; they share Engine's ivars.
|
|
118
146
|
class Engine
|
|
147
|
+
include SteeringPart
|
|
148
|
+
include SideRequestsPart
|
|
149
|
+
include RecapPart
|
|
150
|
+
include ModelsPart
|
|
151
|
+
include ModelSettingsPart
|
|
152
|
+
include EventsPart
|
|
153
|
+
include CardsPart
|
|
154
|
+
include ObjectivesPart
|
|
155
|
+
include MessagesPart
|
|
156
|
+
include PluginsPart
|
|
157
|
+
include HooksPart
|
|
158
|
+
include GuardrailsPart
|
|
159
|
+
include ParticipantsPart
|
|
160
|
+
include SessionPart
|
|
161
|
+
include VerifyPart
|
|
162
|
+
include QuestionsPart
|
|
163
|
+
include ToolkitPart
|
|
164
|
+
include ContextJudgePart
|
|
165
|
+
include TurnHelpersPart
|
|
166
|
+
include TurnEndPart
|
|
167
|
+
|
|
119
168
|
# Raised by #answer_question when the question it targets is no longer
|
|
120
169
|
# open (never asked, superseded, already answered or cancelled). A subclass
|
|
121
170
|
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
@@ -182,7 +231,7 @@ module Samagotchi
|
|
|
182
231
|
@host_registry = host_registry || HostRegistry.new
|
|
183
232
|
# A model given here (a worker's session model) is the one it runs,
|
|
184
233
|
# so its host is checked; the config default is only checked once it
|
|
185
|
-
# is used (the
|
|
234
|
+
# is used (the one-shot checks the model it starts on, #switch_model!).
|
|
186
235
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
187
236
|
ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
|
|
188
237
|
# The model choice and the profile resolved for it, under one mutex.
|
|
@@ -274,9 +323,8 @@ module Samagotchi
|
|
|
274
323
|
end
|
|
275
324
|
# The idle subsystems' (session recap + reminders) inactivity clock
|
|
276
325
|
# starts now, after the plugins loaded. `record_activity` is the
|
|
277
|
-
# single seam every
|
|
278
|
-
#
|
|
279
|
-
# across UIs.
|
|
326
|
+
# single seam every host calls (run_turn itself, the worker after a
|
|
327
|
+
# reminder turn), so the idle layer's clock is identical across hosts.
|
|
280
328
|
@turn_state.restart_clock!
|
|
281
329
|
@side_client_mutex = Mutex.new
|
|
282
330
|
# Kept for a rebuild under a bound session's own recap values.
|
|
@@ -364,7 +412,7 @@ module Samagotchi
|
|
|
364
412
|
# The persistent observers of every turn's events.
|
|
365
413
|
def subscribe_session_observers
|
|
366
414
|
# The metrics collector is a persistent observer so every run_turn event
|
|
367
|
-
# (
|
|
415
|
+
# (a -p --non-interactive one-shot and SessionManager workers)
|
|
368
416
|
# feeds it automatically.
|
|
369
417
|
@session_observer.subscribe(observer: @metrics)
|
|
370
418
|
# And the event trail in the debug log (`turn` records).
|
|
@@ -572,330 +620,11 @@ module Samagotchi
|
|
|
572
620
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
573
621
|
end
|
|
574
622
|
|
|
575
|
-
# The backend for the next turn: the loop the effective model's host speaks.
|
|
576
|
-
def backend(model = effective_model)
|
|
577
|
-
backend_for(model)
|
|
578
|
-
end
|
|
579
|
-
|
|
580
|
-
# Record that activity happened (user input or a completed turn): the
|
|
581
|
-
# idle jobs' seam (TurnState#record_activity). Each call advances both
|
|
582
|
-
# the last-activity timestamp and the activity sequence.
|
|
583
|
-
# @param now [Float, nil] injectable monotonic time (defaults to now)
|
|
584
|
-
def record_activity(now = nil)
|
|
585
|
-
@turn_state.record_activity(now)
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
# @return [Float] monotonic seconds of the last recorded activity
|
|
589
|
-
def last_activity_at
|
|
590
|
-
@turn_state.last_activity_at
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
# @return [Integer] monotonically-increasing activity counter (advanced by
|
|
594
|
-
# #record_activity; lets the idle detector summarize once per idle window)
|
|
595
|
-
def activity_seq
|
|
596
|
-
@turn_state.activity_seq
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
# @return [Boolean] true while a turn is in flight (the idle jobs never
|
|
600
|
-
# fire, nor render, while the model is generating)
|
|
601
|
-
def turn_running?
|
|
602
|
-
@turn_state.running?
|
|
603
|
-
end
|
|
604
|
-
|
|
605
|
-
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
606
|
-
# conversation at the loop's next iteration boundary as its own user
|
|
607
|
-
# message (kind: "steer", source:). Callable from any thread; never
|
|
608
|
-
# blocks. With no turn running it does nothing (it never starts one).
|
|
609
|
-
# True means queued, not merged: at the after-answer boundary, and when
|
|
610
|
-
# the turn ends first, it is dropped (logged).
|
|
611
|
-
# @return [Boolean] whether it was queued
|
|
612
|
-
def steer(text, source:)
|
|
613
|
-
@turn_state.steer(text, source: source)
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
# A message for the running turn from +source+ cuts the generation
|
|
617
|
-
# streaming only thinking (SteerCut#cut_for_steer). Call it after the
|
|
618
|
-
# message is queued, with the #input_epoch read before queueing it.
|
|
619
|
-
# @return [Boolean] whether a generation was cut now
|
|
620
|
-
def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
|
|
621
|
-
|
|
622
|
-
# The drains that took input so far: read it before queueing a message
|
|
623
|
-
# for #cut_for_steer.
|
|
624
|
-
def input_epoch = @steer_cut.input_epoch
|
|
625
|
-
|
|
626
|
-
# The running generation's phase (SteerCut#generation_snapshot): what
|
|
627
|
-
# Bridge#snapshot puts in current_turn.generation.
|
|
628
|
-
def generation_snapshot = @steer_cut.generation_snapshot
|
|
629
|
-
|
|
630
|
-
# Like #steer, for the turn that begins next (a continue turn answered
|
|
631
|
-
# with a text): it joins that turn at its first boundary. Callable from
|
|
632
|
-
# any thread; a turn that never begins keeps it for the next one, unless
|
|
633
|
-
# #drop_carried_steers drops it.
|
|
634
|
-
# @return [Boolean] whether it was queued (false: blank)
|
|
635
|
-
def steer_next_turn(text, source:)
|
|
636
|
-
@turn_state.steer_next_turn(text, source: source)
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
# Drop the #steer_next_turn steers no turn began with (the continue turn
|
|
640
|
-
# failed before it began), logged; a no-op once a turn took them.
|
|
641
|
-
def drop_carried_steers
|
|
642
|
-
log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
646
|
-
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
647
|
-
# boundary the steers are dropped: the model answered, and a nudge would
|
|
648
|
-
# only restart the turn. The steer part fails on its own, never taking
|
|
649
|
-
# the user's lines with it. Items are a String (a user line), a
|
|
650
|
-
# Steer::Line (a worker's input line with its sender) or a steer Hash.
|
|
651
|
-
def turn_drain(pending_input)
|
|
652
|
-
lambda do |at_answer: false|
|
|
653
|
-
lines = pending_input ? Array(pending_input.call) : []
|
|
654
|
-
# The input went to the model: a message that waited to cut is in.
|
|
655
|
-
@steer_cut.delivered! unless lines.empty?
|
|
656
|
-
lines + take_steers(at_answer)
|
|
657
|
-
end
|
|
658
|
-
end
|
|
659
|
-
private :turn_drain
|
|
660
|
-
|
|
661
|
-
def take_steers(drop)
|
|
662
|
-
steers = @turn_state.take_steers
|
|
663
|
-
return steers unless drop
|
|
664
|
-
|
|
665
|
-
log_dropped_steers(steers, "answered")
|
|
666
|
-
[]
|
|
667
|
-
rescue StandardError => e
|
|
668
|
-
Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
669
|
-
[]
|
|
670
|
-
end
|
|
671
|
-
private :take_steers
|
|
672
|
-
|
|
673
|
-
def log_dropped_steers(steers, why)
|
|
674
|
-
steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
|
|
675
|
-
end
|
|
676
|
-
private :log_dropped_steers
|
|
677
|
-
|
|
678
|
-
# @return [Array<String>] reminder names queued for a synthetic REPL turn
|
|
679
|
-
def due_reminder_names
|
|
680
|
-
@reminder_queue.pending_names
|
|
681
|
-
end
|
|
682
|
-
|
|
683
|
-
# Queue reminder names for a synthetic REPL turn (IdleReminders callback).
|
|
684
|
-
def note_due_reminders(names)
|
|
685
|
-
@reminder_queue.note_pending(names)
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
def clear_due_reminder_names!
|
|
689
|
-
@reminder_queue.clear_pending!
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
# @return [CancellationController, nil] active turn's cancellation controller
|
|
693
|
-
def active_cancel_controller
|
|
694
|
-
@turn_state.controller
|
|
695
|
-
end
|
|
696
|
-
|
|
697
|
-
# Whether a message a person sent waits for the running turn's next
|
|
698
|
-
# boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
|
|
699
|
-
# returns on it, an execute hands its command over early
|
|
700
|
-
# (PeerView#input_waiting?). False outside a turn, for a turn given no
|
|
701
|
-
# probe, and when the probe raises (warned once a turn; the tool never
|
|
702
|
-
# fails for it).
|
|
703
|
-
def input_waiting?
|
|
704
|
-
probe = @turn_state.input_waiting
|
|
705
|
-
!!probe&.call
|
|
706
|
-
rescue StandardError => e
|
|
707
|
-
Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
|
|
708
|
-
false
|
|
709
|
-
end
|
|
710
|
-
|
|
711
|
-
# Cancel the currently running turn, if any.
|
|
712
|
-
# @param reason [Symbol] cancellation reason
|
|
713
|
-
# @return [Boolean] whether a cancellation was triggered
|
|
714
|
-
def cancel_current_turn!(reason = :manual)
|
|
715
|
-
@turn_state.cancel!(reason)
|
|
716
|
-
end
|
|
717
|
-
|
|
718
|
-
# Snapshot the current session messages as a JSON string for the idle
|
|
719
|
-
# recap. The array is never mutated in place (a turn or a note replaces
|
|
720
|
-
# it), so the reference read here is a consistent snapshot; a dup'd
|
|
721
|
-
# copy is serialized. Never mutates session.messages.
|
|
722
|
-
# @return [String] JSON array of the messages
|
|
723
|
-
def messages_json_for_recap
|
|
724
|
-
snapshot = @session&.messages
|
|
725
|
-
return "[]" if snapshot.nil?
|
|
726
|
-
|
|
727
|
-
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
728
|
-
end
|
|
729
|
-
|
|
730
|
-
# Emit a :recap_ready event (additive slot) carrying the generated recap
|
|
731
|
-
# and the generation id an observer uses to reject an invalidated recap.
|
|
732
|
-
# +covered+ counts the session messages it summarizes; +created_at+ is
|
|
733
|
-
# the saved recap's (UTC ISO; the UIs show its local time).
|
|
734
|
-
def emit_recap(recap:, generation:, covered: nil, created_at: nil)
|
|
735
|
-
event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
|
|
736
|
-
event[:created_at] = created_at if created_at
|
|
737
|
-
@session_observer.notify(event)
|
|
738
|
-
end
|
|
739
|
-
|
|
740
|
-
# Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
|
|
741
|
-
# on_usage) in the session's side line, apart from its turns' totals.
|
|
742
|
-
# Folded and announced as :side_usage under the event lock, so the side
|
|
743
|
-
# blocks reach the UIs in fold order (the lock order event lock →
|
|
744
|
-
# metrics mutex is the existing one); then, between turns, saved to
|
|
745
|
-
# analytics.json (a running turn's end or checkpoint saves it), unless
|
|
746
|
-
# the session is gone (no session file: a late recap thread must not
|
|
747
|
-
# recreate a deleted session's folder; the folder itself may not exist
|
|
748
|
-
# yet before a first turn). Safe from any thread: the recap's, a
|
|
749
|
-
# plugin's, the turn's.
|
|
750
|
-
# A side request counts against the participant who caused it
|
|
751
|
-
# (#side_author), its usage's own author when it names one.
|
|
752
|
-
# @param usage [SideUsage]
|
|
753
|
-
# @return [void]
|
|
754
|
-
def note_side_usage(usage)
|
|
755
|
-
usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
|
|
756
|
-
synchronize_events do
|
|
757
|
-
block = @metrics.add_side(usage)
|
|
758
|
-
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
759
|
-
end
|
|
760
|
-
sid = @metrics.session_id
|
|
761
|
-
return if sid.nil? || turn_running?
|
|
762
|
-
return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
|
|
763
|
-
|
|
764
|
-
@metrics.persist(state_dir: session_state_dir)
|
|
765
|
-
end
|
|
766
|
-
|
|
767
|
-
# Who caused a side request of +kind+ now: the running turn's author (a
|
|
768
|
-
# plugin's ctx.ask_model in it), the last turn's for the idle recap
|
|
769
|
-
# (it recaps that turn); nil, the owner's, for anything else (an owner
|
|
770
|
-
# command's ask_model between turns).
|
|
771
|
-
# @return [String, nil]
|
|
772
|
-
def side_author(kind)
|
|
773
|
-
# Check this is only ever the owner's, whichever turn runs as it ends.
|
|
774
|
-
return nil if kind.to_s == ObjectivesCheck::SOURCE
|
|
775
|
-
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
776
|
-
|
|
777
|
-
@guardrail_wiring.turn_author
|
|
778
|
-
end
|
|
779
|
-
|
|
780
623
|
RECAP_SIDE_KIND = "recap"
|
|
781
624
|
|
|
782
|
-
# The running turn's author when a participant wrote it, else nil
|
|
783
|
-
# (ctx.turn_author, the hooks' author).
|
|
784
|
-
# @return [String, nil]
|
|
785
|
-
def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
|
|
786
|
-
|
|
787
625
|
# @return [SessionMetrics] the per-session analytics collector
|
|
788
626
|
attr_reader :metrics
|
|
789
627
|
|
|
790
|
-
# The model as given (maybe an alias; HostRegistry#resolve applies it).
|
|
791
|
-
def effective_model_name = @model_binding.choice.name
|
|
792
|
-
|
|
793
|
-
# The model as typed at start or at the last /model.
|
|
794
|
-
def typed_model_name = @model_binding.choice.typed
|
|
795
|
-
|
|
796
|
-
# The resolved ref a session stores for the effective model (ModelRef#ref:
|
|
797
|
-
# the alias applied, "host:id" when it names a host).
|
|
798
|
-
def effective_model_ref
|
|
799
|
-
model_ref_for(effective_model_name)
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
# The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
|
|
803
|
-
# the ref, its host, the overlay key and, for a local llama.cpp host
|
|
804
|
-
# only, the model its /props names when that differs. Never a new
|
|
805
|
-
# request: only the /props answer profile resolution already cached.
|
|
806
|
-
def model_identity
|
|
807
|
-
model = effective_model
|
|
808
|
-
entry = model.entry
|
|
809
|
-
host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
|
|
810
|
-
SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_binding.model_key,
|
|
811
|
-
served: model.prompt_served, fallback_key: @model_binding.fallback_model_key)
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
# The model notes for the effective model (ModelNotes): matched on its
|
|
815
|
-
# bare id and key, the session's mutes left out.
|
|
816
|
-
# @return [Array<ModelNotes::Note>]
|
|
817
|
-
def model_notes
|
|
818
|
-
ModelNotes.for(name: guardrail_model_name, key: @model_binding.model_key, fallback_key: @model_binding.fallback_model_key,
|
|
819
|
-
muted: @muted_memory_names)
|
|
820
|
-
end
|
|
821
|
-
|
|
822
|
-
# The model notes the session's prompt carried at its last build
|
|
823
|
-
# (Session#prompt_notes): a resumed session's or a woken worker's saved
|
|
824
|
-
# ones until this process builds its prompt; a fresh session (no build
|
|
825
|
-
# yet, no messages) has none saved, so the effective model's prompt
|
|
826
|
-
# would load them: the notes #model_notes names, as without a session.
|
|
827
|
-
# The three readers (/model, /stats, the web) share this.
|
|
828
|
-
# @return [Array<PromptNote>]
|
|
829
|
-
def prompt_notes
|
|
830
|
-
return model_notes.map { |note| PromptNote.from_note(note) } unless @session
|
|
831
|
-
|
|
832
|
-
saved = @session.prompt_notes
|
|
833
|
-
return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
|
|
834
|
-
|
|
835
|
-
model_notes.map { |note| PromptNote.from_note(note) }
|
|
836
|
-
rescue StandardError => e
|
|
837
|
-
Log.warn(:model, "prompt_notes_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
838
|
-
[]
|
|
839
|
-
end
|
|
840
|
-
|
|
841
|
-
# Records +notes+ (ModelNotes::Note) on the session as the ones its
|
|
842
|
-
# prompt carries; saved with it.
|
|
843
|
-
def record_prompt_notes(notes)
|
|
844
|
-
@session.prompt_notes = Array(notes).map { |note| PromptNote.from_note(note) } if @session && notes
|
|
845
|
-
end
|
|
846
|
-
private :record_prompt_notes
|
|
847
|
-
|
|
848
|
-
# A failed turn's one line (the error's #summary). A host's failure
|
|
849
|
-
# under an alias says what the alias became (HostRegistry#alias_note):
|
|
850
|
-
# "host main" alone doesn't tell --model splash's user it was splash.
|
|
851
|
-
# @return [String]
|
|
852
|
-
def failure_summary(error)
|
|
853
|
-
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
854
|
-
return summary unless error.is_a?(LLM::ProviderError) && error.host
|
|
855
|
-
|
|
856
|
-
note = @host_registry.alias_note(effective_model_name)
|
|
857
|
-
note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
|
|
858
|
-
rescue StandardError => e
|
|
859
|
-
EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
|
|
860
|
-
end
|
|
861
|
-
|
|
862
|
-
# +name+'s resolved ref (ModelRef#ref).
|
|
863
|
-
def model_ref_for(name)
|
|
864
|
-
@host_registry.model_ref(name).ref
|
|
865
|
-
end
|
|
866
|
-
|
|
867
|
-
# The ref #switch_model! would switch +model_name+ to, checked the way it
|
|
868
|
-
# checks one (UnknownHost, MissingModel), without switching.
|
|
869
|
-
# @return [String]
|
|
870
|
-
def model_switch_ref(model_name)
|
|
871
|
-
model_ref_for(ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries))
|
|
872
|
-
end
|
|
873
|
-
|
|
874
|
-
# Writes the effective model to +session+: the resolved ref, and the
|
|
875
|
-
# name as typed when that differs (model_typed: an alias, for the
|
|
876
|
-
# models: lookup after a resume).
|
|
877
|
-
def store_model!(session)
|
|
878
|
-
ref = effective_model_ref
|
|
879
|
-
session.model_name = ref
|
|
880
|
-
typed = typed_model_name
|
|
881
|
-
session.model_typed = typed.to_s == ref ? nil : typed
|
|
882
|
-
session
|
|
883
|
-
end
|
|
884
|
-
|
|
885
|
-
# The effective model's key (ModelOverlay.key_for its bare name): memory
|
|
886
|
-
# overlays, guardrail rules' models:.
|
|
887
|
-
# @return [String, nil]
|
|
888
|
-
def model_key = @model_binding.model_key
|
|
889
|
-
|
|
890
|
-
# The prompt profile for the effective model (see #profile_resolution).
|
|
891
|
-
# @return [ModelProfile]
|
|
892
|
-
def profile = profile_resolution.profile
|
|
893
|
-
|
|
894
|
-
# Which profile the effective model gets and where that came from,
|
|
895
|
-
# kept with the model choice it was resolved for
|
|
896
|
-
# (ModelBinding#profile_resolution).
|
|
897
|
-
# @return [ModelProfile::Resolution]
|
|
898
|
-
def profile_resolution = @model_binding.profile_resolution
|
|
899
628
|
attr_reader :default_model_name, :host_registry, :client
|
|
900
629
|
|
|
901
630
|
# @return [Commands::Registry] the commands this session runs: the
|
|
@@ -905,55 +634,6 @@ module Samagotchi
|
|
|
905
634
|
# @return [PeerSources] the sources the peer bundles declared
|
|
906
635
|
attr_reader :peer_sources
|
|
907
636
|
|
|
908
|
-
# The model id sent for +full_ref+ (its alias applied, the host prefix off).
|
|
909
|
-
def bare_model_name(full_ref)
|
|
910
|
-
@host_registry.bare_name(full_ref)
|
|
911
|
-
end
|
|
912
|
-
|
|
913
|
-
# The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
|
|
914
|
-
def sync_model_key! = @model_binding.sync_model_key!
|
|
915
|
-
private :sync_model_key!
|
|
916
|
-
|
|
917
|
-
# The effective model's bare name for the guardrails' models: rules;
|
|
918
|
-
# nil without a model name.
|
|
919
|
-
def guardrail_model_name
|
|
920
|
-
model = effective_model_name
|
|
921
|
-
name = model && bare_model_name(model)
|
|
922
|
-
name.to_s.strip.empty? ? nil : name
|
|
923
|
-
end
|
|
924
|
-
|
|
925
|
-
# Point the kernel (and a chat backend) at the effective model's host
|
|
926
|
-
# (after /model, --model, resume).
|
|
927
|
-
def sync_kernel_client!(model = effective_model)
|
|
928
|
-
@client = model.client
|
|
929
|
-
@kernel.client = model.client if @kernel.client != model.client
|
|
930
|
-
backend_for(model)
|
|
931
|
-
end
|
|
932
|
-
|
|
933
|
-
# Subscribe a persistent observer to engine events.
|
|
934
|
-
#
|
|
935
|
-
# Unlike the turn-scoped `on_event:` sink, a subscribed observer keeps
|
|
936
|
-
# receiving events across every `run_turn` call on this Engine. Each
|
|
937
|
-
# delivery carries a locally-monotonic `event_seq`. The returned handle can
|
|
938
|
-
# be used to unsubscribe later.
|
|
939
|
-
# @param observer [#call] receives event hashes (with `event_seq:` merged in)
|
|
940
|
-
# @return [Samagotchi::SessionObserver::SubscribedObserver] handle to unsubscribe
|
|
941
|
-
def subscribe(observer:)
|
|
942
|
-
@session_observer.subscribe(observer: observer)
|
|
943
|
-
end
|
|
944
|
-
|
|
945
|
-
# Unsubscribe a previously-registered observer.
|
|
946
|
-
# @param handle [Samagotchi::SessionObserver::SubscribedObserver]
|
|
947
|
-
# @return [Boolean] whether the observer was removed (nil/unknown never raises)
|
|
948
|
-
def unsubscribe(handle:)
|
|
949
|
-
@session_observer.unsubscribe(handle: handle)
|
|
950
|
-
end
|
|
951
|
-
|
|
952
|
-
# @return [Integer] total engine events emitted so far (locally monotonic)
|
|
953
|
-
def event_count
|
|
954
|
-
@session_observer.event_count
|
|
955
|
-
end
|
|
956
|
-
|
|
957
637
|
# Event types #announce accepts: facts about the session's input queue,
|
|
958
638
|
# a failed turn's prompt handed back and the continue offer, which live
|
|
959
639
|
# UIs need in the event log, emitted outside any turn's stream; a
|
|
@@ -963,217 +643,6 @@ module Samagotchi
|
|
|
963
643
|
plugin_init_started plugin_init_finished working_set_updated participants_changed
|
|
964
644
|
verdict_recorded objectives_updated].freeze
|
|
965
645
|
|
|
966
|
-
# Put a transport-level event into the ordered event log. Unlike turn
|
|
967
|
-
# events it reaches only persistent observers (no turn sink, no memory
|
|
968
|
-
# capture).
|
|
969
|
-
# @param event [Hash] with :type in ANNOUNCEABLE_EVENTS
|
|
970
|
-
# @raise [ArgumentError] for any other type
|
|
971
|
-
def announce(event)
|
|
972
|
-
unless ANNOUNCEABLE_EVENTS.include?(event[:type])
|
|
973
|
-
raise ArgumentError, "cannot announce #{event[:type].inspect} (allowed: #{ANNOUNCEABLE_EVENTS.join(", ")})"
|
|
974
|
-
end
|
|
975
|
-
|
|
976
|
-
@session_observer.notify(event)
|
|
977
|
-
end
|
|
978
|
-
|
|
979
|
-
# Run the block with the event log held: no event is numbered or
|
|
980
|
-
# delivered meanwhile, and events the block emits keep their order.
|
|
981
|
-
def synchronize_events(&)
|
|
982
|
-
@session_observer.synchronize(&)
|
|
983
|
-
end
|
|
984
|
-
|
|
985
|
-
# The working set's :working_set_updated, announced between turns
|
|
986
|
-
# (after a command changed what the next request sends), when it
|
|
987
|
-
# differs from the last one emitted (#emit_working_set). Under the
|
|
988
|
-
# event lock, so it is ordered with the change's own events.
|
|
989
|
-
def announce_working_set(session = @session)
|
|
990
|
-
return unless session
|
|
991
|
-
|
|
992
|
-
synchronize_events { emit_working_set(nil, session, session.messages) }
|
|
993
|
-
end
|
|
994
|
-
|
|
995
|
-
# The session's objectives as the page draws them (Objectives.view), when
|
|
996
|
-
# that differs from the last announced: after an edit (its file written,
|
|
997
|
-
# pending), once a list note is in, at a turn's end (its moves) and after
|
|
998
|
-
# a rollback. A session with no list announces nothing. It only informs:
|
|
999
|
-
# a failure is logged, never the turn's.
|
|
1000
|
-
def announce_objectives(session = @session)
|
|
1001
|
-
return unless session
|
|
1002
|
-
|
|
1003
|
-
synchronize_events do
|
|
1004
|
-
view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
|
|
1005
|
-
next if view.nil? || view == @objectives_announced
|
|
1006
|
-
|
|
1007
|
-
@objectives_announced = view
|
|
1008
|
-
announce({ type: :objectives_updated, session_id: session.id, **view })
|
|
1009
|
-
end
|
|
1010
|
-
rescue StandardError => e
|
|
1011
|
-
Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
|
|
1012
|
-
end
|
|
1013
|
-
|
|
1014
|
-
# Check this (ObjectivesCheck)'s one side request. Its usage is in the
|
|
1015
|
-
# session's side line. Raises as ctx.ask_model's request does.
|
|
1016
|
-
# @param request [Array<Hash>] chat messages
|
|
1017
|
-
# @param model [String, nil] a configured model; nil: the session's
|
|
1018
|
-
# @return [String] the answer
|
|
1019
|
-
def ask_objective_check(request, model:)
|
|
1020
|
-
ask_side_model(request, timeout: ObjectivesCheck::TIMEOUT_SECONDS, max_tokens: ObjectivesCheck::MAX_TOKENS,
|
|
1021
|
-
cancel_controller: nil, model: model, source: ObjectivesCheck::SOURCE)
|
|
1022
|
-
end
|
|
1023
|
-
|
|
1024
|
-
# A change the model proposes (the objectives tool), kept until the turn
|
|
1025
|
-
# is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
|
|
1026
|
-
# over a restart (CardStore), not a running turn's that says it waits on the
|
|
1027
|
-
# user. The same change twice in a turn is one card.
|
|
1028
|
-
# @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
|
|
1029
|
-
def propose_objective(proposal)
|
|
1030
|
-
@objective_proposals_mutex.synchronize do
|
|
1031
|
-
next true if @objective_proposals.any? { |p| p.line == proposal.line }
|
|
1032
|
-
next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
|
|
1033
|
-
|
|
1034
|
-
@objective_proposals << proposal
|
|
1035
|
-
true
|
|
1036
|
-
end
|
|
1037
|
-
end
|
|
1038
|
-
|
|
1039
|
-
def show_objective_proposals
|
|
1040
|
-
drain_objective_proposals.each do |proposal|
|
|
1041
|
-
id = "objectives-#{SecureRandom.hex(4)}"
|
|
1042
|
-
show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
|
|
1043
|
-
actions: [{ label: "Apply", command: proposal.action_line(id) },
|
|
1044
|
-
{ label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
|
|
1045
|
-
end
|
|
1046
|
-
rescue StandardError => e
|
|
1047
|
-
Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
|
|
1048
|
-
end
|
|
1049
|
-
private :show_objective_proposals
|
|
1050
|
-
|
|
1051
|
-
# A turn that did not complete (canceled, failed) takes its proposals
|
|
1052
|
-
# off the buffer without showing them: their Apply card would sit after a
|
|
1053
|
-
# turn whose `objectives propose` call is not in the history (a rollback
|
|
1054
|
-
# after Ctrl-C drops it), and leaving them in the buffer would show them
|
|
1055
|
-
# at the end of the next turn.
|
|
1056
|
-
def discard_objective_proposals(turn)
|
|
1057
|
-
dropped = drain_objective_proposals
|
|
1058
|
-
Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
|
|
1059
|
-
end
|
|
1060
|
-
private :discard_objective_proposals
|
|
1061
|
-
|
|
1062
|
-
def drain_objective_proposals
|
|
1063
|
-
@objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
|
|
1064
|
-
end
|
|
1065
|
-
private :drain_objective_proposals
|
|
1066
|
-
|
|
1067
|
-
# The strategy the working set is built under now, as LLMContextView
|
|
1068
|
-
# takes it: the running turn's, else the next turn's (#emit_working_set).
|
|
1069
|
-
# /working-set's ops act under it.
|
|
1070
|
-
# @return [Symbol, Array<Symbol>]
|
|
1071
|
-
def working_set_strategy
|
|
1072
|
-
working_set_llm_context&.strategy || LLMContextView::NONE
|
|
1073
|
-
end
|
|
1074
|
-
|
|
1075
|
-
# Replace the current session's conversation, between turns (a
|
|
1076
|
-
# /working-set op's copy: #clone_messages, then its edits).
|
|
1077
|
-
# @param messages [Array<Hash>]
|
|
1078
|
-
# @return [Array<Hash>] the session's messages
|
|
1079
|
-
def replace_messages(messages)
|
|
1080
|
-
raise ArgumentError, "no current session" unless @session
|
|
1081
|
-
|
|
1082
|
-
replace_session_messages(@session, messages)
|
|
1083
|
-
end
|
|
1084
|
-
|
|
1085
|
-
# Add a context note to the session's conversation, between turns: a
|
|
1086
|
-
# tail system message the next turn sees, announced as :context_added.
|
|
1087
|
-
# The array is replaced, not mutated, and the append and the event
|
|
1088
|
-
# happen under the event lock, so a joining UI sees both or neither.
|
|
1089
|
-
# The caller saves the session.
|
|
1090
|
-
# @param note [Hash] SessionInbox.read_note's shape
|
|
1091
|
-
# @return [Hash, nil] the message, or nil when the conversation already
|
|
1092
|
-
# holds this note (a note file re-read after a crash)
|
|
1093
|
-
def add_context_note(session, note)
|
|
1094
|
-
synchronize_events do
|
|
1095
|
-
next nil if Array(session.messages).any? { |m| m[:note_id] == note[:note_id] }
|
|
1096
|
-
|
|
1097
|
-
message = ContextNote.message(**note)
|
|
1098
|
-
entry = note[:objectives] && Objectives.entry(note[:objectives], before: Objectives.build(session.messages))
|
|
1099
|
-
replace_session_messages(session, Array(session.messages) + [message])
|
|
1100
|
-
announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
|
|
1101
|
-
label: ContextNote.label_of(message), from_session: note[:from_session],
|
|
1102
|
-
from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
|
|
1103
|
-
created_at: note[:created_at], objectives: entry }.compact)
|
|
1104
|
-
announce_objectives(session) if entry
|
|
1105
|
-
objectives_audit.note(session, note)
|
|
1106
|
-
message
|
|
1107
|
-
end
|
|
1108
|
-
end
|
|
1109
|
-
|
|
1110
|
-
# ── Cards ───────────────────────────────────────────────────────────────
|
|
1111
|
-
|
|
1112
|
-
# Show a card in every UI (docs/plugins.md, Cards): {type: :card, id:,
|
|
1113
|
-
# source:, title:, body:, level:, actions: [{label:, command:}], in_turn:}.
|
|
1114
|
-
# Where it goes (held while the plugins load, an anytime command's, an
|
|
1115
|
-
# init task's, a turn event, else announced) is EventRoutes#route's,
|
|
1116
|
-
# marked in_turn: (CARD_MARKS). A card with the id of an earlier one
|
|
1117
|
-
# replaces it (btw's "thinking…" → the answer).
|
|
1118
|
-
# @param source [String] who shows it (a bundle's name)
|
|
1119
|
-
# @param actions [Array<Hash>] {label:, command:}; a command is a line
|
|
1120
|
-
# the session runs, as typed (D3)
|
|
1121
|
-
# @return [String] the card's id
|
|
1122
|
-
# @raise [ArgumentError] a card without a title, or a bad action
|
|
1123
|
-
def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
|
|
1124
|
-
card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
|
|
1125
|
-
command = Thread.current[running_command_key]
|
|
1126
|
-
card[:command] = command if command
|
|
1127
|
-
@event_routes.route(card, **EventRoutes::CARD_MARKS)
|
|
1128
|
-
card[:id]
|
|
1129
|
-
end
|
|
1130
|
-
|
|
1131
|
-
# Run an anytime command's block (D8): the cards and notices it shows on
|
|
1132
|
-
# this thread are announced at once, marked anytime: true, and are
|
|
1133
|
-
# never a running turn's events. They belong to the command, not the
|
|
1134
|
-
# turn, and a UI shows them after the command's own line (a web
|
|
1135
|
-
# command bubble drawn at its command_queued), even mid-turn.
|
|
1136
|
-
# @return the block's value
|
|
1137
|
-
def running_anytime
|
|
1138
|
-
key = :"samagotchi_anytime_#{object_id}"
|
|
1139
|
-
outer = Thread.current[key]
|
|
1140
|
-
Thread.current[key] = true
|
|
1141
|
-
yield
|
|
1142
|
-
ensure
|
|
1143
|
-
Thread.current[key] = outer
|
|
1144
|
-
end
|
|
1145
|
-
|
|
1146
|
-
# Run a session command's dispatch: a card shown on this thread says
|
|
1147
|
-
# it's that command's (:card's command:), for a reload that no longer has
|
|
1148
|
-
# the command's line above it (Bridge::CardStore). A thread of its own
|
|
1149
|
-
# isn't the command, nor a card shown after it.
|
|
1150
|
-
# @param name [String] the command, "/short"
|
|
1151
|
-
# @return the block's value
|
|
1152
|
-
def running_command(name)
|
|
1153
|
-
outer = Thread.current[running_command_key]
|
|
1154
|
-
Thread.current[running_command_key] = name
|
|
1155
|
-
yield
|
|
1156
|
-
ensure
|
|
1157
|
-
Thread.current[running_command_key] = outer
|
|
1158
|
-
end
|
|
1159
|
-
|
|
1160
|
-
# Whether an anytime command (/btw) still runs on its thread.
|
|
1161
|
-
def anytime_running?
|
|
1162
|
-
@lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
|
|
1163
|
-
end
|
|
1164
|
-
|
|
1165
|
-
# Start an anytime command on its own thread (D8), one #shutdown waits
|
|
1166
|
-
# for, so its command_ran is announced before the process leaves.
|
|
1167
|
-
# @return [Thread]
|
|
1168
|
-
def spawn_anytime(&)
|
|
1169
|
-
thread = Thread.new(&)
|
|
1170
|
-
@lifecycle_mutex.synchronize do
|
|
1171
|
-
@anytime_threads.select!(&:alive?)
|
|
1172
|
-
@anytime_threads << thread
|
|
1173
|
-
end
|
|
1174
|
-
thread
|
|
1175
|
-
end
|
|
1176
|
-
|
|
1177
646
|
# ── Plugin init tasks (chi.init) ──────────────────────────────────────
|
|
1178
647
|
|
|
1179
648
|
# A plugin's slow setup (docs/plugins.md, Init tasks): PluginTasks.
|
|
@@ -1181,63 +650,6 @@ module Samagotchi
|
|
|
1181
650
|
INIT_TASK_TIMEOUT = PluginTasks::INIT_TASK_TIMEOUT
|
|
1182
651
|
INIT_WAIT_POLL = PluginTasks::INIT_WAIT_POLL
|
|
1183
652
|
|
|
1184
|
-
# Add a plugin's init task (Plugin::Api#init at commit); it starts with
|
|
1185
|
-
# #start_init_tasks!.
|
|
1186
|
-
def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &)
|
|
1187
|
-
@plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
|
|
1188
|
-
quiet: quiet, timeout: timeout, failed: failed, &)
|
|
1189
|
-
end
|
|
1190
|
-
|
|
1191
|
-
# Start the init tasks not started yet, each on its own thread
|
|
1192
|
-
# (PluginTasks#start!).
|
|
1193
|
-
def start_init_tasks!
|
|
1194
|
-
@plugin_tasks.start!
|
|
1195
|
-
end
|
|
1196
|
-
|
|
1197
|
-
# The running init tasks a UI shows (not the quiet ones), for a UI that
|
|
1198
|
-
# joins while they run (Bridge#snapshot, with the event log held).
|
|
1199
|
-
# @return [Array<Hash>] {bundle:, id:, label:}
|
|
1200
|
-
def init_tasks
|
|
1201
|
-
@plugin_tasks.running
|
|
1202
|
-
end
|
|
1203
|
-
|
|
1204
|
-
# Wait for the running init tasks that provide tools (PluginTasks#await).
|
|
1205
|
-
# @return [Boolean] whether it waited
|
|
1206
|
-
def await_init_tasks(controller = nil, on_event = nil)
|
|
1207
|
-
@plugin_tasks.await(controller, on_event)
|
|
1208
|
-
end
|
|
1209
|
-
|
|
1210
|
-
# The init task this thread runs, or nil.
|
|
1211
|
-
def current_init_task = @plugin_tasks.current
|
|
1212
|
-
private :current_init_task
|
|
1213
|
-
|
|
1214
|
-
# ── Load events ───────────────────────────────────────────────────────
|
|
1215
|
-
|
|
1216
|
-
# Announce what failed to load and what plugins showed while loading,
|
|
1217
|
-
# once, as soon as the owner can show it (the worker's Bridge is up, the
|
|
1218
|
-
# REPL renders events): the guardrail and plugin warnings, an orphaned
|
|
1219
|
-
# project memory folder (label: "memory", MemoryOrphans), then the
|
|
1220
|
-
# held notices (between_turns) and cards (in_turn: false, so late
|
|
1221
|
-
# joiners get them). Without this call the first turn announces them
|
|
1222
|
-
# (#announce_guardrail_failures).
|
|
1223
|
-
def announce_load_events!
|
|
1224
|
-
synchronize_events do
|
|
1225
|
-
next if @guardrail_failures_announced
|
|
1226
|
-
|
|
1227
|
-
@guardrail_failures_announced = true
|
|
1228
|
-
message = @guardrail_failures.message
|
|
1229
|
-
announce({ type: :guardrail_warning, message: message }) if message
|
|
1230
|
-
plugins = @plugin_failures.message
|
|
1231
|
-
announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
1232
|
-
memory = MemoryOrphans.message
|
|
1233
|
-
announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
1234
|
-
@extension_load.held_events.each do |event|
|
|
1235
|
-
announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
|
|
1236
|
-
end
|
|
1237
|
-
end
|
|
1238
|
-
nil
|
|
1239
|
-
end
|
|
1240
|
-
|
|
1241
653
|
# How long #shutdown waits for the running anytime commands, in all.
|
|
1242
654
|
SHUTDOWN_JOIN_SECONDS = 3.0
|
|
1243
655
|
# How long #shutdown waits for the running side judgments (System 1
|
|
@@ -1245,8 +657,8 @@ module Samagotchi
|
|
|
1245
657
|
# after its turn), in all; a slower ask goes unlogged.
|
|
1246
658
|
SIDE_JUDGMENTS_JOIN_SECONDS = 1.0
|
|
1247
659
|
|
|
1248
|
-
# The
|
|
1249
|
-
# crash, TERM): stop the idle jobs, give the running anytime commands
|
|
660
|
+
# The session's worker (or a one-shot run) is leaving (/exit, an idle
|
|
661
|
+
# exit, a crash, TERM): stop the idle jobs, give the running anytime commands
|
|
1250
662
|
# up to +join_timeout+ seconds to finish (their command_ran is
|
|
1251
663
|
# announced), then stop the plugins' services, newest first. Once;
|
|
1252
664
|
# later calls do nothing.
|
|
@@ -1279,474 +691,9 @@ module Samagotchi
|
|
|
1279
691
|
self
|
|
1280
692
|
end
|
|
1281
693
|
|
|
1282
|
-
def anytime_thread? = Thread.current[:"samagotchi_anytime_#{object_id}"] == true
|
|
1283
|
-
private :anytime_thread?
|
|
1284
|
-
|
|
1285
|
-
def announce_anytime(event)
|
|
1286
|
-
event = event.merge(anytime: true)
|
|
1287
|
-
announce(event)
|
|
1288
|
-
event
|
|
1289
|
-
end
|
|
1290
|
-
private :announce_anytime
|
|
1291
|
-
|
|
1292
|
-
# Run the block holding back the cards and between-turns notices it
|
|
1293
|
-
# announces on this thread, so the caller announces them after its own
|
|
1294
|
-
# event (a worker's command: its command_ran first, as the REPL prints
|
|
1295
|
-
# the command's output before its cards).
|
|
1296
|
-
# @return [Array(Object, Array<Hash>)] the block's value and the held events
|
|
1297
|
-
def holding_announcements
|
|
1298
|
-
key = :"samagotchi_held_#{object_id}"
|
|
1299
|
-
outer = Thread.current[key]
|
|
1300
|
-
Thread.current[key] = []
|
|
1301
|
-
value = yield
|
|
1302
|
-
[value, Thread.current[key]]
|
|
1303
|
-
ensure
|
|
1304
|
-
Thread.current[key] = outer
|
|
1305
|
-
end
|
|
1306
|
-
|
|
1307
|
-
# Announce +event+ now, or keep it for #holding_announcements' caller.
|
|
1308
|
-
def announce_or_hold(event)
|
|
1309
|
-
held = Thread.current[:"samagotchi_held_#{object_id}"]
|
|
1310
|
-
held ? held << event : announce(event)
|
|
1311
|
-
end
|
|
1312
|
-
private :announce_or_hold
|
|
1313
|
-
|
|
1314
|
-
# ── Model switching ────────────────────────────────────────────────────────
|
|
1315
|
-
|
|
1316
|
-
# @param typed [String, nil] the name the model was typed as when
|
|
1317
|
-
# +model_name+ is a stored ref (a resumed session's model_typed)
|
|
1318
|
-
# @return [String] the resolved ref the session stores (ModelRef#ref)
|
|
1319
|
-
def switch_model!(model_name, persist_default: false, typed: nil)
|
|
1320
|
-
# The alias is applied where the model is resolved (HostRegistry#resolve),
|
|
1321
|
-
# once; checked here: an unknown host, an alias naming another host.
|
|
1322
|
-
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
|
|
1323
|
-
choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
|
|
1324
|
-
@model_binding.switch!(choice)
|
|
1325
|
-
sync_model_key!
|
|
1326
|
-
@prompt_builder.reset!
|
|
1327
|
-
# The notes the new model's prompt loads at its next build, so /model,
|
|
1328
|
-
# /stats and the web name them before the next turn does.
|
|
1329
|
-
record_prompt_notes(model_notes) if @session
|
|
1330
|
-
sync_kernel_client!
|
|
1331
|
-
@client.invalidate_context_window!
|
|
1332
|
-
@metrics.forget_model_reports!
|
|
1333
|
-
# The last turn's ctx % was the old model's window's
|
|
1334
|
-
# (#announce_switched_context! sends the new one's).
|
|
1335
|
-
@last_context_status = nil
|
|
1336
|
-
ref = effective_model_ref
|
|
1337
|
-
if persist_default
|
|
1338
|
-
ConfigFile.write_default_model!(ref)
|
|
1339
|
-
@default_model_name = ref
|
|
1340
|
-
end
|
|
1341
|
-
ref
|
|
1342
|
-
end
|
|
1343
|
-
|
|
1344
|
-
# After /model: how full the context is on the new model's window,
|
|
1345
|
-
# before its first turn: the last turn's count (the old model's
|
|
1346
|
-
# tokenizer's, replaced at the next generation) against the new model's
|
|
1347
|
-
# window (EffectiveModel#context_window, ContextWindow.resolve: the
|
|
1348
|
-
# running server first, one /props GET that a remote chat host is
|
|
1349
|
-
# spared; else the host's cached model list; else the configured
|
|
1350
|
-
# window) and the
|
|
1351
|
-
# session's budget. Sent as a :context_status event (the web's ctx
|
|
1352
|
-
# meter, the attached TUI's status row) with context_window_tokens, and
|
|
1353
|
-
# kept for a client that joins later (#session_state_snapshot; the
|
|
1354
|
-
# window in the metrics' context too, which a page counts from). A window
|
|
1355
|
-
# that can't be resolved sends usage nil: the UIs clear the % until the
|
|
1356
|
-
# next generation. Nothing before the session's first counted turn.
|
|
1357
|
-
# +configured_only+: the configured window only, never asking a server
|
|
1358
|
-
# (EffectiveModel#configured_context_window): a loaded session's (#session=).
|
|
1359
|
-
# @return [Hash, nil] the event sent
|
|
1360
|
-
def announce_switched_context!(configured_only: false)
|
|
1361
|
-
used = @metrics.snapshot.dig(:context, :used_tokens)
|
|
1362
|
-
return nil unless used.is_a?(Integer)
|
|
1363
|
-
|
|
1364
|
-
model = effective_model
|
|
1365
|
-
window = configured_only ? model.configured_context_window : current_context_window(model)
|
|
1366
|
-
display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
|
|
1367
|
-
@last_context_status = display&.dup
|
|
1368
|
-
@metrics.note_context_window(window.tokens, window.source) if window
|
|
1369
|
-
usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
|
|
1370
|
-
window_source: window.source.to_s, source: "switch" }
|
|
1371
|
-
event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
|
|
1372
|
-
context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
|
|
1373
|
-
@session_observer.notify(event)
|
|
1374
|
-
event
|
|
1375
|
-
rescue StandardError => e
|
|
1376
|
-
Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1377
|
-
nil
|
|
1378
|
-
end
|
|
1379
|
-
|
|
1380
|
-
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
1381
|
-
|
|
1382
|
-
# Register a hook callback for a named lifecycle event.
|
|
1383
|
-
#
|
|
1384
|
-
# Hooks are turn-scoped: they are automatically cleared after each
|
|
1385
|
-
# `run_turn` call so that a single turn's hooks do not leak into the next.
|
|
1386
|
-
#
|
|
1387
|
-
# A hook that raises tells the user once, as a bundle's does
|
|
1388
|
-
# (HookRoutes#plugin_hook_failed); the registry logs each failure and goes on.
|
|
1389
|
-
#
|
|
1390
|
-
# @param name [Symbol] one of the hook names (see Hooks module)
|
|
1391
|
-
# @param block [Proc] receives an event hash (may mutate in place)
|
|
1392
|
-
# @return [void]
|
|
1393
|
-
def register_hook(name, &block)
|
|
1394
|
-
return @hooks.register(name) unless block
|
|
1395
|
-
|
|
1396
|
-
@hooks.register(name) do |event|
|
|
1397
|
-
yield(event)
|
|
1398
|
-
rescue StandardError => e
|
|
1399
|
-
@hook_routes.plugin_hook_failed("#{Hooks::Registry::TURN_HOOK_LABEL} (#{name})", "#{e.class}: #{e.message}")
|
|
1400
|
-
raise
|
|
1401
|
-
end
|
|
1402
|
-
end
|
|
1403
|
-
|
|
1404
|
-
# Unregister a previously registered hook.
|
|
1405
|
-
# @param name [Symbol]
|
|
1406
|
-
# @return [Boolean] true if it was removed, false if not found
|
|
1407
|
-
def unregister_hook(name)
|
|
1408
|
-
@hooks.unregister(name)
|
|
1409
|
-
end
|
|
1410
|
-
|
|
1411
|
-
# Clear all registered hooks. Called automatically at the end of each
|
|
1412
|
-
# `run_turn` to keep hooks turn-scoped.
|
|
1413
|
-
# @return [void]
|
|
1414
|
-
def clear_hooks
|
|
1415
|
-
@hooks.clear_all
|
|
1416
|
-
end
|
|
1417
|
-
|
|
1418
|
-
# ── Reminders API ────────────────────────────────────────────────────────────
|
|
1419
|
-
#
|
|
1420
|
-
# Each turn injects the reminders due then (#turn_messages,
|
|
1421
|
-
# ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
|
|
1422
|
-
# finds them due and its callback queues their names
|
|
1423
|
-
# (#note_due_reminders); the worker and the REPL run a reminder turn
|
|
1424
|
-
# (a continue turn) for them.
|
|
1425
|
-
|
|
1426
|
-
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
1427
|
-
# which a turn would inject), regardless of the queued names
|
|
1428
|
-
def reminders_due?
|
|
1429
|
-
@reminder_queue.due?
|
|
1430
|
-
end
|
|
1431
|
-
|
|
1432
|
-
# @return [ReminderStore] the reminder store for inspection
|
|
1433
|
-
attr_reader :reminder_store
|
|
1434
|
-
|
|
1435
|
-
# The metrics for /stats, which may make one short /props GET
|
|
1436
|
-
# (EngineSnapshots#stats_snapshot).
|
|
1437
|
-
# @return [Hash] @metrics.snapshot, filled in
|
|
1438
|
-
def stats_snapshot = @engine_snapshots.stats_snapshot
|
|
1439
|
-
|
|
1440
|
-
# ── The session's model settings (SessionModelSettings) ──────────────────
|
|
1441
|
-
|
|
1442
|
-
# The session's own thinking, reasoning_history and llm_context values
|
|
1443
|
-
# over the effective model's, the summaries /model and /stats show, and
|
|
1444
|
-
# the saved context's status: SessionModelSettings' (resolved on every
|
|
1445
|
-
# read); these forward.
|
|
1446
|
-
def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
|
|
1447
|
-
def llm_context_explained(own: :session) = @model_settings.llm_context_explained(own: own)
|
|
1448
|
-
def llm_context_summary = @model_settings.llm_context_summary
|
|
1449
|
-
def llm_context_override = @model_settings.llm_context_override
|
|
1450
|
-
def chat_model? = @model_settings.chat_model?
|
|
1451
|
-
def sampling_summary = @model_settings.sampling_summary
|
|
1452
|
-
def thinking_summary = @model_settings.thinking_summary
|
|
1453
|
-
def steer_cut_after_explained = @model_settings.steer_cut_after_explained
|
|
1454
|
-
def steer_cut_after = @model_settings.steer_cut_after
|
|
1455
|
-
def steer_cut_summary = @model_settings.steer_cut_summary
|
|
1456
|
-
def thinking_explained(own: thinking_override) = @model_settings.thinking_explained(own: own)
|
|
1457
|
-
def reasoning_history_summary = @model_settings.reasoning_history_summary
|
|
1458
|
-
def reasoning_history_cache_cost(from, to) = @model_settings.reasoning_history_cache_cost(from, to)
|
|
1459
|
-
def reasoning_history_override = @model_settings.reasoning_history_override
|
|
1460
|
-
def thinking_override = @model_settings.thinking_override
|
|
1461
|
-
def served_expected_by(served) = @model_settings.served_expected_by(served)
|
|
1462
|
-
def served_expected?(served) = @model_settings.served_expected?(served)
|
|
1463
|
-
|
|
1464
|
-
def reasoning_history_explained(native: false, own: reasoning_history_override)
|
|
1465
|
-
@model_settings.reasoning_history_explained(native: native, own: own)
|
|
1466
|
-
end
|
|
1467
|
-
|
|
1468
|
-
# Set the session's own reasoning_history or thinking level (nil unsets
|
|
1469
|
-
# it): SessionModelSettings'. The caller saves the session.
|
|
1470
|
-
# @raise [ArgumentError] with no session yet
|
|
1471
|
-
def reasoning_history_override=(level)
|
|
1472
|
-
@model_settings.reasoning_history_override = level
|
|
1473
|
-
end
|
|
1474
|
-
|
|
1475
|
-
def thinking_override=(level)
|
|
1476
|
-
@model_settings.thinking_override = level
|
|
1477
|
-
end
|
|
1478
|
-
|
|
1479
|
-
# Set the session's own llm_context values (an empty override unsets
|
|
1480
|
-
# them); the next turn's start resolves them, never a running one (its
|
|
1481
|
-
# TurnSettings are fixed). The caller saves the session.
|
|
1482
|
-
# @raise [ArgumentError] with no session yet
|
|
1483
|
-
def llm_context_override=(override)
|
|
1484
|
-
raise ArgumentError, "no session yet" unless @session
|
|
1485
|
-
|
|
1486
|
-
SessionSetting::LLM_CONTEXT.set(@session, override)
|
|
1487
|
-
# The status a later client reads counts against the new budget.
|
|
1488
|
-
@last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
|
|
1489
|
-
end
|
|
1490
|
-
|
|
1491
|
-
# Whether the session has a model reply (a cache to restart at all).
|
|
1492
|
-
def history?
|
|
1493
|
-
Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
|
|
1494
|
-
end
|
|
1495
|
-
|
|
1496
|
-
# The latest known context size used by a request, for a cache-restart
|
|
1497
|
-
# confirmation; nil before any request reports a count.
|
|
1498
|
-
# @return [Integer, nil]
|
|
1499
|
-
def cache_restart_tokens
|
|
1500
|
-
used = @metrics.snapshot.dig(:context, :used_tokens)
|
|
1501
|
-
used if used.is_a?(Integer)
|
|
1502
|
-
end
|
|
1503
|
-
|
|
1504
|
-
# Whether a cache restart can invalidate preserved thinking blocks.
|
|
1505
|
-
def cache_restart_anthropic?
|
|
1506
|
-
effective_model.entry&.api.to_s == "anthropic"
|
|
1507
|
-
end
|
|
1508
|
-
|
|
1509
|
-
# What a thinking level change costs the effective model's prompt
|
|
1510
|
-
# cache (Thinking.cache_cost): :full, :provider or :tail.
|
|
1511
|
-
# @return [Symbol]
|
|
1512
|
-
def thinking_cache_cost
|
|
1513
|
-
effective_model.thinking_cache_cost { profile }
|
|
1514
|
-
end
|
|
1515
|
-
|
|
1516
|
-
# The model the server serves for the current model, and the name asked
|
|
1517
|
-
# for: what the last generation of that name reported, else llama.cpp's
|
|
1518
|
-
# model_alias (/props, one short cached probe; not with probe: false),
|
|
1519
|
-
# else [nil, nil].
|
|
1520
|
-
# @return [Array(String, String), Array(nil, nil)]
|
|
1521
|
-
def served_model(probe: true)
|
|
1522
|
-
@engine_snapshots.served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
|
|
1523
|
-
end
|
|
1524
|
-
|
|
1525
|
-
# Read-only snapshot of the engine's view of the current session plus the
|
|
1526
|
-
# live event sequence; cheap, never probes, never raises
|
|
1527
|
-
# (EngineSnapshots#session_state_snapshot has its keys).
|
|
1528
|
-
# @return [Hash]
|
|
1529
|
-
def session_state_snapshot = @engine_snapshots.session_state_snapshot
|
|
1530
|
-
|
|
1531
|
-
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
1532
|
-
def used_memory_names
|
|
1533
|
-
@used_memories.names
|
|
1534
|
-
end
|
|
1535
|
-
|
|
1536
|
-
# @return [Array<String>] the memories hidden from this session (normalized names)
|
|
1537
|
-
def muted_memory_names
|
|
1538
|
-
@muted_memory_names.dup
|
|
1539
|
-
end
|
|
1540
|
-
|
|
1541
|
-
# @return [Array<String>] the names the session preloads (config baseline
|
|
1542
|
-
# + --memory, minus mutes), known before the prompt is built, unlike
|
|
1543
|
-
# #activated_memory_names
|
|
1544
|
-
def preloaded_memory_names
|
|
1545
|
-
@prompt_builder.preloaded_memory_names
|
|
1546
|
-
end
|
|
1547
|
-
|
|
1548
|
-
# ── Guardrails ─────────────────────────────────────────────────────────────
|
|
1549
|
-
|
|
1550
|
-
# Who can answer an approval: :repl, :worker or :non_interactive (the
|
|
1551
|
-
# default, so a bare Engine denies instead of waiting for nobody). Set
|
|
1552
|
-
# by the host (TerminalUI, Worker).
|
|
1553
|
-
def interface
|
|
1554
|
-
@guardrail_wiring.interface
|
|
1555
|
-
end
|
|
1556
|
-
|
|
1557
|
-
def interface=(value)
|
|
1558
|
-
@guardrail_wiring.interface = value
|
|
1559
|
-
end
|
|
1560
|
-
|
|
1561
|
-
# Where the approval store lives: beside Session's state dir
|
|
1562
|
-
# ($XDG_STATE_HOME/samagotchi/guardrails/). A Worker with its own state
|
|
1563
|
-
# dir passes it.
|
|
1564
|
-
# Where sessions live (a session's images/ are under it); a worker sets
|
|
1565
|
-
# its own.
|
|
1566
|
-
attr_writer :session_state_dir
|
|
1567
|
-
|
|
1568
|
-
# The metrics load the session's saved records from its state dir, once.
|
|
1569
|
-
def bind_metrics(session)
|
|
1570
|
-
return unless session&.id
|
|
1571
|
-
|
|
1572
|
-
@metrics.state_dir = session_state_dir
|
|
1573
|
-
@metrics.session_id = session.id
|
|
1574
|
-
end
|
|
1575
|
-
|
|
1576
|
-
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1577
|
-
|
|
1578
|
-
# Whether a participant's pair turn must stop, read before each of its
|
|
1579
|
-
# generations (#guard_participant_spend): author ("p:<id>") → why (their
|
|
1580
|
-
# link ended, or they passed their cap), nil while it may go on. The
|
|
1581
|
-
# Worker sets it (Worker#participant_turn_stop); without one (the REPL)
|
|
1582
|
-
# nothing is checked mid-turn.
|
|
1583
|
-
attr_writer :participant_stop
|
|
1584
|
-
|
|
1585
694
|
# What a participant turn's stop is attributed to (HookRoutes#stop_turn).
|
|
1586
695
|
PARTICIPANTS_HOOK = "participants"
|
|
1587
696
|
|
|
1588
|
-
# Before each generation (KernelLoop#generation_guard): a pair turn whose
|
|
1589
|
-
# participant's link ended, or whose spend (Engine#metrics: their
|
|
1590
|
-
# finished turns, this one's finished generations, their side requests)
|
|
1591
|
-
# passed their cap, stops on the plugins' stop path (cost-cap's,
|
|
1592
|
-
# work-cap's), with its notice. A generation's own cost is known only
|
|
1593
|
-
# once it ends, so a turn can pass the cap by its last generation. The
|
|
1594
|
-
# turn-start check is the Worker's (Worker#participant_refusal); a
|
|
1595
|
-
# review turn only reads, and keeps to it.
|
|
1596
|
-
def guard_participant_spend
|
|
1597
|
-
author = turn_author
|
|
1598
|
-
return if author.nil? || @participant_stop.nil?
|
|
1599
|
-
return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
|
|
1600
|
-
|
|
1601
|
-
reason = @participant_stop.call(author) or return
|
|
1602
|
-
hook_stop_turn(reason, PARTICIPANTS_HOOK)
|
|
1603
|
-
end
|
|
1604
|
-
private :guard_participant_spend
|
|
1605
|
-
|
|
1606
|
-
# The running turn's metrics so far, saved as a turn a worker died in
|
|
1607
|
-
# would leave them (SessionMetrics#persist's running_turn): a worker's
|
|
1608
|
-
# turn checkpoint (TurnCheckpoint) calls it when it writes.
|
|
1609
|
-
# @return [Boolean] whether they were saved
|
|
1610
|
-
def checkpoint_metrics = @metrics.persist(state_dir: session_state_dir, running_turn: true)
|
|
1611
|
-
|
|
1612
|
-
# What a Bridge adds to a plugin's ctx.messages while a turn runs (the
|
|
1613
|
-
# turn so far, as messages); nil without a Bridge (the REPL).
|
|
1614
|
-
attr_writer :running_turn_messages
|
|
1615
|
-
|
|
1616
|
-
def guardrail_state_dir=(state_dir)
|
|
1617
|
-
# Also where list_sessions and send_note look for other sessions.
|
|
1618
|
-
@state_dir = state_dir
|
|
1619
|
-
@guardrail_wiring.state_dir = state_dir
|
|
1620
|
-
end
|
|
1621
|
-
|
|
1622
|
-
# The YAML rules, read again when a file changed (GuardrailWiring#rules).
|
|
1623
|
-
# @return [Guardrails::Rules]
|
|
1624
|
-
def guardrail_rules
|
|
1625
|
-
@guardrail_wiring.rules
|
|
1626
|
-
end
|
|
1627
|
-
|
|
1628
|
-
# @return [Guardrails::LoadFailures]
|
|
1629
|
-
attr_reader :guardrail_failures
|
|
1630
|
-
|
|
1631
|
-
# @return [Guardrails::Approvals]
|
|
1632
|
-
def guardrail_approvals
|
|
1633
|
-
@guardrail_wiring.approvals
|
|
1634
|
-
end
|
|
1635
|
-
|
|
1636
|
-
# @return [Guardrails::LoadFailures] the plugins that failed to load
|
|
1637
|
-
attr_reader :plugin_failures
|
|
1638
|
-
|
|
1639
|
-
# Once per Engine, on its first turn: what failed to load, so every UI
|
|
1640
|
-
# (REPL, attached TUI, web) shows it: the guardrails, then the plugins
|
|
1641
|
-
# (label: "plugins"; the UIs say guardrails without one), an orphaned
|
|
1642
|
-
# project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
|
|
1643
|
-
def announce_guardrail_failures(on_event)
|
|
1644
|
-
return if @guardrail_failures_announced
|
|
1645
|
-
|
|
1646
|
-
@guardrail_failures_announced = true
|
|
1647
|
-
message = @guardrail_failures.message
|
|
1648
|
-
emit_event(on_event, { type: :guardrail_warning, message: message }) if message
|
|
1649
|
-
plugins = @plugin_failures.message
|
|
1650
|
-
emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
1651
|
-
memory = MemoryOrphans.message
|
|
1652
|
-
emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
1653
|
-
@extension_load.held_events.each { |event| emit_event(on_event, event) }
|
|
1654
|
-
end
|
|
1655
|
-
private :announce_guardrail_failures
|
|
1656
|
-
|
|
1657
|
-
# The warning the first turn announced (nil before it, or with nothing
|
|
1658
|
-
# failed), for a UI that joins later (Bridge#snapshot).
|
|
1659
|
-
def guardrail_warning
|
|
1660
|
-
@guardrail_failures.message if @guardrail_failures_announced
|
|
1661
|
-
end
|
|
1662
|
-
|
|
1663
|
-
# The plugins' load warning the first turn announced, likewise, and what
|
|
1664
|
-
# their failed chi.on handlers told (HookRoutes#plugin_hook_failed).
|
|
1665
|
-
def plugin_warning
|
|
1666
|
-
loaded = @plugin_failures.message if @guardrail_failures_announced
|
|
1667
|
-
[loaded, @hook_routes.hook_failures_message].compact.join("; ").then { |text| text unless text.empty? }
|
|
1668
|
-
end
|
|
1669
|
-
|
|
1670
|
-
# Ask the user to approve a call the gate voted `ask` on
|
|
1671
|
-
# (GuardrailWiring#request_approval).
|
|
1672
|
-
# @param verdict [Guardrails::Verdict]
|
|
1673
|
-
# @return [Guardrails::Verdict]
|
|
1674
|
-
def request_approval(verdict)
|
|
1675
|
-
@guardrail_wiring.request_approval(verdict)
|
|
1676
|
-
end
|
|
1677
|
-
|
|
1678
|
-
# The conversation as a hook may read it: a frozen array of copied
|
|
1679
|
-
# messages, so a hook cannot change what the turn sends or stores.
|
|
1680
|
-
def hook_messages(messages)
|
|
1681
|
-
AnswerDisplay.strip_all(messages).map(&:dup).freeze
|
|
1682
|
-
end
|
|
1683
|
-
private :hook_messages
|
|
1684
|
-
|
|
1685
|
-
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1686
|
-
# save puts it in the file the session hub watches. Public for the
|
|
1687
|
-
# Worker's turn that failed before it began (no #end_turn ran).
|
|
1688
|
-
# @param limit [Integer, nil] the turn ran out of iterations at this
|
|
1689
|
-
# limit: "exhausted" => true, "limit" => N, so a wait with nobody to
|
|
1690
|
-
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1691
|
-
# @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
|
|
1692
|
-
# @param event [Hash] the turn's end event (#stop_facts)
|
|
1693
|
-
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1694
|
-
enqueued_id = origin.enqueued_id
|
|
1695
|
-
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1696
|
-
origin: origin.source, exhausted: limit ? true : nil, limit: limit,
|
|
1697
|
-
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1698
|
-
author: origin.participant_author,
|
|
1699
|
-
**stop_facts(event))
|
|
1700
|
-
end
|
|
1701
|
-
|
|
1702
|
-
# A participant joined (invited) or left (revoked, expired) since the
|
|
1703
|
-
# last look: :participants_changed to the observers (ParticipantsWatch;
|
|
1704
|
-
# the worker looks on its idle tick). The first look announces nobody.
|
|
1705
|
-
# @return [ParticipantsWatch::Change, nil]
|
|
1706
|
-
def announce_participants
|
|
1707
|
-
change = @participants_watch.changes or return nil
|
|
1708
|
-
|
|
1709
|
-
announce(change.to_event)
|
|
1710
|
-
change
|
|
1711
|
-
end
|
|
1712
|
-
|
|
1713
|
-
# Reads the session's participants at a turn's start; the first to join,
|
|
1714
|
-
# the first invited to act (a pair participant: the line's words) and
|
|
1715
|
-
# the last to leave rebuild the system prompt (its participants line),
|
|
1716
|
-
# once each: a turn in between sends the same prefix.
|
|
1717
|
-
def refresh_participants!(session)
|
|
1718
|
-
line = @participants.refresh!(session).prompt_mode(session)
|
|
1719
|
-
return if line == @participants_line
|
|
1720
|
-
|
|
1721
|
-
@participants_line = line
|
|
1722
|
-
@prompt_builder.reset!
|
|
1723
|
-
end
|
|
1724
|
-
private :refresh_participants!
|
|
1725
|
-
|
|
1726
|
-
# The author fields a message +author+ wrote carries (Steer.author_fields:
|
|
1727
|
-
# author:, and author_name: from participants.json), {} for the owner.
|
|
1728
|
-
# The worker's drain names a participant's merged lines with it, and
|
|
1729
|
-
# its running save a participant's prompt.
|
|
1730
|
-
# @param author [String, nil] a turn's or a line's author (TurnOrigin#author)
|
|
1731
|
-
# @param session [Session, nil] the session it is for (the turn's)
|
|
1732
|
-
def author_fields(author, session: @session)
|
|
1733
|
-
return {} unless Steer.participant_author?(author)
|
|
1734
|
-
|
|
1735
|
-
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1736
|
-
end
|
|
1737
|
-
|
|
1738
|
-
# A participant's opening message keeps the rules it was delivered under
|
|
1739
|
-
# (ParticipantMode), stamped before any request carries it, so its header
|
|
1740
|
-
# never changes when the session's mode does.
|
|
1741
|
-
def participant_mode_fields(author)
|
|
1742
|
-
mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
|
|
1743
|
-
mode ? { participant_mode: mode.to_s } : {}
|
|
1744
|
-
end
|
|
1745
|
-
|
|
1746
|
-
# Why a turn stopped (TurnEndings#stop_facts).
|
|
1747
|
-
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
1748
|
-
private :stop_facts
|
|
1749
|
-
|
|
1750
697
|
# How every turn ends (completed, canceled, failed): the session gets
|
|
1751
698
|
# what the turn kept, goes idle and records the ending, and the end
|
|
1752
699
|
# event is announced, as one step of the event log (a snapshot taken
|
|
@@ -1786,223 +733,8 @@ module Samagotchi
|
|
|
1786
733
|
end
|
|
1787
734
|
private :end_turn
|
|
1788
735
|
|
|
1789
|
-
#
|
|
1790
|
-
|
|
1791
|
-
# in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
|
|
1792
|
-
# A Verify turn's claim however it ended; a Revise turn's draft only
|
|
1793
|
-
# once it completed with an answer (#revision_target). Returns the
|
|
1794
|
-
# stamped ReviseAnchor (the end event's revises), or nil.
|
|
1795
|
-
def stamp_opening(messages, turn, outcome)
|
|
1796
|
-
return unless messages.is_a?(Array)
|
|
1797
|
-
|
|
1798
|
-
revises = revision_target(messages, turn, outcome)
|
|
1799
|
-
fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
|
|
1800
|
-
return if fields.empty?
|
|
1801
|
-
|
|
1802
|
-
opening = messages.reverse.find do |message|
|
|
1803
|
-
Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1804
|
-
end
|
|
1805
|
-
return unless opening
|
|
1806
|
-
|
|
1807
|
-
opening.merge!(fields)
|
|
1808
|
-
revises
|
|
1809
|
-
end
|
|
1810
|
-
private :stamp_opening
|
|
1811
|
-
|
|
1812
|
-
# The turn a Revise turn revised (D6: "this turn revised that one"):
|
|
1813
|
-
# only a completed turn with an answer (AnswerTail) has revised
|
|
1814
|
-
# anything. A draft no prompt in the messages carries is no target (a
|
|
1815
|
-
# stale page, a rolled-back turn); one a stamped turn already revised
|
|
1816
|
-
# is re-targeted to its stack's top, the newest version, which the
|
|
1817
|
-
# person meant ("this draft").
|
|
1818
|
-
def revision_target(messages, turn, outcome)
|
|
1819
|
-
return unless turn.revises && outcome == "completed"
|
|
1820
|
-
|
|
1821
|
-
slice = AnswerTail.turn_slice(messages, turn.id)
|
|
1822
|
-
return unless slice&.any? { |message| AnswerTail.answer?(message) }
|
|
1823
|
-
|
|
1824
|
-
target = turn.revises.turn_id
|
|
1825
|
-
seen = [turn.id]
|
|
1826
|
-
until seen.include?(target)
|
|
1827
|
-
return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
|
|
1828
|
-
|
|
1829
|
-
seen << target
|
|
1830
|
-
newer = messages.reverse.find do |message|
|
|
1831
|
-
Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
|
|
1832
|
-
end
|
|
1833
|
-
return ReviseAnchor.new(turn_id: target) unless newer
|
|
1834
|
-
|
|
1835
|
-
target = (newer[:turn_id] || newer["turn_id"]).to_s
|
|
1836
|
-
end
|
|
1837
|
-
reject_revise_anchor(turn.revises, :cycle)
|
|
1838
|
-
end
|
|
1839
|
-
private :revision_target
|
|
1840
|
-
|
|
1841
|
-
def reject_revise_anchor(anchor, reason)
|
|
1842
|
-
Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
1843
|
-
nil
|
|
1844
|
-
end
|
|
1845
|
-
private :reject_revise_anchor
|
|
1846
|
-
|
|
1847
|
-
def verified_anchor(session, anchor)
|
|
1848
|
-
return unless anchor
|
|
1849
|
-
|
|
1850
|
-
source = verify_anchor_source_session(session, anchor)
|
|
1851
|
-
return unless source
|
|
1852
|
-
|
|
1853
|
-
turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
|
|
1854
|
-
return reject_verify_anchor(anchor, :turn_missing) unless turn
|
|
1855
|
-
|
|
1856
|
-
reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
|
|
1857
|
-
return reject_verify_anchor(anchor, :reply_missing) unless reply
|
|
1858
|
-
|
|
1859
|
-
quote_text = verify_comparison_text(anchor.quote)
|
|
1860
|
-
quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
|
|
1861
|
-
return reject_verify_anchor(anchor, :quote_missing) unless quoted
|
|
1862
|
-
|
|
1863
|
-
anchor
|
|
1864
|
-
end
|
|
1865
|
-
private :verified_anchor
|
|
1866
|
-
|
|
1867
|
-
# Whether +quote_text+ is in +answer+ as the model wrote it, or as the
|
|
1868
|
-
# pages show it: a page quotes its own text, which reads the session's
|
|
1869
|
-
# folder relative, the home folder as ~ and a secret shape as
|
|
1870
|
-
# [redacted] (a participant's page gets it so, ParticipantView::Redact;
|
|
1871
|
-
# the owner's page rewrites its quote the same way, claim_link.js
|
|
1872
|
-
# normalizeClaim, so claim links match both pages). +source+ is the
|
|
1873
|
-
# session whose folder that is.
|
|
1874
|
-
def verify_quote_in?(quote_text, answer, source)
|
|
1875
|
-
return true if verify_comparison_text(answer).include?(quote_text)
|
|
1876
|
-
|
|
1877
|
-
viewer = Web::ParticipantView::Viewer.new(cwd: source.working_directory)
|
|
1878
|
-
verify_comparison_text(Web::ParticipantView::Redact.text(answer, viewer)).include?(quote_text)
|
|
1879
|
-
end
|
|
1880
|
-
private :verify_quote_in?
|
|
1881
|
-
|
|
1882
|
-
def verify_anchor_source_session(session, anchor)
|
|
1883
|
-
return session unless session.verify_fresh
|
|
1884
|
-
|
|
1885
|
-
fresh = session.verify_fresh
|
|
1886
|
-
return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
|
|
1887
|
-
return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
|
|
1888
|
-
|
|
1889
|
-
Session.load(session.parent_id, state_dir: session_state_dir)
|
|
1890
|
-
rescue ArgumentError, SystemCallError, IOError => e
|
|
1891
|
-
Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
|
|
1892
|
-
error: e.class.name)
|
|
1893
|
-
nil
|
|
1894
|
-
end
|
|
1895
|
-
private :verify_anchor_source_session
|
|
1896
|
-
|
|
1897
|
-
def verify_comparison_text(text)
|
|
1898
|
-
OutputFormatter.strip_markup(text)
|
|
1899
|
-
.gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
|
|
1900
|
-
.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
|
|
1901
|
-
.gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
|
|
1902
|
-
.gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
|
|
1903
|
-
.gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
|
|
1904
|
-
.gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
|
|
1905
|
-
.tr("|", " ") # a table row's cells; the page copies them without the pipes
|
|
1906
|
-
.gsub(/\s+/, " ")
|
|
1907
|
-
.strip
|
|
1908
|
-
end
|
|
1909
|
-
private :verify_comparison_text
|
|
1910
|
-
|
|
1911
|
-
def reject_verify_anchor(anchor, reason)
|
|
1912
|
-
Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
1913
|
-
nil
|
|
1914
|
-
end
|
|
1915
|
-
private :reject_verify_anchor
|
|
1916
|
-
|
|
1917
|
-
def record_verdict(turn, outcome)
|
|
1918
|
-
return unless outcome == "completed" && turn.verify
|
|
1919
|
-
|
|
1920
|
-
answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
|
|
1921
|
-
parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
|
|
1922
|
-
return unless parsed
|
|
1923
|
-
|
|
1924
|
-
record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
|
|
1925
|
-
by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
|
|
1926
|
-
turn.session.verdicts = turn.session.verdicts + [record]
|
|
1927
|
-
record
|
|
1928
|
-
end
|
|
1929
|
-
private :record_verdict
|
|
1930
|
-
|
|
1931
|
-
def save_quietly(session)
|
|
1932
|
-
session.save(state_dir: session_state_dir)
|
|
1933
|
-
rescue StandardError => e
|
|
1934
|
-
Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1935
|
-
nil
|
|
1936
|
-
end
|
|
1937
|
-
private :save_quietly
|
|
1938
|
-
|
|
1939
|
-
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1940
|
-
# and the refs they attached as its `display_refs` (AnswerRefs, one
|
|
1941
|
-
# replace for both), and tell the observers (:answer_display, the
|
|
1942
|
-
# display or nil, and how many refs), after the turn_completed they
|
|
1943
|
-
# already had: the web re-reads the answer then. A turn_completed
|
|
1944
|
-
# with display_pending (after_turn hooks were about to run) always gets
|
|
1945
|
-
# one, `display: nil` when nothing was presented, so the web holds the
|
|
1946
|
-
# answer's pop until then and never swaps it after. Terminals are not
|
|
1947
|
-
# sinks of it; they printed the answer already. The session is saved by
|
|
1948
|
-
# the caller, as after every turn.
|
|
1949
|
-
def store_answer_display(session, display, refs, pending: false)
|
|
1950
|
-
synchronize_events do
|
|
1951
|
-
# A plugin that kept event[:refs] and calls it later gets nil: the
|
|
1952
|
-
# list is stored now (and the turn is over on every path).
|
|
1953
|
-
refs.close!
|
|
1954
|
-
messages = Array(session.messages)
|
|
1955
|
-
changed = display.changed? && messages.last.equal?(display.target)
|
|
1956
|
-
added = refs.added? && messages.last.equal?(refs.target)
|
|
1957
|
-
if changed || added
|
|
1958
|
-
merged = messages.last.dup
|
|
1959
|
-
merged[AnswerDisplay::KEY] = display.text if changed
|
|
1960
|
-
merged[AnswerRefs::KEY] = refs.to_a if added
|
|
1961
|
-
replace_session_messages(session, messages[0...-1] + [merged])
|
|
1962
|
-
event = { type: :answer_display, display: changed ? display.text : nil }
|
|
1963
|
-
event[:refs] = refs.count if added
|
|
1964
|
-
@session_observer.notify(event)
|
|
1965
|
-
elsif pending
|
|
1966
|
-
@session_observer.notify({ type: :answer_display, display: nil })
|
|
1967
|
-
end
|
|
1968
|
-
end
|
|
1969
|
-
end
|
|
1970
|
-
private :store_answer_display
|
|
1971
|
-
|
|
1972
|
-
# ── The hook runtime (HookRoutes) ───────────────────────────────────────
|
|
1973
|
-
|
|
1974
|
-
# What a hook (Hooks::Runtime) and a plugin (Plugin::Host) can do beyond
|
|
1975
|
-
# reading its event, routed to the UIs by HookRoutes; these forward.
|
|
1976
|
-
def hook_runtime = @hook_routes.runtime
|
|
1977
|
-
private :hook_runtime
|
|
1978
|
-
|
|
1979
|
-
def hook_source(hook) = @hook_routes.source(hook)
|
|
1980
|
-
private :hook_source
|
|
1981
|
-
|
|
1982
|
-
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
1983
|
-
NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
|
|
1984
|
-
|
|
1985
|
-
# One line to the user (:hook_notice), HookRoutes#notify.
|
|
1986
|
-
def hook_notify(text, level, hook, fallback_for: nil, extra: {})
|
|
1987
|
-
@hook_routes.notify(text, level, hook, fallback_for: fallback_for, extra: extra)
|
|
1988
|
-
end
|
|
1989
|
-
private :hook_notify
|
|
1990
|
-
|
|
1991
|
-
def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
|
|
1992
|
-
private :guardrail_notify
|
|
1993
|
-
|
|
1994
|
-
# @param question [HookQuestion]
|
|
1995
|
-
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
1996
|
-
def hook_ask_user(question) = @hook_routes.ask_user(question)
|
|
1997
|
-
private :hook_ask_user
|
|
1998
|
-
|
|
1999
|
-
# @return [Boolean] true when a running turn was cancelled now
|
|
2000
|
-
def hook_stop_turn(reason, hook) = @hook_routes.stop_turn(reason, hook)
|
|
2001
|
-
private :hook_stop_turn
|
|
2002
|
-
|
|
2003
|
-
# @return [Boolean] true when a streaming generation was cut now
|
|
2004
|
-
def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
|
|
2005
|
-
private :hook_stop_generation
|
|
736
|
+
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
737
|
+
NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
|
|
2006
738
|
|
|
2007
739
|
# Where cards and notices go (see #initialize for when). Each lookup
|
|
2008
740
|
# calls the Engine method the routes used before EventRoutes, at use.
|
|
@@ -2032,192 +764,6 @@ module Samagotchi
|
|
|
2032
764
|
end
|
|
2033
765
|
private :build_hook_routes
|
|
2034
766
|
|
|
2035
|
-
# ── Ask-user-question (structured qualification) ──────────────────────────
|
|
2036
|
-
|
|
2037
|
-
# @return [PendingQuestion, nil] the question pending now
|
|
2038
|
-
def pending_question
|
|
2039
|
-
@question_desk.pending
|
|
2040
|
-
end
|
|
2041
|
-
|
|
2042
|
-
# Ask the model's question (ask_user_question): the kernel's
|
|
2043
|
-
# question_handler, called on the turn thread with the payload
|
|
2044
|
-
# Tools::AskUserQuestion.validate made (QuestionDesk#request), after
|
|
2045
|
-
# the :question_asked hooks (QuestionAsked) gave the step its refs; a
|
|
2046
|
-
# --non-interactive run has no one to ask, so they don't fire there.
|
|
2047
|
-
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
2048
|
-
# @return [String] normalized answer JSON
|
|
2049
|
-
def request_question(payload)
|
|
2050
|
-
payload = question_asked(QuestionAsked::KIND_QUESTION, payload) unless interface == :non_interactive
|
|
2051
|
-
@question_desk.request(payload)
|
|
2052
|
-
end
|
|
2053
|
-
|
|
2054
|
-
# The :question_asked hooks for a question of the model's about to
|
|
2055
|
-
# open: the fields with the asking step's refs (QuestionAsked.fire).
|
|
2056
|
-
# @return [Hash]
|
|
2057
|
-
def question_asked(kind, fields)
|
|
2058
|
-
QuestionAsked.fire(hooks: @hooks, conversation: live_conversation, kind: kind, fields: fields,
|
|
2059
|
-
cwd: @session&.working_directory, synchronize: ->(&block) { synchronize_events(&block) },
|
|
2060
|
-
visible: ->(content) { question_step_text(content) })
|
|
2061
|
-
end
|
|
2062
|
-
private :question_asked
|
|
2063
|
-
|
|
2064
|
-
# A step's text as the asking slot shows it: the native loop's raw
|
|
2065
|
-
# response without its thoughts and tool-call bodies (a chat step has
|
|
2066
|
-
# neither), then the markup's tokens (OutputFormatter.strip_markup).
|
|
2067
|
-
def question_step_text(content)
|
|
2068
|
-
parser = @kernel.parser
|
|
2069
|
-
OutputFormatter.strip_markup(parser.strip_tool_calls(parser.strip_thought(content)))
|
|
2070
|
-
end
|
|
2071
|
-
private :question_step_text
|
|
2072
|
-
|
|
2073
|
-
# Open a question for the UIs and wait for its answer
|
|
2074
|
-
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
2075
|
-
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
2076
|
-
# (as a Hash or as keywords)
|
|
2077
|
-
# @param watch [#call, nil] closes the question with the reason it returns
|
|
2078
|
-
# @return [QuestionAnswer, Hash, String] the answer, or {error:, …}, or a
|
|
2079
|
-
# sync handler's text
|
|
2080
|
-
def open_question(fields = nil, watch: nil, **more)
|
|
2081
|
-
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
2082
|
-
end
|
|
2083
|
-
|
|
2084
|
-
# Publish a standing question that blocks nobody (QuestionDesk#post: a
|
|
2085
|
-
# worker's step-limit question).
|
|
2086
|
-
# @return [PendingQuestion, nil] the pending question, nil while another is open
|
|
2087
|
-
def post_question(fields, on_answer:, on_superseded_close: nil)
|
|
2088
|
-
@question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
|
|
2089
|
-
end
|
|
2090
|
-
|
|
2091
|
-
# Withdraw the standing question (QuestionDesk#withdraw).
|
|
2092
|
-
# @return [Boolean]
|
|
2093
|
-
def withdraw_question(reason, id: nil)
|
|
2094
|
-
@question_desk.withdraw(reason, id: id)
|
|
2095
|
-
end
|
|
2096
|
-
|
|
2097
|
-
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
2098
|
-
# @return [QuestionAnswer]
|
|
2099
|
-
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
2100
|
-
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
|
|
2101
|
-
parent_agent: parent_agent)
|
|
2102
|
-
end
|
|
2103
|
-
|
|
2104
|
-
# Mark the pending question as relayed, or clear the mark
|
|
2105
|
-
# (QuestionDesk#annotate).
|
|
2106
|
-
# @return [Boolean]
|
|
2107
|
-
def annotate_question(id, relayed_to:, reason: nil)
|
|
2108
|
-
@question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
|
|
2109
|
-
end
|
|
2110
|
-
|
|
2111
|
-
def set_question_sync_handler(&block)
|
|
2112
|
-
@question_desk.sync_handler = block
|
|
2113
|
-
end
|
|
2114
|
-
|
|
2115
|
-
# Cancel the pending question (QuestionDesk#cancel).
|
|
2116
|
-
# @return [Boolean] whether it was cancelled
|
|
2117
|
-
# @raise [QuestionDesk::NotDismissable] +id+ names a standing question
|
|
2118
|
-
def cancel_question(reason = "user", id: nil)
|
|
2119
|
-
@question_desk.cancel(reason, id: id)
|
|
2120
|
-
end
|
|
2121
|
-
|
|
2122
|
-
# The system prompt for a target's loop (default: the effective model's):
|
|
2123
|
-
# the chat loop's leaves out the raw-prompt tool text, since its tools go
|
|
2124
|
-
# as schemas. Built once per loop (and again after a model switch) so the
|
|
2125
|
-
# prompt prefix, and the server's KV cache for it, stay stable.
|
|
2126
|
-
# @param model [EffectiveModel, nil]
|
|
2127
|
-
# @return [String]
|
|
2128
|
-
# The native prompt depends on the thinking level too (Gemma's token):
|
|
2129
|
-
# a level change builds it again. A build that
|
|
2130
|
-
# misses the cache names the model and the level through its own
|
|
2131
|
-
# lambdas (#model_identity, #turn_thinking), which resolve again.
|
|
2132
|
-
def system_prompt(model = nil)
|
|
2133
|
-
model ||= effective_model
|
|
2134
|
-
chat = model.chat?
|
|
2135
|
-
level = chat ? nil : @model_settings.thinking_level(model)
|
|
2136
|
-
prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(@model_settings.llm_context_session_args))
|
|
2137
|
-
# The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
|
|
2138
|
-
@metrics.memory_index = @prompt_builder.memory_index
|
|
2139
|
-
# The model notes it carries: the session records them (/stats, /model, the web).
|
|
2140
|
-
record_prompt_notes(@prompt_builder.prompt_notes)
|
|
2141
|
-
prompt
|
|
2142
|
-
end
|
|
2143
|
-
|
|
2144
|
-
# The base prompt (specs, plugins' declarations).
|
|
2145
|
-
def assist_system_prompt(chat: false)
|
|
2146
|
-
@prompt_builder.base(chat: chat)
|
|
2147
|
-
end
|
|
2148
|
-
|
|
2149
|
-
# The --memory names activated while building the prompt; the TerminalUI
|
|
2150
|
-
# mirrors them into its status line.
|
|
2151
|
-
def activated_memory_names
|
|
2152
|
-
@prompt_builder.activated_memory_names
|
|
2153
|
-
end
|
|
2154
|
-
|
|
2155
|
-
# @return [Session] current session (Engine owns create/resume)
|
|
2156
|
-
attr_reader :session
|
|
2157
|
-
|
|
2158
|
-
# This worker's relays of its delegates' approvals (memory only).
|
|
2159
|
-
# @return [RelayDesk]
|
|
2160
|
-
attr_reader :relay_desk
|
|
2161
|
-
|
|
2162
|
-
# The relay's view of this Engine, or nil when it can't host a relay: a
|
|
2163
|
-
# worker's Engine only (its Bridge is what a child verifies against;
|
|
2164
|
-
# --non-interactive has no one to ask), never with a REPL's sync
|
|
2165
|
-
# handler (no wait loop, no watch).
|
|
2166
|
-
# @return [RelayPeer, nil]
|
|
2167
|
-
def relay_peer
|
|
2168
|
-
return nil unless interface == :worker && !@question_desk.sync_handler?
|
|
2169
|
-
|
|
2170
|
-
RelayPeer.new(self)
|
|
2171
|
-
end
|
|
2172
|
-
|
|
2173
|
-
# @return [String] the state dir holding this Engine's sessions
|
|
2174
|
-
def peer_state_dir = @state_dir
|
|
2175
|
-
|
|
2176
|
-
# Set the current session outside a turn (the REPL does, before its first
|
|
2177
|
-
# turn, so the messages API and recap see it)
|
|
2178
|
-
def session=(session)
|
|
2179
|
-
@session = session
|
|
2180
|
-
# One session per REPL/worker process: its records carry this sid.
|
|
2181
|
-
Log.session_id = session.id if session&.id
|
|
2182
|
-
# Its own recap values (chi --set recap.enabled=false) rebuild the job.
|
|
2183
|
-
rebuild_recap!
|
|
2184
|
-
# A woken worker's /stats and status line count the turns before it.
|
|
2185
|
-
bind_metrics(session)
|
|
2186
|
-
@used_memories.absorb(session)
|
|
2187
|
-
# Loaded on another model than its last turn ran on (a --resume'd
|
|
2188
|
-
# REPL's --model, a worker woken after a /model with no turn since):
|
|
2189
|
-
# the saved ctx % was that model's window's. Drop it and count it as
|
|
2190
|
-
# /model does, on a configured window only: this runs before a
|
|
2191
|
-
# worker's Bridge serves, so no /props GET or model list here (a slow
|
|
2192
|
-
# or hung host would hold up the worker's start). No configured
|
|
2193
|
-
# window: no % (ctx ?) until the first generation reports one.
|
|
2194
|
-
return unless model_switched_since_last_turn?
|
|
2195
|
-
|
|
2196
|
-
@metrics.forget_model_reports!
|
|
2197
|
-
announce_switched_context!(configured_only: true)
|
|
2198
|
-
end
|
|
2199
|
-
|
|
2200
|
-
# Whether the last turn that named its model ran on another one than
|
|
2201
|
-
# the effective model; false before any (or when the ref can't be told).
|
|
2202
|
-
def model_switched_since_last_turn?
|
|
2203
|
-
last = @metrics.last_model_ref
|
|
2204
|
-
!last.nil? && last != effective_model_ref
|
|
2205
|
-
rescue StandardError => e
|
|
2206
|
-
Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2207
|
-
false
|
|
2208
|
-
end
|
|
2209
|
-
private :model_switched_since_last_turn?
|
|
2210
|
-
|
|
2211
|
-
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
2212
|
-
attr_reader :recap
|
|
2213
|
-
|
|
2214
|
-
# Whether a continue offer waits (the Worker's or the REPL's TurnFlow),
|
|
2215
|
-
# for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
|
|
2216
|
-
def recap_awaiting_continue=(callable)
|
|
2217
|
-
@recap_awaiting_continue = callable
|
|
2218
|
-
@recap&.awaiting_continue = callable
|
|
2219
|
-
end
|
|
2220
|
-
|
|
2221
767
|
# The idle recap job (RecapSetup.build) under the current session's own
|
|
2222
768
|
# recap values, which it records.
|
|
2223
769
|
# @return [IdleRecap, nil]
|
|
@@ -2232,95 +778,6 @@ module Samagotchi
|
|
|
2232
778
|
end
|
|
2233
779
|
private :build_recap
|
|
2234
780
|
|
|
2235
|
-
# The bound session's own recap.* values ({} for none).
|
|
2236
|
-
def session_recap_values
|
|
2237
|
-
(@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
|
|
2238
|
-
end
|
|
2239
|
-
private :session_recap_values
|
|
2240
|
-
|
|
2241
|
-
# A new recap job when the bound session's own recap values differ from
|
|
2242
|
-
# the ones the current job was built under (none at start): the old one
|
|
2243
|
-
# is dropped (an attempt in flight goes stale) and the scheduler swaps
|
|
2244
|
-
# it, keeping its one thread. Binding the same session again changes
|
|
2245
|
-
# nothing (the REPL binds it twice).
|
|
2246
|
-
def rebuild_recap!
|
|
2247
|
-
return if session_recap_values == @recap_built_for
|
|
2248
|
-
|
|
2249
|
-
old = @recap
|
|
2250
|
-
old&.invalidate!
|
|
2251
|
-
@recap = build_recap
|
|
2252
|
-
@idle_scheduler&.replace_job(old, @recap)
|
|
2253
|
-
end
|
|
2254
|
-
|
|
2255
|
-
# /recap on|off|default: the bound session's own recap.enabled (true,
|
|
2256
|
-
# false; nil removes it, so config.yml's applies again), and the recap
|
|
2257
|
-
# job rebuilt under it at once. It doesn't save: the command says
|
|
2258
|
-
# changed: [:recap] and its host saves (Worker#run_command).
|
|
2259
|
-
# @param enabled [Boolean, nil]
|
|
2260
|
-
# @raise [ArgumentError] no session is bound
|
|
2261
|
-
def recap_override=(enabled)
|
|
2262
|
-
raise ArgumentError, "no session" unless @session
|
|
2263
|
-
|
|
2264
|
-
core = @session.settings.core.dup
|
|
2265
|
-
enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
|
|
2266
|
-
@session.settings = @session.settings.with(core: core)
|
|
2267
|
-
rebuild_recap!
|
|
2268
|
-
end
|
|
2269
|
-
|
|
2270
|
-
# The bound session's own recap.enabled (/recap on|off, --set), or nil.
|
|
2271
|
-
# @return [Boolean, nil]
|
|
2272
|
-
def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
|
|
2273
|
-
|
|
2274
|
-
# Write the recap now, before the session is left (IdleRecap#write_now).
|
|
2275
|
-
# @return [String, nil] the recap written, nil when none was (recap off,
|
|
2276
|
-
# nothing new, an error, the timeout)
|
|
2277
|
-
def write_recap_now(on_start: nil)
|
|
2278
|
-
@recap&.write_now(on_start: on_start)
|
|
2279
|
-
end
|
|
2280
|
-
|
|
2281
|
-
# The longest #write_recap_now waits (recap.timeout), nil when no recap
|
|
2282
|
-
# is set up.
|
|
2283
|
-
# @return [Float, nil]
|
|
2284
|
-
def recap_timeout = @recap&.timeout
|
|
2285
|
-
|
|
2286
|
-
# Ask for a recap now (/recap), without waiting for the idle window.
|
|
2287
|
-
# @return [Symbol] IdleRecap#request_now's answer, or :off
|
|
2288
|
-
def request_recap
|
|
2289
|
-
@recap ? @recap.request_now : :off
|
|
2290
|
-
end
|
|
2291
|
-
|
|
2292
|
-
# The recap saved with the current session (recap.json), and how many
|
|
2293
|
-
# user turns came after it (0: it is current).
|
|
2294
|
-
# @return [Hash, nil] {text:, covered:, turns_since:, created_at:}
|
|
2295
|
-
def saved_recap
|
|
2296
|
-
state = @recap&.state
|
|
2297
|
-
return nil unless state
|
|
2298
|
-
|
|
2299
|
-
messages = @session&.messages || []
|
|
2300
|
-
covered = state[:covered].to_i
|
|
2301
|
-
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
|
|
2302
|
-
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
2303
|
-
end
|
|
2304
|
-
|
|
2305
|
-
# Start the shared idle scheduler (reminders + optional recap). The recap
|
|
2306
|
-
# job is only registered when recap is configured; the reminders job is
|
|
2307
|
-
# always present. TerminalUI calls this before the REPL and SessionManager
|
|
2308
|
-
# workers call it so reminders can trigger turns; one-shot/worker paths
|
|
2309
|
-
# that never start it poll nothing.
|
|
2310
|
-
def start_idle
|
|
2311
|
-
@idle_scheduler&.start
|
|
2312
|
-
self
|
|
2313
|
-
end
|
|
2314
|
-
|
|
2315
|
-
# Stop the shared idle scheduler (TerminalUI calls this when the REPL
|
|
2316
|
-
# exits). +join+: wait for a tick under way (IdleScheduler#stop); a
|
|
2317
|
-
# caller holding #synchronize_events passes false, since a recap's emit
|
|
2318
|
-
# in that tick waits for the same lock.
|
|
2319
|
-
def stop_idle(join: true)
|
|
2320
|
-
@idle_scheduler&.stop(join: join)
|
|
2321
|
-
self
|
|
2322
|
-
end
|
|
2323
|
-
|
|
2324
781
|
# One turn's values, for #run_turn and its endings. Per turn and
|
|
2325
782
|
# single-threaded: the cross-thread turn state (the cancel slot, the
|
|
2326
783
|
# sink, steers) stays on the Engine.
|
|
@@ -2462,7 +919,7 @@ module Samagotchi
|
|
|
2462
919
|
|
|
2463
920
|
# Keep what the turn got to (the prompt plus the loop's completed
|
|
2464
921
|
# tool iterations) like a cancel does, and save it: a worker exits
|
|
2465
|
-
# after a failed turn.
|
|
922
|
+
# after a failed turn.
|
|
2466
923
|
end_turn(turn, "failed", save: true) { |seconds| [failed_messages(turn, e), failed_event(e, seconds)] }
|
|
2467
924
|
raise
|
|
2468
925
|
ensure
|
|
@@ -2497,7 +954,7 @@ module Samagotchi
|
|
|
2497
954
|
# or a plugin's card from another thread never sees a running turn
|
|
2498
955
|
# without them. The idle recap detector does not fire (or render an
|
|
2499
956
|
# invalidated recap) while the model is working. A hook's notice goes
|
|
2500
|
-
# where the turn's events go (
|
|
957
|
+
# where the turn's events go (a one-shot prints only its sink; a
|
|
2501
958
|
# worker's observers carry it to the bridge).
|
|
2502
959
|
@turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
|
|
2503
960
|
# Drop a recap already in flight: the turn makes it stale.
|
|
@@ -2602,90 +1059,6 @@ module Samagotchi
|
|
|
2602
1059
|
end
|
|
2603
1060
|
private :prepare_turn
|
|
2604
1061
|
|
|
2605
|
-
# A turn stopped before the model was asked (#prepare_turn returned
|
|
2606
|
-
# false): canceled with nothing kept, like a Ctrl-C there; the result
|
|
2607
|
-
# has no conversation, so a UI's checkpoint stands.
|
|
2608
|
-
def stopped_before_model(turn)
|
|
2609
|
-
reason = turn.controller.reason
|
|
2610
|
-
end_turn(turn, "canceled") do |seconds|
|
|
2611
|
-
[nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
|
|
2612
|
-
duration_ms: (seconds * 1000).round }]
|
|
2613
|
-
end
|
|
2614
|
-
LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
|
|
2615
|
-
end
|
|
2616
|
-
private :stopped_before_model
|
|
2617
|
-
|
|
2618
|
-
# Who stopped a turn a hook stopped or cut (the controller's detail: the
|
|
2619
|
-
# bundle, or the hook's label), for the UIs' "stopped by loop-guard";
|
|
2620
|
-
# nil for any other cancel.
|
|
2621
|
-
def cancelled_by(turn) = turn.controller.stopped_by
|
|
2622
|
-
private :cancelled_by
|
|
2623
|
-
|
|
2624
|
-
# The turn's settings for the kernel (LLM::TurnSettings): vision,
|
|
2625
|
-
# sampling and thinking, the thinking resolved before the hooks run.
|
|
2626
|
-
# The model name is added in #generate, after the hooks that may switch
|
|
2627
|
-
# the model.
|
|
2628
|
-
# One resolve for all of them: nothing runs between them that could
|
|
2629
|
-
# change what a resolve gives.
|
|
2630
|
-
def turn_settings(session)
|
|
2631
|
-
model = effective_model
|
|
2632
|
-
vision = turn_vision(session, model)
|
|
2633
|
-
sampling = model.sampling
|
|
2634
|
-
level = @model_settings.thinking_level(model)
|
|
2635
|
-
@thinking_notices.turn_started(level, model.target)
|
|
2636
|
-
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
|
|
2637
|
-
reasoning_history: @model_settings.reasoning_history_level(model), model_name: nil,
|
|
2638
|
-
window_setting: nil, llm_context: nil, price: nil)
|
|
2639
|
-
end
|
|
2640
|
-
private :turn_settings
|
|
2641
|
-
|
|
2642
|
-
# @return [PromptWarmup] the turn-end warm-up (specs, measurements)
|
|
2643
|
-
attr_reader :prompt_warmup
|
|
2644
|
-
|
|
2645
|
-
# A caller's check that the next turn starts at once (input queued for
|
|
2646
|
-
# it: a worker's inbox) or that none follows (a one-shot run): no
|
|
2647
|
-
# warm-up then. nil: neither.
|
|
2648
|
-
attr_accessor :next_turn_waiting
|
|
2649
|
-
|
|
2650
|
-
# After a completed turn, prefill the next turn's prompt up to its user
|
|
2651
|
-
# message while the user reads (PromptWarmup, cache.warmup). Only on a
|
|
2652
|
-
# local llama.cpp host on the native loop, and not when the next turn's
|
|
2653
|
-
# start differs anyway or starts at once: a reminder due, a continue
|
|
2654
|
-
# offer (the turn ran out of steps), a carried steer or queued input.
|
|
2655
|
-
# Never fails the turn.
|
|
2656
|
-
def warm_up_next_turn(turn, result)
|
|
2657
|
-
return if result.canceled? || result.resumable?
|
|
2658
|
-
|
|
2659
|
-
model = effective_model
|
|
2660
|
-
client = model.client
|
|
2661
|
-
return unless warmup_target?(model, client)
|
|
2662
|
-
return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
|
|
2663
|
-
|
|
2664
|
-
messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
|
|
2665
|
-
# Under the next turn's strategy (a /model switch may have changed it);
|
|
2666
|
-
# the last turn's when it can't be resolved.
|
|
2667
|
-
prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
|
|
2668
|
-
return unless prompt
|
|
2669
|
-
|
|
2670
|
-
started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
|
|
2671
|
-
slot: @kernel.last_slot, images: images)
|
|
2672
|
-
@kernel.warmed_up! if started
|
|
2673
|
-
rescue StandardError => e
|
|
2674
|
-
Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2675
|
-
end
|
|
2676
|
-
private :warm_up_next_turn
|
|
2677
|
-
|
|
2678
|
-
# Never a paid or remote host (cache.warmup auto means local only), nor
|
|
2679
|
-
# the chat loop (Splash keeps its own cache; llama.cpp's chat path is
|
|
2680
|
-
# not measured), nor a client that can't warm up (a spec's stub).
|
|
2681
|
-
def warmup_target?(model, client)
|
|
2682
|
-
return false if model.chat? || model.entry.remote?
|
|
2683
|
-
return false unless client.respond_to?(:warm_up)
|
|
2684
|
-
|
|
2685
|
-
PromptWarmup.enabled?
|
|
2686
|
-
end
|
|
2687
|
-
private :warmup_target?
|
|
2688
|
-
|
|
2689
1062
|
# The messages the turn sends: the history under the system head, due
|
|
2690
1063
|
# reminders, the prompt. turn.messages is set first and grown in place,
|
|
2691
1064
|
# so a Ctrl-C or a failure meanwhile keeps what is there.
|
|
@@ -2767,250 +1140,6 @@ module Samagotchi
|
|
|
2767
1140
|
end
|
|
2768
1141
|
private :generate
|
|
2769
1142
|
|
|
2770
|
-
# The ref (host:model, as asked) a turn's generations ran on: the model
|
|
2771
|
-
# #generate resolved, after the before_turn hooks (which may switch it).
|
|
2772
|
-
# SessionMetrics keys its per-model segments on it. nil when it can't be
|
|
2773
|
-
# named (the turn's record then has none).
|
|
2774
|
-
def generation_model_ref(model)
|
|
2775
|
-
model.ref
|
|
2776
|
-
rescue StandardError => e
|
|
2777
|
-
Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2778
|
-
nil
|
|
2779
|
-
end
|
|
2780
|
-
private :generation_model_ref
|
|
2781
|
-
|
|
2782
|
-
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2783
|
-
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2784
|
-
# Entries before it never move between the turns (#turn_messages adds
|
|
2785
|
-
# at the tail, and the system head is replaced in place).
|
|
2786
|
-
def resumed_run_start(turn, run_start)
|
|
2787
|
-
start = turn.resume_from
|
|
2788
|
-
return nil if start.nil?
|
|
2789
|
-
return start if start.is_a?(Integer) && start.between?(0, run_start)
|
|
2790
|
-
|
|
2791
|
-
Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
|
|
2792
|
-
nil
|
|
2793
|
-
end
|
|
2794
|
-
private :resumed_run_start
|
|
2795
|
-
|
|
2796
|
-
# The turn's LLM context strategy and where it came from, in the turn
|
|
2797
|
-
# log: when it runs a layer, or the session set it (none too).
|
|
2798
|
-
def log_llm_context(explained)
|
|
2799
|
-
return unless explained
|
|
2800
|
-
return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
|
|
2801
|
-
|
|
2802
|
-
summary = explained.summary
|
|
2803
|
-
Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
|
|
2804
|
-
:budget_source).compact)
|
|
2805
|
-
end
|
|
2806
|
-
private :log_llm_context
|
|
2807
|
-
|
|
2808
|
-
def publish_used_memories(session, on_event)
|
|
2809
|
-
# Persist deduped used memories onto the session for Web + reload.
|
|
2810
|
-
begin
|
|
2811
|
-
session.used_memory_names = used_memory_names
|
|
2812
|
-
rescue StandardError => e
|
|
2813
|
-
Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2814
|
-
end
|
|
2815
|
-
# Notify live observers of the updated memory list (so yellow bar refreshes
|
|
2816
|
-
# even without a tool_call event if the preload was the only addition).
|
|
2817
|
-
# Only emit when there is something to report to avoid noisy event_count drift.
|
|
2818
|
-
return unless used_memory_names.any?
|
|
2819
|
-
|
|
2820
|
-
begin
|
|
2821
|
-
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
|
|
2822
|
-
rescue StandardError => e
|
|
2823
|
-
Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2824
|
-
end
|
|
2825
|
-
end
|
|
2826
|
-
private :publish_used_memories
|
|
2827
|
-
|
|
2828
|
-
# The normal ending (an answer, an empty answer, a Stop, the iteration
|
|
2829
|
-
# limit), then the after-turn hooks.
|
|
2830
|
-
def complete_turn(turn, result)
|
|
2831
|
-
canceled = result.canceled?
|
|
2832
|
-
# after_turn hooks run below and may present the answer (the web
|
|
2833
|
-
# holds its pop until it knows).
|
|
2834
|
-
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
2835
|
-
end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
|
|
2836
|
-
kept = kept_messages(turn, result, seconds)
|
|
2837
|
-
if canceled
|
|
2838
|
-
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
|
|
2839
|
-
duration_ms: (seconds * 1000).round }]
|
|
2840
|
-
else
|
|
2841
|
-
# For a client that attaches later (session_state_snapshot).
|
|
2842
|
-
@last_context_status = result.context_status.dup if result.context_status
|
|
2843
|
-
[kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
|
|
2844
|
-
display_pending: display_pending }]
|
|
2845
|
-
end
|
|
2846
|
-
end
|
|
2847
|
-
|
|
2848
|
-
# Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
|
|
2849
|
-
# ends the turn in #run_turn without it), with a read-only
|
|
2850
|
-
# copy of the conversation the turn stored, event[:answer] (the answer's
|
|
2851
|
-
# text: the native loop's stored message still holds its thought),
|
|
2852
|
-
# event[:present] for
|
|
2853
|
-
# a display version of the answer (AnswerDisplay) and event[:refs]
|
|
2854
|
-
# for refs on it (AnswerRefs).
|
|
2855
|
-
session = turn.session
|
|
2856
|
-
audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
|
|
2857
|
-
display = AnswerDisplay.new(session.messages)
|
|
2858
|
-
refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
|
|
2859
|
-
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
2860
|
-
messages: hook_messages(session.messages),
|
|
2861
|
-
answer: (result.text unless canceled || result.exhausted?) }
|
|
2862
|
-
after_turn[:present] = display.presenter(after_turn)
|
|
2863
|
-
after_turn[:refs] = refs.adder(after_turn)
|
|
2864
|
-
@hooks.fire(:after_turn, after_turn)
|
|
2865
|
-
store_answer_display(session, display, refs, pending: display_pending)
|
|
2866
|
-
|
|
2867
|
-
# Fire :session_end after the turn (turn-level lifecycle), as :after_turn
|
|
2868
|
-
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
2869
|
-
end
|
|
2870
|
-
private :complete_turn
|
|
2871
|
-
|
|
2872
|
-
# What each turn ending keeps and announces is TurnEndings'
|
|
2873
|
-
# (#build_turn_endings); these forward to it.
|
|
2874
|
-
def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
|
|
2875
|
-
private :kept_messages
|
|
2876
|
-
def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
|
|
2877
|
-
private :ctrl_c_messages
|
|
2878
|
-
def loop_conversation(error) = @turn_endings.loop_conversation(error)
|
|
2879
|
-
private :loop_conversation
|
|
2880
|
-
def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
|
|
2881
|
-
private :failed_messages
|
|
2882
|
-
def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
|
|
2883
|
-
private :failed_event
|
|
2884
|
-
|
|
2885
|
-
# However the turn ended (#run_turn's ensure): the turn flag off, the
|
|
2886
|
-
# probe cancel restored, the cancel controller, sink and steers cleared
|
|
2887
|
-
# (a steer left is logged as dropped), activity recorded, the
|
|
2888
|
-
# turn-scoped hooks cleared.
|
|
2889
|
-
def release_turn(probe_cancel_before)
|
|
2890
|
-
# A completed turn is activity: release the turn flag and advance the
|
|
2891
|
-
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
2892
|
-
# treats the just-finished turn as activity and re-arms its window.
|
|
2893
|
-
left = @turn_state.finish!
|
|
2894
|
-
@steer_cut.finished!
|
|
2895
|
-
Client.swap_probe_cancel(probe_cancel_before)
|
|
2896
|
-
# The next turn's window and served model are asked again: the cache
|
|
2897
|
-
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
2898
|
-
# rather than at its start, so what this turn read (the profile's own
|
|
2899
|
-
# probe included) is not asked for a second time within the turn. A
|
|
2900
|
-
# restart with another -c between turns is still noticed: the next
|
|
2901
|
-
# turn's first probe goes to the server.
|
|
2902
|
-
@client.invalidate_context_window!
|
|
2903
|
-
log_dropped_steers(left, "turn_ended")
|
|
2904
|
-
record_activity
|
|
2905
|
-
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
2906
|
-
clear_hooks
|
|
2907
|
-
end
|
|
2908
|
-
private :release_turn
|
|
2909
|
-
|
|
2910
|
-
# [refs, nil] for a turn's images, or [[], error] when one can't be used
|
|
2911
|
-
# (the turn then fails right after :turn_started).
|
|
2912
|
-
def turn_image_refs(session, images)
|
|
2913
|
-
return [[], nil] if Array(images).empty?
|
|
2914
|
-
|
|
2915
|
-
[ImageStore.resolve_all(Session.session_dir(session.id, state_dir: session_state_dir), images), nil]
|
|
2916
|
-
rescue ImageStore::Error => e
|
|
2917
|
-
[[], e]
|
|
2918
|
-
end
|
|
2919
|
-
|
|
2920
|
-
# The turn's VisionContext: the session's images folder, and whether the
|
|
2921
|
-
# effective model can see images, asked only when a request carries one.
|
|
2922
|
-
def turn_vision(session, model = effective_model)
|
|
2923
|
-
VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
|
|
2924
|
-
capability: -> { model.vision_support(profile) })
|
|
2925
|
-
end
|
|
2926
|
-
|
|
2927
|
-
# The effective model's sampling and thinking level as the next turn
|
|
2928
|
-
# reads them (SessionModelSettings).
|
|
2929
|
-
def turn_sampling = @model_settings.turn_sampling
|
|
2930
|
-
def turn_thinking = @model_settings.turn_thinking
|
|
2931
|
-
|
|
2932
|
-
# A model known not to see images fails a turn with images up front.
|
|
2933
|
-
def refuse_images!(vision)
|
|
2934
|
-
return if vision.sendable?
|
|
2935
|
-
|
|
2936
|
-
host = effective_model.host_name
|
|
2937
|
-
raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
|
|
2938
|
-
end
|
|
2939
|
-
|
|
2940
|
-
# JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
|
|
2941
|
-
# @param result [LLM::ModelResult]
|
|
2942
|
-
# @return [Hash]
|
|
2943
|
-
def turn_summary(result) = @turn_endings.turn_summary(result)
|
|
2944
|
-
|
|
2945
|
-
# ── Session messages API ───────────────────────────────────────────────
|
|
2946
|
-
#
|
|
2947
|
-
# Out-of-turn edits to the current session's conversation (`!cmd` output,
|
|
2948
|
-
# rollback after Ctrl-C). Each replaces the array rather than mutating it,
|
|
2949
|
-
# so the recap's lock-free snapshot never sees a half-applied edit.
|
|
2950
|
-
|
|
2951
|
-
# The answer a UI shows last (AnswerTail), a copy of that one message
|
|
2952
|
-
# only: walked back from the end, never a #messages_checkpoint of the
|
|
2953
|
-
# whole conversation (the Bridge's GET tail). +turn_id+: that turn's
|
|
2954
|
-
# answer (AnswerTail.find); unknown or nil: the newest.
|
|
2955
|
-
# @return [Hash, nil]
|
|
2956
|
-
def last_answer_message(turn_id: nil)
|
|
2957
|
-
AnswerTail.find(@session&.messages, turn_id: turn_id)&.dup
|
|
2958
|
-
end
|
|
2959
|
-
|
|
2960
|
-
# @return [Array<Hash>] a copy of the current session's messages to hand
|
|
2961
|
-
# back to #rollback_to later
|
|
2962
|
-
def messages_checkpoint
|
|
2963
|
-
clone_messages(@session&.messages)
|
|
2964
|
-
end
|
|
2965
|
-
|
|
2966
|
-
# The running turn's conversation as its loop builds it (both loops
|
|
2967
|
-
# register it on the kernel's llm_context_turn): the same array the loop
|
|
2968
|
-
# appends to, not a copy, so read it on the turn's thread (the pending
|
|
2969
|
-
# input drain, at an iteration boundary: TurnCheckpoint), or from another
|
|
2970
|
-
# only as a dup, read only (/objectives). nil when no
|
|
2971
|
-
# turn runs (llm_context_turn is never cleared, and still holds the
|
|
2972
|
-
# last turn's then).
|
|
2973
|
-
# @return [Array<Hash>, nil]
|
|
2974
|
-
def live_conversation
|
|
2975
|
-
return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
|
|
2976
|
-
|
|
2977
|
-
@kernel.llm_context_turn&.conversation
|
|
2978
|
-
end
|
|
2979
|
-
|
|
2980
|
-
# Append messages to the current session's conversation.
|
|
2981
|
-
# @param messages [Array<Hash>]
|
|
2982
|
-
# @return [Array<Hash>] the session's messages
|
|
2983
|
-
def append_messages(messages)
|
|
2984
|
-
raise ArgumentError, "no current session" unless @session
|
|
2985
|
-
|
|
2986
|
-
replace_session_messages(@session, Array(@session.messages) + clone_messages(messages))
|
|
2987
|
-
end
|
|
2988
|
-
|
|
2989
|
-
# Restore the current session's conversation to a #messages_checkpoint.
|
|
2990
|
-
# Between turns the working set is announced when that changed it (a
|
|
2991
|
-
# !rollback or a canceled continue dropping the turn's tool calls, the
|
|
2992
|
-
# set emptied too), so the page doesn't keep the dropped items until
|
|
2993
|
-
# the next request.
|
|
2994
|
-
# @param checkpoint [Array<Hash>]
|
|
2995
|
-
# @return [Array<Hash>] the session's messages
|
|
2996
|
-
def rollback_to(checkpoint)
|
|
2997
|
-
raise ArgumentError, "no current session" unless @session
|
|
2998
|
-
|
|
2999
|
-
messages = replace_session_messages(@session, clone_messages(checkpoint))
|
|
3000
|
-
unless turn_running?
|
|
3001
|
-
announce_working_set
|
|
3002
|
-
announce_objectives
|
|
3003
|
-
end
|
|
3004
|
-
messages
|
|
3005
|
-
end
|
|
3006
|
-
|
|
3007
|
-
# Clone a messages array (shallow dup of each element).
|
|
3008
|
-
# @param messages [Array<Hash>]
|
|
3009
|
-
# @return [Array<Hash>]
|
|
3010
|
-
def clone_messages(messages)
|
|
3011
|
-
Array(messages).map(&:dup)
|
|
3012
|
-
end
|
|
3013
|
-
|
|
3014
1143
|
private
|
|
3015
1144
|
|
|
3016
1145
|
def running_command_key = :"samagotchi_command_#{object_id}"
|
|
@@ -3027,365 +1156,10 @@ module Samagotchi
|
|
|
3027
1156
|
session.messages = messages
|
|
3028
1157
|
end
|
|
3029
1158
|
|
|
3030
|
-
# Pick the loop for +model+ (EffectiveModel); the chat loop is pointed
|
|
3031
|
-
# at its host's adapter (one per host, kept for its cached model list).
|
|
3032
|
-
def backend_for(model)
|
|
3033
|
-
return @native_backend unless model.chat?
|
|
3034
|
-
|
|
3035
|
-
@chat_backend_mutex.synchronize do
|
|
3036
|
-
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
|
|
3037
|
-
@chat_backend.adapter = @host_registry.adapter_for(model.entry)
|
|
3038
|
-
@chat_backend.session_id = session&.id
|
|
3039
|
-
@chat_backend
|
|
3040
|
-
end
|
|
3041
|
-
end
|
|
3042
|
-
|
|
3043
|
-
def load_plugins
|
|
3044
|
-
host = plugin_host
|
|
3045
|
-
registries = Plugin::Registries.new(
|
|
3046
|
-
commands: @command_registry, tools: @tools, hooks: @hooks,
|
|
3047
|
-
context_for: lambda { |bundle, settings, label|
|
|
3048
|
-
Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host)
|
|
3049
|
-
},
|
|
3050
|
-
tools_changed: -> { tools_changed! },
|
|
3051
|
-
services: @services,
|
|
3052
|
-
stage_tools: ->(bundle, specs, context) { stage_tools(bundle, specs, context) },
|
|
3053
|
-
init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
|
|
3054
|
-
add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
|
|
3055
|
-
quiet: quiet, timeout: timeout, failed: failed, &block)
|
|
3056
|
-
},
|
|
3057
|
-
toolkits: @toolkit_binding.plugin_kits,
|
|
3058
|
-
tool_index: @tool_index,
|
|
3059
|
-
peer_sources: @peer_sources,
|
|
3060
|
-
prompt_sections: @prompt_sections,
|
|
3061
|
-
prompt_changed: -> { prompt_changed! },
|
|
3062
|
-
judgments: @judgments,
|
|
3063
|
-
hook_failed: ->(hook, failed) { @hook_routes.plugin_hook_failed(hook, failed) }
|
|
3064
|
-
)
|
|
3065
|
-
# What plugins show while they load (an MCP server that didn't
|
|
3066
|
-
# start) waits for the first turn, beside the load warnings: no UI
|
|
3067
|
-
# is there yet, and the Engine isn't built.
|
|
3068
|
-
@extension_load.load_plugins(registries, failures: @plugin_failures) do
|
|
3069
|
-
warn_undeclared_judgments
|
|
3070
|
-
warn_bundle_settings
|
|
3071
|
-
end
|
|
3072
|
-
end
|
|
3073
|
-
|
|
3074
|
-
# config.yml bundles: names and keys that do nothing (BundleSettingsCheck),
|
|
3075
|
-
# held like the judgments' line, for the same reason. Logged by every
|
|
3076
|
-
# Engine; shown by the first to find them (BundleSettingsCheck.unshown).
|
|
3077
|
-
def warn_bundle_settings
|
|
3078
|
-
lines = BundleSettingsCheck.warnings(@extension_load.bundle_settings)
|
|
3079
|
-
lines.each { |line| Log.warn(:config, "bundle_setting_unknown", msg: line) }
|
|
3080
|
-
BundleSettingsCheck.unshown(lines).each { |line| hook_notify(line, :warn, "config") }
|
|
3081
|
-
end
|
|
3082
|
-
private :warn_bundle_settings
|
|
3083
|
-
|
|
3084
|
-
# Held with the plugins' own notices, so every UI gets it: a worker's
|
|
3085
|
-
# stderr goes nowhere.
|
|
3086
|
-
def warn_undeclared_judgments
|
|
3087
|
-
@judgments.undeclared.each do |name|
|
|
3088
|
-
Log.warn(:config, "judgment_undeclared", judgment: name)
|
|
3089
|
-
hook_notify("system_one.judgments.#{name}: no loaded bundle declares it, so it does nothing", :warn, "config")
|
|
3090
|
-
end
|
|
3091
|
-
end
|
|
3092
|
-
private :warn_undeclared_judgments
|
|
3093
|
-
|
|
3094
|
-
# Keep a plugin's new tool set (chi.replace_tools, from any thread)
|
|
3095
|
-
# for the turn thread (PluginTasks#stage_tools).
|
|
3096
|
-
def stage_tools(bundle, specs, context)
|
|
3097
|
-
@plugin_tasks.stage_tools(bundle, specs, context)
|
|
3098
|
-
end
|
|
3099
|
-
private :stage_tools
|
|
3100
|
-
|
|
3101
|
-
# Apply the staged tool sets, on the turn thread, before the turn's
|
|
3102
|
-
# system prompt is built (PluginTasks#apply_staged_tools!). A changed
|
|
3103
|
-
# set may change the chi group the search tool's description counts
|
|
3104
|
-
# (a plugin tool the toolkit leaves out).
|
|
3105
|
-
# @return [Boolean] whether the tools changed
|
|
3106
|
-
def apply_staged_tools!
|
|
3107
|
-
changed = @plugin_tasks.apply_staged_tools!
|
|
3108
|
-
@tool_index.refresh! if changed
|
|
3109
|
-
changed
|
|
3110
|
-
end
|
|
3111
|
-
public :apply_staged_tools!
|
|
3112
|
-
|
|
3113
|
-
# The session's toolkit (ToolkitBinding): the kits, what its next turn
|
|
3114
|
-
# declares, ctx.use_toolkit and the turn-start apply.
|
|
3115
|
-
def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
|
|
3116
|
-
def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
|
|
3117
|
-
def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
|
|
3118
|
-
def toolkit_summary = @toolkit_binding.summary
|
|
3119
|
-
|
|
3120
|
-
# The tools this session registered (Tools::Registry), declared or not.
|
|
3121
|
-
attr_reader :tools
|
|
3122
|
-
|
|
3123
|
-
public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
|
|
3124
|
-
|
|
3125
|
-
# A plugin's ctx.load_tools: stage the reachable tools +names+ names
|
|
3126
|
-
# (Tools::ToolIndex#deferred_name) that the session neither declares
|
|
3127
|
-
# nor loaded with an earlier tool change, for bundle +bundle+. A name
|
|
3128
|
-
# that names no tool at all is logged; a declared or removed one is
|
|
3129
|
-
# skipped quietly.
|
|
3130
|
-
# @return [Array<String>] the wire names staged
|
|
3131
|
-
def load_tools(names, bundle)
|
|
3132
|
-
added = ToolLoads.added(@session&.messages)
|
|
3133
|
-
wanted = names.filter_map do |name|
|
|
3134
|
-
wire = @tool_index.deferred_name(name)
|
|
3135
|
-
Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
|
|
3136
|
-
wire
|
|
3137
|
-
end
|
|
3138
|
-
@tool_loads.stage(wanted - added, by: bundle)
|
|
3139
|
-
end
|
|
3140
|
-
public :load_tools
|
|
3141
|
-
|
|
3142
|
-
# The staged tool loads, appended to +turn+'s messages (after its user
|
|
3143
|
-
# message, or the trailing results and notes of a turn with none) as
|
|
3144
|
-
# one ToolLoads entry per bundle, when the turn's host takes tool
|
|
3145
|
-
# changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
|
|
3146
|
-
# A conversation that ends with a reply keeps them staged (a system
|
|
3147
|
-
# change must follow a user turn).
|
|
3148
|
-
def append_tool_loads(turn)
|
|
3149
|
-
return unless @tool_loads.any?
|
|
3150
|
-
return if turn.messages.last&.dig(:role).to_s == "model"
|
|
3151
|
-
|
|
3152
|
-
model = effective_model
|
|
3153
|
-
host = backend(model)
|
|
3154
|
-
staged = @tool_loads.take
|
|
3155
|
-
added = ToolLoads.added(turn.messages)
|
|
3156
|
-
unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
|
|
3157
|
-
Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
|
|
3158
|
-
return
|
|
3159
|
-
end
|
|
3160
|
-
|
|
3161
|
-
staged.each do |bundle, names|
|
|
3162
|
-
names -= added
|
|
3163
|
-
next if names.empty?
|
|
3164
|
-
|
|
3165
|
-
turn.messages << ToolLoads.entry(names, by: bundle)
|
|
3166
|
-
hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
|
|
3167
|
-
end
|
|
3168
|
-
end
|
|
3169
|
-
private :append_tool_loads
|
|
3170
|
-
|
|
3171
|
-
# A plugin's ctx.use_toolkit (ToolkitBinding#use).
|
|
3172
|
-
# @return [Boolean] whether it was staged
|
|
3173
|
-
# @raise [ArgumentError] an unknown toolkit
|
|
3174
|
-
def use_toolkit(name) = @toolkit_binding.use(name)
|
|
3175
|
-
public :use_toolkit
|
|
3176
|
-
|
|
3177
|
-
# At a turn's start (ToolkitBinding#apply!).
|
|
3178
|
-
# @return [Boolean] whether the declared tools changed
|
|
3179
|
-
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
3180
|
-
private :apply_toolkit!
|
|
3181
|
-
|
|
3182
|
-
# Start the toolkit classifier after :turn_started, for audit and on
|
|
3183
|
-
# mode alike (one eligibility, one snapshot, one thread). All session
|
|
3184
|
-
# and tool-registry reads happen here; the thread receives only this
|
|
3185
|
-
# snapshot. Audit never blocks: the thread logs its line. On mode
|
|
3186
|
-
# returns the ask's Pending, which the turn joins before apply_toolkit!
|
|
3187
|
-
# (#settle_toolkit_suggestion), with the turn's controller as the
|
|
3188
|
-
# request's cancel controller.
|
|
3189
|
-
# @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
|
|
3190
|
-
def start_toolkit_suggestion(turn)
|
|
3191
|
-
mode = Config.get("toolkit.suggest.mode").to_s
|
|
3192
|
-
return if mode == "off"
|
|
3193
|
-
|
|
3194
|
-
joined = mode == "on"
|
|
3195
|
-
session = turn.session
|
|
3196
|
-
default = Toolkit.of(session.toolkit)
|
|
3197
|
-
log = toolkit_suggestion_log
|
|
3198
|
-
# The predicate checks the session first; the kits (memory kits are
|
|
3199
|
-
# read from disk) and the log (read whole) only when it gets there.
|
|
3200
|
-
kits = nil
|
|
3201
|
-
candidates = nil
|
|
3202
|
-
return unless ToolkitSuggest.eligible?(
|
|
3203
|
-
session: session, prompt: turn.prompt, author: turn.origin.author,
|
|
3204
|
-
candidate_count: lambda {
|
|
3205
|
-
kits = @toolkit_binding.kits
|
|
3206
|
-
candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
|
|
3207
|
-
candidates.size
|
|
3208
|
-
},
|
|
3209
|
-
last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
|
|
3210
|
-
defer_turns: Config.get("toolkit.suggest.defer_turns"),
|
|
3211
|
-
defer_tokens: Config.get("toolkit.suggest.defer_tokens")
|
|
3212
|
-
)
|
|
3213
|
-
|
|
3214
|
-
# One ask at a time: an eligible turn while the last one still runs
|
|
3215
|
-
# goes unasked, and says so.
|
|
3216
|
-
if @side_judgments.in_flight?(:toolkit)
|
|
3217
|
-
Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
|
|
3218
|
-
return
|
|
3219
|
-
end
|
|
3220
|
-
|
|
3221
|
-
client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
|
|
3222
|
-
ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
|
|
3223
|
-
return unless client
|
|
3224
|
-
|
|
3225
|
-
# The warm-up's only value is the cache: the real ask never queues
|
|
3226
|
-
# behind it.
|
|
3227
|
-
stop_toolkit_warmup
|
|
3228
|
-
|
|
3229
|
-
# Rules, not tool lists: this runs before await_init_tasks and
|
|
3230
|
-
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
3231
|
-
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
3232
|
-
snapshots = candidates.map do |kit|
|
|
3233
|
-
ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
|
|
3234
|
-
rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
|
|
3235
|
-
end.freeze
|
|
3236
|
-
default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
|
|
3237
|
-
rules: ToolkitSuggest.rules(default.resolve(kits)))
|
|
3238
|
-
registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
|
|
3239
|
-
core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
|
|
3240
|
-
state = ToolkitSuggest.question(
|
|
3241
|
-
default: default, kits: kits, project: File.basename(session.project_root.to_s),
|
|
3242
|
-
prompt: turn.prompt
|
|
3243
|
-
).last
|
|
3244
|
-
snapshot = ToolkitSuggest::Snapshot.new(
|
|
3245
|
-
session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
|
|
3246
|
-
default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
|
|
3247
|
-
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
3248
|
-
)
|
|
3249
|
-
threshold = Config.get("toolkit.suggest.threshold")
|
|
3250
|
-
runs = session.scratch ? nil : system_one_runs
|
|
3251
|
-
pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
|
|
3252
|
-
acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
|
|
3253
|
-
timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
|
|
3254
|
-
# On mode: cut by a Stop while the turn waits (Pending#wait), not
|
|
3255
|
-
# by one after it went on; audit's ask is never cut.
|
|
3256
|
-
controller = joined ? pending.cancel_controller : nil
|
|
3257
|
-
@side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
|
|
3258
|
-
ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
|
|
3259
|
-
cancel_controller: controller)
|
|
3260
|
-
end
|
|
3261
|
-
joined ? pending : nil
|
|
3262
|
-
rescue StandardError => e
|
|
3263
|
-
ToolkitSuggest.warn(e)
|
|
3264
|
-
nil
|
|
3265
|
-
end
|
|
3266
|
-
private :start_toolkit_suggestion
|
|
3267
|
-
|
|
3268
|
-
# On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
|
|
3269
|
-
# suggestion) up to System 1's timeout from its start, or until a Stop,
|
|
3270
|
-
# and put the session on a candidate picked with confidence ≥ threshold
|
|
3271
|
-
# (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
|
|
3272
|
-
# other outcome (the default, below threshold, undecided, no judgment,
|
|
3273
|
-
# a timeout, a Stop) leaves the session on its default. Nothing here
|
|
3274
|
-
# sends a request; prepare_turn's cancel check ends a stopped turn. A
|
|
3275
|
-
# Stop wins over an answer already in (Pending#wait checks it first).
|
|
3276
|
-
def settle_toolkit_suggestion(turn, pending)
|
|
3277
|
-
return if pending.nil?
|
|
3278
|
-
|
|
3279
|
-
outcome, judged = pending.wait(turn.controller)
|
|
3280
|
-
if outcome == :timeout
|
|
3281
|
-
# The session starts on its default without a word otherwise: a
|
|
3282
|
-
# slow System 1 host (a local one) is the usual cause.
|
|
3283
|
-
timeout_ms = SystemOne::Client.configured_timeout_ms
|
|
3284
|
-
Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
|
|
3285
|
-
reason: "System 1 gave no answer within system_one.timeout_ms")
|
|
3286
|
-
@toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
|
|
3287
|
-
return
|
|
3288
|
-
end
|
|
3289
|
-
unless outcome == :answered
|
|
3290
|
-
Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
|
|
3291
|
-
return
|
|
3292
|
-
end
|
|
3293
|
-
return if judged.nil?
|
|
3294
|
-
|
|
3295
|
-
pending.record(toolkit_suggestion_outcome(turn.session, judged))
|
|
3296
|
-
rescue StandardError => e
|
|
3297
|
-
ToolkitSuggest.warn(e)
|
|
3298
|
-
pending&.record("failed")
|
|
3299
|
-
end
|
|
3300
|
-
private :settle_toolkit_suggestion
|
|
3301
|
-
|
|
3302
|
-
# What the turn does with an answered ask, as the log line's did:
|
|
3303
|
-
# suggested (switched), chosen (the toolkit isn't the default any
|
|
3304
|
-
# more), unavailable (the kit is gone), defer or default.
|
|
3305
|
-
def toolkit_suggestion_outcome(session, judged)
|
|
3306
|
-
case judged.would
|
|
3307
|
-
when "suggested"
|
|
3308
|
-
return "chosen" unless session.toolkit_origin == "default"
|
|
3309
|
-
return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
|
|
3310
|
-
|
|
3311
|
-
switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
|
|
3312
|
-
deferred: ToolkitSuggest.model_reply?(session.messages))
|
|
3313
|
-
switched ? "suggested" : "chosen"
|
|
3314
|
-
when "defer" then "defer"
|
|
3315
|
-
when "no_judgment"
|
|
3316
|
-
# System 1 failed (its own warn) or answered nothing usable.
|
|
3317
|
-
@toolkit_binding.fell_back(session, "System 1 gave no answer")
|
|
3318
|
-
"default"
|
|
3319
|
-
else "default"
|
|
3320
|
-
end
|
|
3321
|
-
end
|
|
3322
|
-
private :toolkit_suggestion_outcome
|
|
3323
|
-
|
|
3324
|
-
# On mode, as an idle worker starts (Worker#start_background): one ask
|
|
3325
|
-
# with the real question and a stand-in prompt, on its own thread,
|
|
3326
|
-
# never logged and never waited for, so System 1's server has the
|
|
3327
|
-
# question's prefix cached for the first prompt. Only for a session a
|
|
3328
|
-
# first prompt could be asked for: origin default, no model reply, not
|
|
3329
|
-
# a delegate child or a review session, at least two candidates; and
|
|
3330
|
-
# not when the worker starts on a first prompt (that ask comes at once).
|
|
3331
|
-
def warm_toolkit_suggestion!
|
|
3332
|
-
return unless Config.get("toolkit.suggest.mode").to_s == "on"
|
|
3333
|
-
|
|
3334
|
-
session = @session
|
|
3335
|
-
return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
|
|
3336
|
-
return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
|
|
3337
|
-
|
|
3338
|
-
default = Toolkit.of(session.toolkit)
|
|
3339
|
-
kits = @toolkit_binding.kits
|
|
3340
|
-
return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
|
|
3341
|
-
|
|
3342
|
-
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
3343
|
-
return unless client
|
|
3344
|
-
|
|
3345
|
-
project = File.basename(session.project_root.to_s)
|
|
3346
|
-
@toolkit_warmup_thread = Thread.new do
|
|
3347
|
-
ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
|
|
3348
|
-
end
|
|
3349
|
-
rescue StandardError => e
|
|
3350
|
-
ToolkitSuggest.warn(e)
|
|
3351
|
-
nil
|
|
3352
|
-
end
|
|
3353
|
-
public :warm_toolkit_suggestion!
|
|
3354
|
-
|
|
3355
|
-
# A still-running warm-up, stopped: a real ask (or the worker leaving)
|
|
3356
|
-
# doesn't wait behind it.
|
|
3357
|
-
def stop_toolkit_warmup
|
|
3358
|
-
@toolkit_warmup_thread&.kill
|
|
3359
|
-
@toolkit_warmup_thread = nil
|
|
3360
|
-
end
|
|
3361
|
-
private :stop_toolkit_warmup
|
|
3362
|
-
|
|
3363
|
-
def toolkit_suggestion_log
|
|
3364
|
-
SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
|
|
3365
|
-
end
|
|
3366
|
-
private :toolkit_suggestion_log
|
|
3367
|
-
|
|
3368
|
-
# +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
|
|
3369
|
-
# going wrong here leaves the client as it was: a run is never worth a
|
|
3370
|
-
# judgment.
|
|
3371
|
-
#
|
|
3372
|
-
# A scratch session's asks aren't saved; a participant's turn is never
|
|
3373
|
-
# judged, so it never comes here.
|
|
3374
|
-
# @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
|
|
3375
|
-
def system_one_recording(client, judgment, session, mode: nil)
|
|
3376
|
-
return client if client.nil? || session&.scratch
|
|
3377
|
-
|
|
3378
|
-
context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
|
|
3379
|
-
SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
|
|
3380
|
-
rescue StandardError => e
|
|
3381
|
-
Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3382
|
-
client
|
|
3383
|
-
end
|
|
3384
|
-
private :system_one_recording
|
|
3385
|
-
|
|
3386
1159
|
# Bundles' judgments (chi.judgment), on this Engine's Runs and side asks.
|
|
3387
1160
|
# A judgment's own host and model are for bundles only: core's clients
|
|
3388
|
-
#
|
|
1161
|
+
# (#system_one_recording's, engine/side_requests.rb) never pass them (D2,
|
|
1162
|
+
# JudgmentSettings).
|
|
3389
1163
|
def build_judgments
|
|
3390
1164
|
SystemOne::Judgments.new(
|
|
3391
1165
|
client_for: lambda { |_name, settings|
|
|
@@ -3400,165 +1174,6 @@ module Samagotchi
|
|
|
3400
1174
|
end
|
|
3401
1175
|
private :build_judgments
|
|
3402
1176
|
|
|
3403
|
-
# Where this Engine's runs are saved; read each time, as the settings are.
|
|
3404
|
-
def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
|
|
3405
|
-
private :system_one_runs
|
|
3406
|
-
|
|
3407
|
-
# The candidate judge (ContextJudge) at an owner turn's start, after
|
|
3408
|
-
# the toolkit's ask settled: the session's candidates' status changes
|
|
3409
|
-
# are logged (ContextCandidatesLog#look) and, unless
|
|
3410
|
-
# context.candidates.mode is off, the proposed candidates due an ask
|
|
3411
|
-
# are asked about the owner's prompts so far, this one included. Never
|
|
3412
|
-
# a participant's turn, a review session or a Continue (no new prompt).
|
|
3413
|
-
def start_context_judge(turn)
|
|
3414
|
-
session = turn.session
|
|
3415
|
-
return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
|
|
3416
|
-
|
|
3417
|
-
context_judge_pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
|
|
3418
|
-
end
|
|
3419
|
-
private :start_context_judge
|
|
3420
|
-
|
|
3421
|
-
# System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
|
|
3422
|
-
# only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
|
|
3423
|
-
def audit_objectives_incoming(turn)
|
|
3424
|
-
return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
|
|
3425
|
-
|
|
3426
|
-
objectives_audit.incoming(turn.session, turn.prompt)
|
|
3427
|
-
end
|
|
3428
|
-
private :audit_objectives_incoming
|
|
3429
|
-
|
|
3430
|
-
def audit_objectives_outgoing(turn, result)
|
|
3431
|
-
return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
|
|
3432
|
-
|
|
3433
|
-
objectives_audit.outgoing(turn.session, result.text)
|
|
3434
|
-
end
|
|
3435
|
-
private :audit_objectives_outgoing
|
|
3436
|
-
|
|
3437
|
-
# With saving off nothing would come of an ask, so none is made.
|
|
3438
|
-
def objectives_audit
|
|
3439
|
-
@objectives_audit ||= ObjectivesAudit.new(
|
|
3440
|
-
side: @side_judgments, state_dir: -> { session_state_dir },
|
|
3441
|
-
client_for: lambda do |judgment, session|
|
|
3442
|
-
next nil unless system_one_runs.on?
|
|
3443
|
-
|
|
3444
|
-
system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
|
|
3445
|
-
judgment, session)
|
|
3446
|
-
end
|
|
3447
|
-
)
|
|
3448
|
-
end
|
|
3449
|
-
private :objectives_audit
|
|
3450
|
-
|
|
3451
|
-
# A late proposal no owner prompt mentions (Plugin::Host
|
|
3452
|
-
# #candidate_proposed: github-pr's init task runs beside the first
|
|
3453
|
-
# turn): judged on the owner's prompts so far, the running turn's too.
|
|
3454
|
-
def judge_late_candidates
|
|
3455
|
-
session = @session
|
|
3456
|
-
return if session.nil? || session.review?
|
|
3457
|
-
|
|
3458
|
-
texts = ContextCandidates.owner_texts(plugin_messages)
|
|
3459
|
-
context_judge_pass(session, texts, phase: "late") unless texts.empty?
|
|
3460
|
-
end
|
|
3461
|
-
private :judge_late_candidates
|
|
3462
|
-
|
|
3463
|
-
# One look at +session+'s candidates; the asks go to SideJudgments'
|
|
3464
|
-
# :context kind, one thread asking each due candidate in turn.
|
|
3465
|
-
def context_judge_pass(session, texts, phase:)
|
|
3466
|
-
return if session.id.nil? || session.scratch
|
|
3467
|
-
|
|
3468
|
-
rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: session_state_dir)
|
|
3469
|
-
return if rows.empty?
|
|
3470
|
-
|
|
3471
|
-
log = ContextCandidatesLog.log_for(session_state_dir)
|
|
3472
|
-
records = ContextCandidatesLog.records_for(log, session.id)
|
|
3473
|
-
ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
|
|
3474
|
-
settings = ContextJudge::Settings.from_config
|
|
3475
|
-
return if settings.mode == "off"
|
|
3476
|
-
|
|
3477
|
-
jobs = ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings, phase: phase,
|
|
3478
|
-
parent_attached: context_parent_attached(session))
|
|
3479
|
-
return if jobs.empty?
|
|
3480
|
-
|
|
3481
|
-
client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
|
|
3482
|
-
ContextJudge::JUDGMENT, session, mode: settings.mode)
|
|
3483
|
-
return unless client
|
|
3484
|
-
|
|
3485
|
-
start_context_asks(client, jobs, log, settings, context_choice(session, log, settings))
|
|
3486
|
-
rescue StandardError => e
|
|
3487
|
-
ContextJudge.warn(e)
|
|
3488
|
-
end
|
|
3489
|
-
private :context_judge_pass
|
|
3490
|
-
|
|
3491
|
-
# @return [Thread, nil] the asks' thread, nil when none started
|
|
3492
|
-
def start_context_asks(client, jobs, log, settings, choice)
|
|
3493
|
-
if @side_judgments.in_flight?(:context)
|
|
3494
|
-
Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
|
|
3495
|
-
return nil
|
|
3496
|
-
end
|
|
3497
|
-
|
|
3498
|
-
@side_judgments.start(:context, warn: ContextJudge.method(:warn)) do
|
|
3499
|
-
jobs.each { |job| choice.call(job, ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
|
|
3500
|
-
end
|
|
3501
|
-
end
|
|
3502
|
-
private :start_context_asks
|
|
3503
|
-
|
|
3504
|
-
# What an answer does on the asks' thread (ContextCandidateChoice
|
|
3505
|
-
# #after_judged: on mode attaches or cards; audit nothing). Taken on
|
|
3506
|
-
# the caller's thread. The attach only writes the session's folder: its
|
|
3507
|
-
# note comes with the next between-turn delivery, never into a running
|
|
3508
|
-
# turn. A card shows only while this Engine is still on that session,
|
|
3509
|
-
# and not in a running turn (it isn't the turn's: no "waiting for you"
|
|
3510
|
-
# on it), but once it has ended.
|
|
3511
|
-
# @return [#call] (job, fields)
|
|
3512
|
-
def context_choice(session, log, settings)
|
|
3513
|
-
session_id = session.id
|
|
3514
|
-
project_root = session.project_root
|
|
3515
|
-
state_dir = session_state_dir
|
|
3516
|
-
show = lambda do |**card|
|
|
3517
|
-
current = @session
|
|
3518
|
-
next nil unless current.nil? || current.id == session_id
|
|
3519
|
-
|
|
3520
|
-
@context_cards_mutex.synchronize do
|
|
3521
|
-
next @context_cards_after_turn << card if turn_running?
|
|
3522
|
-
|
|
3523
|
-
show_card(**card)
|
|
3524
|
-
end
|
|
3525
|
-
end
|
|
3526
|
-
lambda do |job, fields|
|
|
3527
|
-
ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
|
|
3528
|
-
state_dir: state_dir, log: log, show_card: show)
|
|
3529
|
-
end
|
|
3530
|
-
end
|
|
3531
|
-
private :context_choice
|
|
3532
|
-
|
|
3533
|
-
# The cards #context_choice held while a turn ran (#run_turn's ensure,
|
|
3534
|
-
# after the turn flag is released).
|
|
3535
|
-
def show_context_cards_after_turn
|
|
3536
|
-
cards = @context_cards_mutex.synchronize { @context_cards_after_turn.slice!(0..) }
|
|
3537
|
-
cards.each { |card| show_card(**card) }
|
|
3538
|
-
rescue StandardError => e
|
|
3539
|
-
ContextJudge.warn(e)
|
|
3540
|
-
end
|
|
3541
|
-
private :show_context_cards_after_turn
|
|
3542
|
-
|
|
3543
|
-
# A delegate child's parent's sources, by title (else name): the
|
|
3544
|
-
# judge's started_from. nil for any other session (a chi send --new
|
|
3545
|
-
# session has no parent; a fork isn't a delegate).
|
|
3546
|
-
def context_parent_attached(session)
|
|
3547
|
-
return nil unless session.delegate? && session.parent_id
|
|
3548
|
-
|
|
3549
|
-
parent_root = if Session.exist?(session.parent_id, state_dir: session_state_dir)
|
|
3550
|
-
Session.load(session.parent_id, state_dir: session_state_dir).project_root
|
|
3551
|
-
else
|
|
3552
|
-
session.project_root
|
|
3553
|
-
end
|
|
3554
|
-
ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: session_state_dir)
|
|
3555
|
-
.map { |attached| attached.source.title || attached.name }
|
|
3556
|
-
rescue StandardError => e
|
|
3557
|
-
Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3558
|
-
[]
|
|
3559
|
-
end
|
|
3560
|
-
private :context_parent_attached
|
|
3561
|
-
|
|
3562
1177
|
# The session's toolkit (see #initialize for when).
|
|
3563
1178
|
def build_toolkit_binding
|
|
3564
1179
|
ToolkitBinding.new(
|
|
@@ -3574,160 +1189,6 @@ module Samagotchi
|
|
|
3574
1189
|
end
|
|
3575
1190
|
private :build_toolkit_binding
|
|
3576
1191
|
|
|
3577
|
-
# The tools changed (a plugin's chi.tools_changed!): the system prompts,
|
|
3578
|
-
# which declare them, are built again on the next turn.
|
|
3579
|
-
def tools_changed!
|
|
3580
|
-
prompt_changed!
|
|
3581
|
-
end
|
|
3582
|
-
|
|
3583
|
-
# A plugin's prompt section would give other text now
|
|
3584
|
-
# (chi.prompt_changed!): the system prompts are built again at their
|
|
3585
|
-
# next build (the next turn's start; a running turn keeps the prompt it
|
|
3586
|
-
# started with). One cache restart: the plugin's command asked first
|
|
3587
|
-
# (Plugin::Context#cache_confirm).
|
|
3588
|
-
def prompt_changed!
|
|
3589
|
-
@prompt_builder&.reset!
|
|
3590
|
-
end
|
|
3591
|
-
public :prompt_changed!
|
|
3592
|
-
|
|
3593
|
-
# What a Plugin::Context reads and calls: the session now, and the
|
|
3594
|
-
# hook runtime's notify and ask_user.
|
|
3595
|
-
def plugin_host
|
|
3596
|
-
Plugin::Host.new(
|
|
3597
|
-
session_id: -> { @session&.id },
|
|
3598
|
-
cwd: -> { @session&.working_directory },
|
|
3599
|
-
messages: -> { plugin_messages },
|
|
3600
|
-
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
3601
|
-
turn_running: -> { turn_running? },
|
|
3602
|
-
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
3603
|
-
ask_user: ->(question) { hook_ask_user(question) },
|
|
3604
|
-
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
3605
|
-
cancelled: lambda {
|
|
3606
|
-
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|
|
3607
|
-
},
|
|
3608
|
-
card: ->(**card) { show_card(**card) },
|
|
3609
|
-
steer: ->(text, label) { steer(text, source: hook_source(label)) },
|
|
3610
|
-
stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
|
|
3611
|
-
stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
|
|
3612
|
-
toolkit: -> { @toolkit_binding.name },
|
|
3613
|
-
declared_tools: -> { toolkit_explained.declared.map(&:name) },
|
|
3614
|
-
use_toolkit: ->(name) { use_toolkit(name) },
|
|
3615
|
-
load_tools: ->(names, bundle) { load_tools(names, bundle) },
|
|
3616
|
-
cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
|
|
3617
|
-
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
|
|
3618
|
-
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
|
|
3619
|
-
model: model, source: source)
|
|
3620
|
-
},
|
|
3621
|
-
model_name: -> { effective_model_ref },
|
|
3622
|
-
model_key: -> { @model_binding.model_key },
|
|
3623
|
-
# The names a models: map looks the model up under; [] when it doesn't resolve.
|
|
3624
|
-
model_names: lambda do
|
|
3625
|
-
effective_model.names
|
|
3626
|
-
rescue StandardError
|
|
3627
|
-
[]
|
|
3628
|
-
end,
|
|
3629
|
-
state_dir: -> { session_state_dir },
|
|
3630
|
-
scratch: -> { @scratch },
|
|
3631
|
-
setup: -> { SessionSetup.of(@session) },
|
|
3632
|
-
# The files whose text the built system prompt holds; [] until a
|
|
3633
|
-
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
3634
|
-
# returns [] itself: the || keeps a nil builder safe).
|
|
3635
|
-
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
3636
|
-
frontend: -> { interface },
|
|
3637
|
-
cost: -> { @metrics.cost },
|
|
3638
|
-
usage: -> { @metrics.usage },
|
|
3639
|
-
turn_author: -> { turn_author },
|
|
3640
|
-
# A plugin's prompt section is in the stable part: a full re-read.
|
|
3641
|
-
cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) },
|
|
3642
|
-
candidate_proposed: ->(_candidate) { judge_late_candidates },
|
|
3643
|
-
judgments: -> { @judgments }
|
|
3644
|
-
)
|
|
3645
|
-
end
|
|
3646
|
-
|
|
3647
|
-
# The controller a plugin's ctx.http call without cancel: stops with
|
|
3648
|
-
# (docs/plugins.md): an init task's own, else the running turn's for the
|
|
3649
|
-
# turn's own work until the turn has ended (TurnState#own_controller); nil
|
|
3650
|
-
# for an anytime command, a service or a thread of the plugin's.
|
|
3651
|
-
def plugin_cancel_controller(handler:)
|
|
3652
|
-
(task = current_init_task) ? task.cancel : @turn_state.own_controller(handler: handler)
|
|
3653
|
-
end
|
|
3654
|
-
private :plugin_cancel_controller
|
|
3655
|
-
|
|
3656
|
-
# A plugin's ctx.messages: the conversation without the system prompt
|
|
3657
|
-
# (the REPL's starts with it, a worker's new session doesn't), and,
|
|
3658
|
-
# while a turn runs, the turn so far from the Bridge (plan O1). Read
|
|
3659
|
-
# with the event log held, so a turn is in exactly one of the two.
|
|
3660
|
-
def plugin_messages
|
|
3661
|
-
synchronize_events do
|
|
3662
|
-
messages = AnswerDisplay.strip_all(messages_checkpoint)
|
|
3663
|
-
first = messages.first
|
|
3664
|
-
messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
|
|
3665
|
-
running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
|
|
3666
|
-
messages + running
|
|
3667
|
-
end
|
|
3668
|
-
end
|
|
3669
|
-
private :plugin_messages
|
|
3670
|
-
|
|
3671
|
-
# ctx.ask_model's request: the session's current model on its host, as
|
|
3672
|
-
# a turn resolves them (a /model switch counts), or a configured +model+
|
|
3673
|
-
# (a host:id ref, an alias, a bare id one of a host's models), through its own
|
|
3674
|
-
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
3675
|
-
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
3676
|
-
# refused thinking-off, holds for the next request); the session's id
|
|
3677
|
-
# goes with the request. Its usage counts in the session's side line
|
|
3678
|
-
# (#note_side_usage) under +source+, the asking bundle's name.
|
|
3679
|
-
# @return [String] the answer
|
|
3680
|
-
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
|
|
3681
|
-
target = model.nil? ? session_model_recap_target : configured_side_target(model)
|
|
3682
|
-
client = side_model_client(target, timeout)
|
|
3683
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
3684
|
-
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
|
|
3685
|
-
kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
|
|
3686
|
-
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
3687
|
-
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
3688
|
-
answer.text
|
|
3689
|
-
end
|
|
3690
|
-
private :ask_side_model
|
|
3691
|
-
|
|
3692
|
-
# The target of ctx.ask_model(model:): +model+ as a turn resolves it,
|
|
3693
|
-
# once it is known to name something configured. IdleTarget.of_model
|
|
3694
|
-
# never raises (an unknown host falls to the default host, an unknown id
|
|
3695
|
-
# to the default entry), so a typo would silently ask another model.
|
|
3696
|
-
# @raise [ArgumentError] +model+ names no configured host and is no
|
|
3697
|
-
# host's model (cached, declared or saved lists; nothing is asked)
|
|
3698
|
-
def configured_side_target(model)
|
|
3699
|
-
ref = @host_registry.model_ref(model)
|
|
3700
|
-
unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
|
|
3701
|
-
raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
|
|
3702
|
-
"or a model id one of your hosts lists)"
|
|
3703
|
-
end
|
|
3704
|
-
|
|
3705
|
-
IdleTarget.of_model(@host_registry, model)
|
|
3706
|
-
end
|
|
3707
|
-
private :configured_side_target
|
|
3708
|
-
|
|
3709
|
-
# The IdleClient for +target+ at +timeout+: the last one when both are
|
|
3710
|
-
# the same, else a new one.
|
|
3711
|
-
def side_model_client(target, timeout)
|
|
3712
|
-
@side_client_mutex.synchronize do
|
|
3713
|
-
key = [target, timeout]
|
|
3714
|
-
unless @side_client && @side_client_key == key
|
|
3715
|
-
@side_client = IdleClient.for(target, timeout: timeout)
|
|
3716
|
-
@side_client_key = key
|
|
3717
|
-
end
|
|
3718
|
-
@side_client
|
|
3719
|
-
end
|
|
3720
|
-
end
|
|
3721
|
-
private :side_model_client
|
|
3722
|
-
|
|
3723
|
-
# The session's current model as a recap target: its host's OpenAI API
|
|
3724
|
-
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
3725
|
-
# and bare model name, as a turn resolves them.
|
|
3726
|
-
# @return [IdleTarget]
|
|
3727
|
-
def session_model_recap_target
|
|
3728
|
-
IdleTarget.of_model(@host_registry, effective_model_name)
|
|
3729
|
-
end
|
|
3730
|
-
|
|
3731
1192
|
# Build the idle reminders job. Always created (reminders are opt-in
|
|
3732
1193
|
# via the agent calling register_reminder). The shared IdleScheduler
|
|
3733
1194
|
# polls it and triggers synthetic turns when reminders are due.
|
|
@@ -3743,50 +1204,5 @@ module Samagotchi
|
|
|
3743
1204
|
callback: @auto_turn_callback
|
|
3744
1205
|
)
|
|
3745
1206
|
end
|
|
3746
|
-
|
|
3747
|
-
# ── Event helpers ──────────────────────────────────────────────────────────
|
|
3748
|
-
|
|
3749
|
-
# The stream handler, the working set and the event fan-out are
|
|
3750
|
-
# EventFanout's (#build_event_fanout); these forward to it.
|
|
3751
|
-
def build_stream_event_handler(...) = @event_fanout.build_stream_event_handler(...)
|
|
3752
|
-
def emit_working_set(...) = @event_fanout.emit_working_set(...)
|
|
3753
|
-
private :emit_working_set
|
|
3754
|
-
def working_set_llm_context = @event_fanout.working_set_llm_context
|
|
3755
|
-
private :working_set_llm_context
|
|
3756
|
-
def with_generation_report(event) = @event_fanout.with_generation_report(event)
|
|
3757
|
-
private :with_generation_report
|
|
3758
|
-
def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
|
|
3759
|
-
private :stream_watch
|
|
3760
|
-
def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
|
|
3761
|
-
|
|
3762
|
-
# The effective model's window_tokens and price, read per turn
|
|
3763
|
-
# (SessionModelSettings).
|
|
3764
|
-
def turn_window_setting = @model_settings.turn_window_setting
|
|
3765
|
-
def turn_price = @model_settings.turn_price
|
|
3766
|
-
|
|
3767
|
-
# The snapshots' helpers are EngineSnapshots' (#build_engine_snapshots);
|
|
3768
|
-
# these forward to it.
|
|
3769
|
-
def served_model_for(...) = @engine_snapshots.served_model_for(...)
|
|
3770
|
-
def kit_prompt_stats = @engine_snapshots.kit_prompt_stats
|
|
3771
|
-
|
|
3772
|
-
# The window as +model+'s loop would see it (EffectiveModel#context_window).
|
|
3773
|
-
def current_context_window(model) = model.context_window
|
|
3774
|
-
|
|
3775
|
-
# +settings+' chips (EngineSnapshots#setting_chips).
|
|
3776
|
-
def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
|
|
3777
|
-
|
|
3778
|
-
# The effective model now, resolved on each call, never kept
|
|
3779
|
-
# (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
|
|
3780
|
-
# @return [EffectiveModel]
|
|
3781
|
-
def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
|
|
3782
|
-
|
|
3783
|
-
# ── Prompt profile ─────────────────────────────────────────────────────────
|
|
3784
|
-
|
|
3785
|
-
# The profile's resolve, keep, apply and retry are ModelBinding's
|
|
3786
|
-
# (#build_model_binding); these forward to it.
|
|
3787
|
-
def resolve_profile(choice) = @model_binding.resolve_profile(choice)
|
|
3788
|
-
def store_profile(choice, resolution) = @model_binding.store_profile(choice, resolution)
|
|
3789
|
-
def apply_profile(resolution) = @model_binding.apply_profile(resolution)
|
|
3790
|
-
def refresh_profile! = @model_binding.refresh_profile!
|
|
3791
1207
|
end
|
|
3792
1208
|
end
|