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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# The session's toolkit: the kits and what a turn declares
|
|
6
|
+
# (ToolkitBinding, forwarded), ctx.load_tools and the staged loads a turn
|
|
7
|
+
# appends, and System 1's toolkit suggestion (start, settle, warm-up).
|
|
8
|
+
# Writes @toolkit_warmup_thread; reads @toolkit_binding, @tools,
|
|
9
|
+
# @tool_index, @tool_loads, @side_judgments, @session.
|
|
10
|
+
module ToolkitPart
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# The session's toolkit (ToolkitBinding): the kits, what its next turn
|
|
14
|
+
# declares, ctx.use_toolkit and the turn-start apply.
|
|
15
|
+
def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
|
|
16
|
+
def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
|
|
17
|
+
def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
|
|
18
|
+
def toolkit_summary = @toolkit_binding.summary
|
|
19
|
+
|
|
20
|
+
# The tools this session registered (Tools::Registry), declared or not.
|
|
21
|
+
attr_reader :tools
|
|
22
|
+
|
|
23
|
+
public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
|
|
24
|
+
|
|
25
|
+
# A plugin's ctx.load_tools: stage the reachable tools +names+ names
|
|
26
|
+
# (Tools::ToolIndex#deferred_name) that the session neither declares
|
|
27
|
+
# nor loaded with an earlier tool change, for bundle +bundle+. A name
|
|
28
|
+
# that names no tool at all is logged; a declared or removed one is
|
|
29
|
+
# skipped quietly.
|
|
30
|
+
# @return [Array<String>] the wire names staged
|
|
31
|
+
def load_tools(names, bundle)
|
|
32
|
+
added = ToolLoads.added(@session&.messages)
|
|
33
|
+
wanted = names.filter_map do |name|
|
|
34
|
+
wire = @tool_index.deferred_name(name)
|
|
35
|
+
Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
|
|
36
|
+
wire
|
|
37
|
+
end
|
|
38
|
+
@tool_loads.stage(wanted - added, by: bundle)
|
|
39
|
+
end
|
|
40
|
+
public :load_tools
|
|
41
|
+
|
|
42
|
+
# The staged tool loads, appended to +turn+'s messages (after its user
|
|
43
|
+
# message, or the trailing results and notes of a turn with none) as
|
|
44
|
+
# one ToolLoads entry per bundle, when the turn's host takes tool
|
|
45
|
+
# changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
|
|
46
|
+
# A conversation that ends with a reply keeps them staged (a system
|
|
47
|
+
# change must follow a user turn).
|
|
48
|
+
def append_tool_loads(turn)
|
|
49
|
+
return unless @tool_loads.any?
|
|
50
|
+
return if turn.messages.last&.dig(:role).to_s == "model"
|
|
51
|
+
|
|
52
|
+
model = effective_model
|
|
53
|
+
host = backend(model)
|
|
54
|
+
staged = @tool_loads.take
|
|
55
|
+
added = ToolLoads.added(turn.messages)
|
|
56
|
+
unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
|
|
57
|
+
Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
staged.each do |bundle, names|
|
|
62
|
+
names -= added
|
|
63
|
+
next if names.empty?
|
|
64
|
+
|
|
65
|
+
turn.messages << ToolLoads.entry(names, by: bundle)
|
|
66
|
+
hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
private :append_tool_loads
|
|
70
|
+
|
|
71
|
+
# A plugin's ctx.use_toolkit (ToolkitBinding#use).
|
|
72
|
+
# @return [Boolean] whether it was staged
|
|
73
|
+
# @raise [ArgumentError] an unknown toolkit
|
|
74
|
+
def use_toolkit(name) = @toolkit_binding.use(name)
|
|
75
|
+
public :use_toolkit
|
|
76
|
+
|
|
77
|
+
# At a turn's start (ToolkitBinding#apply!).
|
|
78
|
+
# @return [Boolean] whether the declared tools changed
|
|
79
|
+
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
80
|
+
private :apply_toolkit!
|
|
81
|
+
|
|
82
|
+
# Start the toolkit classifier after :turn_started, for audit and on
|
|
83
|
+
# mode alike (one eligibility, one snapshot, one thread). All session
|
|
84
|
+
# and tool-registry reads happen here; the thread receives only this
|
|
85
|
+
# snapshot. Audit never blocks: the thread logs its line. On mode
|
|
86
|
+
# returns the ask's Pending, which the turn joins before apply_toolkit!
|
|
87
|
+
# (#settle_toolkit_suggestion), with the turn's controller as the
|
|
88
|
+
# request's cancel controller.
|
|
89
|
+
# @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
|
|
90
|
+
def start_toolkit_suggestion(turn)
|
|
91
|
+
mode = Config.get("toolkit.suggest.mode").to_s
|
|
92
|
+
return if mode == "off"
|
|
93
|
+
|
|
94
|
+
joined = mode == "on"
|
|
95
|
+
session = turn.session
|
|
96
|
+
default = Toolkit.of(session.toolkit)
|
|
97
|
+
log = toolkit_suggestion_log
|
|
98
|
+
# The predicate checks the session first; the kits (memory kits are
|
|
99
|
+
# read from disk) and the log (read whole) only when it gets there.
|
|
100
|
+
kits = nil
|
|
101
|
+
candidates = nil
|
|
102
|
+
return unless ToolkitSuggest.eligible?(
|
|
103
|
+
session: session, prompt: turn.prompt, author: turn.origin.author,
|
|
104
|
+
candidate_count: lambda {
|
|
105
|
+
kits = @toolkit_binding.kits
|
|
106
|
+
candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
|
|
107
|
+
candidates.size
|
|
108
|
+
},
|
|
109
|
+
last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
|
|
110
|
+
defer_turns: Config.get("toolkit.suggest.defer_turns"),
|
|
111
|
+
defer_tokens: Config.get("toolkit.suggest.defer_tokens")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# One ask at a time: an eligible turn while the last one still runs
|
|
115
|
+
# goes unasked, and says so.
|
|
116
|
+
if @side_judgments.in_flight?(:toolkit)
|
|
117
|
+
Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
|
|
122
|
+
ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
|
|
123
|
+
return unless client
|
|
124
|
+
|
|
125
|
+
# The warm-up's only value is the cache: the real ask never queues
|
|
126
|
+
# behind it.
|
|
127
|
+
stop_toolkit_warmup
|
|
128
|
+
|
|
129
|
+
# Rules, not tool lists: this runs before await_init_tasks and
|
|
130
|
+
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
131
|
+
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
132
|
+
snapshots = candidates.map do |kit|
|
|
133
|
+
ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
|
|
134
|
+
rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
|
|
135
|
+
end.freeze
|
|
136
|
+
default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
|
|
137
|
+
rules: ToolkitSuggest.rules(default.resolve(kits)))
|
|
138
|
+
registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
|
|
139
|
+
core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
|
|
140
|
+
state = ToolkitSuggest.question(
|
|
141
|
+
default: default, kits: kits, project: File.basename(session.project_root.to_s),
|
|
142
|
+
prompt: turn.prompt
|
|
143
|
+
).last
|
|
144
|
+
snapshot = ToolkitSuggest::Snapshot.new(
|
|
145
|
+
session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
|
|
146
|
+
default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
|
|
147
|
+
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
148
|
+
)
|
|
149
|
+
threshold = Config.get("toolkit.suggest.threshold")
|
|
150
|
+
runs = session.scratch ? nil : system_one_runs
|
|
151
|
+
pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
|
|
152
|
+
acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
|
|
153
|
+
timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
|
|
154
|
+
# On mode: cut by a Stop while the turn waits (Pending#wait), not
|
|
155
|
+
# by one after it went on; audit's ask is never cut.
|
|
156
|
+
controller = joined ? pending.cancel_controller : nil
|
|
157
|
+
@side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
|
|
158
|
+
ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
|
|
159
|
+
cancel_controller: controller)
|
|
160
|
+
end
|
|
161
|
+
joined ? pending : nil
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
ToolkitSuggest.warn(e)
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
private :start_toolkit_suggestion
|
|
167
|
+
|
|
168
|
+
# On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
|
|
169
|
+
# suggestion) up to System 1's timeout from its start, or until a Stop,
|
|
170
|
+
# and put the session on a candidate picked with confidence ≥ threshold
|
|
171
|
+
# (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
|
|
172
|
+
# other outcome (the default, below threshold, undecided, no judgment,
|
|
173
|
+
# a timeout, a Stop) leaves the session on its default. Nothing here
|
|
174
|
+
# sends a request; prepare_turn's cancel check ends a stopped turn. A
|
|
175
|
+
# Stop wins over an answer already in (Pending#wait checks it first).
|
|
176
|
+
def settle_toolkit_suggestion(turn, pending)
|
|
177
|
+
return if pending.nil?
|
|
178
|
+
|
|
179
|
+
outcome, judged = pending.wait(turn.controller)
|
|
180
|
+
if outcome == :timeout
|
|
181
|
+
# The session starts on its default without a word otherwise: a
|
|
182
|
+
# slow System 1 host (a local one) is the usual cause.
|
|
183
|
+
timeout_ms = SystemOne::Client.configured_timeout_ms
|
|
184
|
+
Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
|
|
185
|
+
reason: "System 1 gave no answer within system_one.timeout_ms")
|
|
186
|
+
@toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
unless outcome == :answered
|
|
190
|
+
Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
return if judged.nil?
|
|
194
|
+
|
|
195
|
+
pending.record(toolkit_suggestion_outcome(turn.session, judged))
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
ToolkitSuggest.warn(e)
|
|
198
|
+
pending&.record("failed")
|
|
199
|
+
end
|
|
200
|
+
private :settle_toolkit_suggestion
|
|
201
|
+
|
|
202
|
+
# What the turn does with an answered ask, as the log line's did:
|
|
203
|
+
# suggested (switched), chosen (the toolkit isn't the default any
|
|
204
|
+
# more), unavailable (the kit is gone), defer or default.
|
|
205
|
+
def toolkit_suggestion_outcome(session, judged)
|
|
206
|
+
case judged.would
|
|
207
|
+
when "suggested"
|
|
208
|
+
return "chosen" unless session.toolkit_origin == "default"
|
|
209
|
+
return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
|
|
210
|
+
|
|
211
|
+
switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
|
|
212
|
+
deferred: ToolkitSuggest.model_reply?(session.messages))
|
|
213
|
+
switched ? "suggested" : "chosen"
|
|
214
|
+
when "defer" then "defer"
|
|
215
|
+
when "no_judgment"
|
|
216
|
+
# System 1 failed (its own warn) or answered nothing usable.
|
|
217
|
+
@toolkit_binding.fell_back(session, "System 1 gave no answer")
|
|
218
|
+
"default"
|
|
219
|
+
else "default"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
private :toolkit_suggestion_outcome
|
|
223
|
+
|
|
224
|
+
# On mode, as an idle worker starts (Worker#start_background): one ask
|
|
225
|
+
# with the real question and a stand-in prompt, on its own thread,
|
|
226
|
+
# never logged and never waited for, so System 1's server has the
|
|
227
|
+
# question's prefix cached for the first prompt. Only for a session a
|
|
228
|
+
# first prompt could be asked for: origin default, no model reply, not
|
|
229
|
+
# a delegate child or a review session, at least two candidates; and
|
|
230
|
+
# not when the worker starts on a first prompt (that ask comes at once).
|
|
231
|
+
def warm_toolkit_suggestion!
|
|
232
|
+
return unless Config.get("toolkit.suggest.mode").to_s == "on"
|
|
233
|
+
|
|
234
|
+
session = @session
|
|
235
|
+
return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
|
|
236
|
+
return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
|
|
237
|
+
|
|
238
|
+
default = Toolkit.of(session.toolkit)
|
|
239
|
+
kits = @toolkit_binding.kits
|
|
240
|
+
return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
|
|
241
|
+
|
|
242
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
243
|
+
return unless client
|
|
244
|
+
|
|
245
|
+
project = File.basename(session.project_root.to_s)
|
|
246
|
+
@toolkit_warmup_thread = Thread.new do
|
|
247
|
+
ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
|
|
248
|
+
end
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
ToolkitSuggest.warn(e)
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
public :warm_toolkit_suggestion!
|
|
254
|
+
|
|
255
|
+
# A still-running warm-up, stopped: a real ask (or the worker leaving)
|
|
256
|
+
# doesn't wait behind it.
|
|
257
|
+
def stop_toolkit_warmup
|
|
258
|
+
@toolkit_warmup_thread&.kill
|
|
259
|
+
@toolkit_warmup_thread = nil
|
|
260
|
+
end
|
|
261
|
+
private :stop_toolkit_warmup
|
|
262
|
+
|
|
263
|
+
def toolkit_suggestion_log
|
|
264
|
+
SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
|
|
265
|
+
end
|
|
266
|
+
private :toolkit_suggestion_log
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# How a turn ends after #end_turn's step: the normal ending and its
|
|
6
|
+
# after-turn hooks, a stop before the model, the release, what an ending
|
|
7
|
+
# keeps (TurnEndings, forwarded), last_turn, the answer's display.
|
|
8
|
+
# Writes @last_context_status; reads @turn_endings, @turn_state,
|
|
9
|
+
# @steer_cut, @hooks, @session_observer, @client.
|
|
10
|
+
module TurnEndPart
|
|
11
|
+
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
12
|
+
# save puts it in the file the session hub watches. Public for the
|
|
13
|
+
# Worker's turn that failed before it began (no #end_turn ran).
|
|
14
|
+
# @param limit [Integer, nil] the turn ran out of iterations at this
|
|
15
|
+
# limit: "exhausted" => true, "limit" => N, so a wait with nobody to
|
|
16
|
+
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
17
|
+
# @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
|
|
18
|
+
# @param event [Hash] the turn's end event (#stop_facts)
|
|
19
|
+
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
20
|
+
enqueued_id = origin.enqueued_id
|
|
21
|
+
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
22
|
+
origin: origin.source, exhausted: limit ? true : nil, limit: limit,
|
|
23
|
+
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
24
|
+
author: origin.participant_author,
|
|
25
|
+
**stop_facts(event))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Why a turn stopped (TurnEndings#stop_facts).
|
|
29
|
+
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
30
|
+
private :stop_facts
|
|
31
|
+
|
|
32
|
+
def save_quietly(session)
|
|
33
|
+
session.save(state_dir: session_state_dir)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
private :save_quietly
|
|
39
|
+
|
|
40
|
+
# Keep what the after_turn hooks presented as the answer's `display`
|
|
41
|
+
# and the refs they attached as its `display_refs` (AnswerRefs, one
|
|
42
|
+
# replace for both), and tell the observers (:answer_display, the
|
|
43
|
+
# display or nil, and how many refs), after the turn_completed they
|
|
44
|
+
# already had: the web re-reads the answer then. A turn_completed
|
|
45
|
+
# with display_pending (after_turn hooks were about to run) always gets
|
|
46
|
+
# one, `display: nil` when nothing was presented, so the web holds the
|
|
47
|
+
# answer's pop until then and never swaps it after. Terminals are not
|
|
48
|
+
# sinks of it; they printed the answer already. The session is saved by
|
|
49
|
+
# the caller, as after every turn.
|
|
50
|
+
def store_answer_display(session, display, refs, pending: false)
|
|
51
|
+
synchronize_events do
|
|
52
|
+
# A plugin that kept event[:refs] and calls it later gets nil: the
|
|
53
|
+
# list is stored now (and the turn is over on every path).
|
|
54
|
+
refs.close!
|
|
55
|
+
messages = Array(session.messages)
|
|
56
|
+
changed = display.changed? && messages.last.equal?(display.target)
|
|
57
|
+
added = refs.added? && messages.last.equal?(refs.target)
|
|
58
|
+
if changed || added
|
|
59
|
+
merged = messages.last.dup
|
|
60
|
+
merged[AnswerDisplay::KEY] = display.text if changed
|
|
61
|
+
merged[AnswerRefs::KEY] = refs.to_a if added
|
|
62
|
+
replace_session_messages(session, messages[0...-1] + [merged])
|
|
63
|
+
event = { type: :answer_display, display: changed ? display.text : nil }
|
|
64
|
+
event[:refs] = refs.count if added
|
|
65
|
+
@session_observer.notify(event)
|
|
66
|
+
elsif pending
|
|
67
|
+
@session_observer.notify({ type: :answer_display, display: nil })
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private :store_answer_display
|
|
72
|
+
|
|
73
|
+
# A turn stopped before the model was asked (#prepare_turn returned
|
|
74
|
+
# false): canceled with nothing kept, like a Ctrl-C there; the result
|
|
75
|
+
# has no conversation, so a UI's checkpoint stands.
|
|
76
|
+
def stopped_before_model(turn)
|
|
77
|
+
reason = turn.controller.reason
|
|
78
|
+
end_turn(turn, "canceled") do |seconds|
|
|
79
|
+
[nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
|
|
80
|
+
duration_ms: (seconds * 1000).round }]
|
|
81
|
+
end
|
|
82
|
+
LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
|
|
83
|
+
end
|
|
84
|
+
private :stopped_before_model
|
|
85
|
+
|
|
86
|
+
# Who stopped a turn a hook stopped or cut (the controller's detail: the
|
|
87
|
+
# bundle, or the hook's label), for the UIs' "stopped by loop-guard";
|
|
88
|
+
# nil for any other cancel.
|
|
89
|
+
def cancelled_by(turn) = turn.controller.stopped_by
|
|
90
|
+
private :cancelled_by
|
|
91
|
+
|
|
92
|
+
# The normal ending (an answer, an empty answer, a Stop, the iteration
|
|
93
|
+
# limit), then the after-turn hooks.
|
|
94
|
+
def complete_turn(turn, result)
|
|
95
|
+
canceled = result.canceled?
|
|
96
|
+
# after_turn hooks run below and may present the answer (the web
|
|
97
|
+
# holds its pop until it knows).
|
|
98
|
+
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
99
|
+
end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
|
|
100
|
+
kept = kept_messages(turn, result, seconds)
|
|
101
|
+
if canceled
|
|
102
|
+
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
|
|
103
|
+
duration_ms: (seconds * 1000).round }]
|
|
104
|
+
else
|
|
105
|
+
# For a client that attaches later (session_state_snapshot).
|
|
106
|
+
@last_context_status = result.context_status.dup if result.context_status
|
|
107
|
+
[kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
|
|
108
|
+
display_pending: display_pending }]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
|
|
113
|
+
# ends the turn in #run_turn without it), with a read-only
|
|
114
|
+
# copy of the conversation the turn stored, event[:answer] (the answer's
|
|
115
|
+
# text: the native loop's stored message still holds its thought),
|
|
116
|
+
# event[:present] for
|
|
117
|
+
# a display version of the answer (AnswerDisplay) and event[:refs]
|
|
118
|
+
# for refs on it (AnswerRefs).
|
|
119
|
+
session = turn.session
|
|
120
|
+
audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
|
|
121
|
+
display = AnswerDisplay.new(session.messages)
|
|
122
|
+
refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
|
|
123
|
+
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
124
|
+
messages: hook_messages(session.messages),
|
|
125
|
+
answer: (result.text unless canceled || result.exhausted?) }
|
|
126
|
+
after_turn[:present] = display.presenter(after_turn)
|
|
127
|
+
after_turn[:refs] = refs.adder(after_turn)
|
|
128
|
+
@hooks.fire(:after_turn, after_turn)
|
|
129
|
+
store_answer_display(session, display, refs, pending: display_pending)
|
|
130
|
+
|
|
131
|
+
# Fire :session_end after the turn (turn-level lifecycle), as :after_turn
|
|
132
|
+
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
133
|
+
end
|
|
134
|
+
private :complete_turn
|
|
135
|
+
|
|
136
|
+
# What each turn ending keeps and announces is TurnEndings'
|
|
137
|
+
# (#build_turn_endings); these forward to it.
|
|
138
|
+
def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
|
|
139
|
+
private :kept_messages
|
|
140
|
+
def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
|
|
141
|
+
private :ctrl_c_messages
|
|
142
|
+
def loop_conversation(error) = @turn_endings.loop_conversation(error)
|
|
143
|
+
private :loop_conversation
|
|
144
|
+
def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
|
|
145
|
+
private :failed_messages
|
|
146
|
+
def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
|
|
147
|
+
private :failed_event
|
|
148
|
+
|
|
149
|
+
# However the turn ended (#run_turn's ensure): the turn flag off, the
|
|
150
|
+
# probe cancel restored, the cancel controller, sink and steers cleared
|
|
151
|
+
# (a steer left is logged as dropped), activity recorded, the
|
|
152
|
+
# turn-scoped hooks cleared.
|
|
153
|
+
def release_turn(probe_cancel_before)
|
|
154
|
+
# A completed turn is activity: release the turn flag and advance the
|
|
155
|
+
# shared inactivity clock so the idle recap detector
|
|
156
|
+
# treats the just-finished turn as activity and re-arms its window.
|
|
157
|
+
left = @turn_state.finish!
|
|
158
|
+
@steer_cut.finished!
|
|
159
|
+
Client.swap_probe_cancel(probe_cancel_before)
|
|
160
|
+
# The next turn's window and served model are asked again: the cache
|
|
161
|
+
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
162
|
+
# rather than at its start, so what this turn read (the profile's own
|
|
163
|
+
# probe included) is not asked for a second time within the turn. A
|
|
164
|
+
# restart with another -c between turns is still noticed: the next
|
|
165
|
+
# turn's first probe goes to the server.
|
|
166
|
+
@client.invalidate_context_window!
|
|
167
|
+
log_dropped_steers(left, "turn_ended")
|
|
168
|
+
record_activity
|
|
169
|
+
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
170
|
+
clear_hooks
|
|
171
|
+
end
|
|
172
|
+
private :release_turn
|
|
173
|
+
|
|
174
|
+
# JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
|
|
175
|
+
# @param result [LLM::ModelResult]
|
|
176
|
+
# @return [Hash]
|
|
177
|
+
def turn_summary(result) = @turn_endings.turn_summary(result)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# What a turn's steps use before and around the model: its settings,
|
|
6
|
+
# images and vision, the model ref and resumed run start it generates
|
|
7
|
+
# with, the used memories it publishes, and the turn-end warm-up of the
|
|
8
|
+
# next one. Writes @next_turn_waiting; reads @model_settings, @kernel,
|
|
9
|
+
# @prompt_warmup, @thinking_notices, @turn_state.
|
|
10
|
+
module TurnHelpersPart
|
|
11
|
+
# The turn's settings for the kernel (LLM::TurnSettings): vision,
|
|
12
|
+
# sampling and thinking, the thinking resolved before the hooks run.
|
|
13
|
+
# The model name is added in #generate, after the hooks that may switch
|
|
14
|
+
# the model.
|
|
15
|
+
# One resolve for all of them: nothing runs between them that could
|
|
16
|
+
# change what a resolve gives.
|
|
17
|
+
def turn_settings(session)
|
|
18
|
+
model = effective_model
|
|
19
|
+
vision = turn_vision(session, model)
|
|
20
|
+
sampling = model.sampling
|
|
21
|
+
level = @model_settings.thinking_level(model)
|
|
22
|
+
@thinking_notices.turn_started(level, model.target)
|
|
23
|
+
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
|
|
24
|
+
reasoning_history: @model_settings.reasoning_history_level(model), model_name: nil,
|
|
25
|
+
window_setting: nil, llm_context: nil, price: nil)
|
|
26
|
+
end
|
|
27
|
+
private :turn_settings
|
|
28
|
+
|
|
29
|
+
# @return [PromptWarmup] the turn-end warm-up (specs, measurements)
|
|
30
|
+
attr_reader :prompt_warmup
|
|
31
|
+
|
|
32
|
+
# A caller's check that the next turn starts at once (input queued for
|
|
33
|
+
# it: a worker's inbox) or that none follows (a one-shot run): no
|
|
34
|
+
# warm-up then. nil: neither.
|
|
35
|
+
attr_accessor :next_turn_waiting
|
|
36
|
+
|
|
37
|
+
# After a completed turn, prefill the next turn's prompt up to its user
|
|
38
|
+
# message while the user reads (PromptWarmup, cache.warmup). Only on a
|
|
39
|
+
# local llama.cpp host on the native loop, and not when the next turn's
|
|
40
|
+
# start differs anyway or starts at once: a reminder due, a continue
|
|
41
|
+
# offer (the turn ran out of steps), a carried steer or queued input.
|
|
42
|
+
# Never fails the turn.
|
|
43
|
+
def warm_up_next_turn(turn, result)
|
|
44
|
+
return if result.canceled? || result.resumable?
|
|
45
|
+
|
|
46
|
+
model = effective_model
|
|
47
|
+
client = model.client
|
|
48
|
+
return unless warmup_target?(model, client)
|
|
49
|
+
return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
|
|
50
|
+
|
|
51
|
+
messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
|
|
52
|
+
# Under the next turn's strategy (a /model switch may have changed it);
|
|
53
|
+
# the last turn's when it can't be resolved.
|
|
54
|
+
prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
|
|
55
|
+
return unless prompt
|
|
56
|
+
|
|
57
|
+
started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
|
|
58
|
+
slot: @kernel.last_slot, images: images)
|
|
59
|
+
@kernel.warmed_up! if started
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
62
|
+
end
|
|
63
|
+
private :warm_up_next_turn
|
|
64
|
+
|
|
65
|
+
# Never a paid or remote host (cache.warmup auto means local only), nor
|
|
66
|
+
# the chat loop (Splash keeps its own cache; llama.cpp's chat path is
|
|
67
|
+
# not measured), nor a client that can't warm up (a spec's stub).
|
|
68
|
+
def warmup_target?(model, client)
|
|
69
|
+
return false if model.chat? || model.entry.remote?
|
|
70
|
+
return false unless client.respond_to?(:warm_up)
|
|
71
|
+
|
|
72
|
+
PromptWarmup.enabled?
|
|
73
|
+
end
|
|
74
|
+
private :warmup_target?
|
|
75
|
+
|
|
76
|
+
# The ref (host:model, as asked) a turn's generations ran on: the model
|
|
77
|
+
# #generate resolved, after the before_turn hooks (which may switch it).
|
|
78
|
+
# SessionMetrics keys its per-model segments on it. nil when it can't be
|
|
79
|
+
# named (the turn's record then has none).
|
|
80
|
+
def generation_model_ref(model)
|
|
81
|
+
model.ref
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
private :generation_model_ref
|
|
87
|
+
|
|
88
|
+
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
89
|
+
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
90
|
+
# Entries before it never move between the turns (#turn_messages adds
|
|
91
|
+
# at the tail, and the system head is replaced in place).
|
|
92
|
+
def resumed_run_start(turn, run_start)
|
|
93
|
+
start = turn.resume_from
|
|
94
|
+
return nil if start.nil?
|
|
95
|
+
return start if start.is_a?(Integer) && start.between?(0, run_start)
|
|
96
|
+
|
|
97
|
+
Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
private :resumed_run_start
|
|
101
|
+
|
|
102
|
+
# The turn's LLM context strategy and where it came from, in the turn
|
|
103
|
+
# log: when it runs a layer, or the session set it (none too).
|
|
104
|
+
def log_llm_context(explained)
|
|
105
|
+
return unless explained
|
|
106
|
+
return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
|
|
107
|
+
|
|
108
|
+
summary = explained.summary
|
|
109
|
+
Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
|
|
110
|
+
:budget_source).compact)
|
|
111
|
+
end
|
|
112
|
+
private :log_llm_context
|
|
113
|
+
|
|
114
|
+
def publish_used_memories(session, on_event)
|
|
115
|
+
# Persist deduped used memories onto the session for Web + reload.
|
|
116
|
+
begin
|
|
117
|
+
session.used_memory_names = used_memory_names
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
120
|
+
end
|
|
121
|
+
# Notify live observers of the updated memory list (so yellow bar refreshes
|
|
122
|
+
# even without a tool_call event if the preload was the only addition).
|
|
123
|
+
# Only emit when there is something to report to avoid noisy event_count drift.
|
|
124
|
+
return unless used_memory_names.any?
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
private :publish_used_memories
|
|
133
|
+
|
|
134
|
+
# [refs, nil] for a turn's images, or [[], error] when one can't be used
|
|
135
|
+
# (the turn then fails right after :turn_started).
|
|
136
|
+
def turn_image_refs(session, images)
|
|
137
|
+
return [[], nil] if Array(images).empty?
|
|
138
|
+
|
|
139
|
+
[ImageStore.resolve_all(Session.session_dir(session.id, state_dir: session_state_dir), images), nil]
|
|
140
|
+
rescue ImageStore::Error => e
|
|
141
|
+
[[], e]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The turn's VisionContext: the session's images folder, and whether the
|
|
145
|
+
# effective model can see images, asked only when a request carries one.
|
|
146
|
+
def turn_vision(session, model = effective_model)
|
|
147
|
+
VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
|
|
148
|
+
capability: -> { model.vision_support(profile) })
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A model known not to see images fails a turn with images up front.
|
|
152
|
+
def refuse_images!(vision)
|
|
153
|
+
return if vision.sendable?
|
|
154
|
+
|
|
155
|
+
host = effective_model.host_name
|
|
156
|
+
raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|