samagotchi 0.153.0 → 0.154.1
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 +42 -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 +82 -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 +21 -8
- 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 +4 -2
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-frontend.md +2 -2
- 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 +31 -6
- data/lib/samagotchi/answer_text.rb +33 -0
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +52 -32
- data/lib/samagotchi/bridge_client.rb +19 -14
- 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/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -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/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -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 +3 -3
- 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/engine_snapshots.rb +9 -8
- 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 +7 -6
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +13 -13
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- 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/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 +67 -49
- 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 +27 -7
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +13 -2
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +154 -145
- 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 +1 -11
- 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 +17 -22
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- 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 +2 -12
- data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +15 -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 +11 -7
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -51
- data/lib/samagotchi/web/message_display.rb +7 -0
- 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/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- 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 +4 -3
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +13 -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 +6 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +103 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- data/lib/samagotchi.rb +0 -1
- metadata +31 -7
- data/lib/samagotchi/pending_input_queue.rb +0 -55
- 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,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# Side requests: a plugin's ctx.ask_model and Check this on their own
|
|
6
|
+
# IdleClient, their usage in the session's side line, and System 1's
|
|
7
|
+
# recording of judged asks. Writes @side_client/@side_client_key (under
|
|
8
|
+
# @side_client_mutex); reads @metrics, @session, @host_registry.
|
|
9
|
+
module SideRequestsPart
|
|
10
|
+
# Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
|
|
11
|
+
# on_usage) in the session's side line, apart from its turns' totals.
|
|
12
|
+
# Folded and announced as :side_usage under the event lock, so the side
|
|
13
|
+
# blocks reach the UIs in fold order (the lock order event lock →
|
|
14
|
+
# metrics mutex is the existing one); then, between turns, saved to
|
|
15
|
+
# analytics.json (a running turn's end or checkpoint saves it), unless
|
|
16
|
+
# the session is gone (no session file: a late recap thread must not
|
|
17
|
+
# recreate a deleted session's folder; the folder itself may not exist
|
|
18
|
+
# yet before a first turn). Safe from any thread: the recap's, a
|
|
19
|
+
# plugin's, the turn's.
|
|
20
|
+
# A side request counts against the participant who caused it
|
|
21
|
+
# (#side_author), its usage's own author when it names one.
|
|
22
|
+
# @param usage [SideUsage]
|
|
23
|
+
# @return [void]
|
|
24
|
+
def note_side_usage(usage)
|
|
25
|
+
usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
|
|
26
|
+
synchronize_events do
|
|
27
|
+
block = @metrics.add_side(usage)
|
|
28
|
+
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
29
|
+
end
|
|
30
|
+
sid = @metrics.session_id
|
|
31
|
+
return if sid.nil? || turn_running?
|
|
32
|
+
return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
|
|
33
|
+
|
|
34
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Who caused a side request of +kind+ now: the running turn's author (a
|
|
38
|
+
# plugin's ctx.ask_model in it), the last turn's for the idle recap
|
|
39
|
+
# (it recaps that turn); nil, the owner's, for anything else (an owner
|
|
40
|
+
# command's ask_model between turns).
|
|
41
|
+
# @return [String, nil]
|
|
42
|
+
def side_author(kind)
|
|
43
|
+
# Check this is only ever the owner's, whichever turn runs as it ends.
|
|
44
|
+
return nil if kind.to_s == ObjectivesCheck::SOURCE
|
|
45
|
+
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
46
|
+
|
|
47
|
+
@guardrail_wiring.turn_author
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
|
|
53
|
+
# going wrong here leaves the client as it was: a run is never worth a
|
|
54
|
+
# judgment.
|
|
55
|
+
#
|
|
56
|
+
# A scratch session's asks aren't saved; a participant's turn is never
|
|
57
|
+
# judged, so it never comes here.
|
|
58
|
+
# @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
|
|
59
|
+
def system_one_recording(client, judgment, session, mode: nil)
|
|
60
|
+
return client if client.nil? || session&.scratch
|
|
61
|
+
|
|
62
|
+
context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
|
|
63
|
+
SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
66
|
+
client
|
|
67
|
+
end
|
|
68
|
+
private :system_one_recording
|
|
69
|
+
|
|
70
|
+
# Where this Engine's runs are saved; read each time, as the settings are.
|
|
71
|
+
def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
|
|
72
|
+
private :system_one_runs
|
|
73
|
+
|
|
74
|
+
# ctx.ask_model's request: the session's current model on its host, as
|
|
75
|
+
# a turn resolves them (a /model switch counts), or a configured +model+
|
|
76
|
+
# (a host:id ref, an alias, a bare id one of a host's models), through its own
|
|
77
|
+
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
78
|
+
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
79
|
+
# refused thinking-off, holds for the next request); the session's id
|
|
80
|
+
# goes with the request. Its usage counts in the session's side line
|
|
81
|
+
# (#note_side_usage) under +source+, the asking bundle's name.
|
|
82
|
+
# @return [String] the answer
|
|
83
|
+
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
|
|
84
|
+
target = model.nil? ? session_model_recap_target : configured_side_target(model)
|
|
85
|
+
client = side_model_client(target, timeout)
|
|
86
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
|
|
88
|
+
kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
|
|
89
|
+
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
90
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
91
|
+
answer.text
|
|
92
|
+
end
|
|
93
|
+
private :ask_side_model
|
|
94
|
+
|
|
95
|
+
# The target of ctx.ask_model(model:): +model+ as a turn resolves it,
|
|
96
|
+
# once it is known to name something configured. IdleTarget.of_model
|
|
97
|
+
# never raises (an unknown host falls to the default host, an unknown id
|
|
98
|
+
# to the default entry), so a typo would silently ask another model.
|
|
99
|
+
# @raise [ArgumentError] +model+ names no configured host and is no
|
|
100
|
+
# host's model (cached, declared or saved lists; nothing is asked)
|
|
101
|
+
def configured_side_target(model)
|
|
102
|
+
ref = @host_registry.model_ref(model)
|
|
103
|
+
unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
|
|
104
|
+
raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
|
|
105
|
+
"or a model id one of your hosts lists)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
IdleTarget.of_model(@host_registry, model)
|
|
109
|
+
end
|
|
110
|
+
private :configured_side_target
|
|
111
|
+
|
|
112
|
+
# The IdleClient for +target+ at +timeout+: the last one when both are
|
|
113
|
+
# the same, else a new one.
|
|
114
|
+
def side_model_client(target, timeout)
|
|
115
|
+
@side_client_mutex.synchronize do
|
|
116
|
+
key = [target, timeout]
|
|
117
|
+
unless @side_client && @side_client_key == key
|
|
118
|
+
@side_client = IdleClient.for(target, timeout: timeout)
|
|
119
|
+
@side_client_key = key
|
|
120
|
+
end
|
|
121
|
+
@side_client
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
private :side_model_client
|
|
125
|
+
|
|
126
|
+
# The session's current model as a recap target: its host's OpenAI API
|
|
127
|
+
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
128
|
+
# and bare model name, as a turn resolves them.
|
|
129
|
+
# @return [IdleTarget]
|
|
130
|
+
def session_model_recap_target
|
|
131
|
+
IdleTarget.of_model(@host_registry, effective_model_name)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# The running turn as other threads see it: steering into it (now or
|
|
6
|
+
# at the next turn), the drains, the cut, the activity clock and the
|
|
7
|
+
# cancel. Reads @turn_state and @steer_cut; writes nothing of its own.
|
|
8
|
+
module SteeringPart
|
|
9
|
+
# Record that activity happened (user input or a completed turn): the
|
|
10
|
+
# idle jobs' seam (TurnState#record_activity). Each call advances both
|
|
11
|
+
# the last-activity timestamp and the activity sequence.
|
|
12
|
+
# @param now [Float, nil] injectable monotonic time (defaults to now)
|
|
13
|
+
def record_activity(now = nil)
|
|
14
|
+
@turn_state.record_activity(now)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Float] monotonic seconds of the last recorded activity
|
|
18
|
+
def last_activity_at
|
|
19
|
+
@turn_state.last_activity_at
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Integer] monotonically-increasing activity counter (advanced by
|
|
23
|
+
# #record_activity; lets the idle detector summarize once per idle window)
|
|
24
|
+
def activity_seq
|
|
25
|
+
@turn_state.activity_seq
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] true while a turn is in flight (the idle jobs never
|
|
29
|
+
# fire, nor render, while the model is generating)
|
|
30
|
+
def turn_running?
|
|
31
|
+
@turn_state.running?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
35
|
+
# conversation at the loop's next iteration boundary as its own user
|
|
36
|
+
# message (kind: "steer", source:). Callable from any thread; never
|
|
37
|
+
# blocks. With no turn running it does nothing (it never starts one).
|
|
38
|
+
# True means queued, not merged: at the after-answer boundary, and when
|
|
39
|
+
# the turn ends first, it is dropped (logged).
|
|
40
|
+
# @return [Boolean] whether it was queued
|
|
41
|
+
def steer(text, source:)
|
|
42
|
+
@turn_state.steer(text, source: source)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A message for the running turn from +source+ cuts the generation
|
|
46
|
+
# streaming only thinking (SteerCut#cut_for_steer). Call it after the
|
|
47
|
+
# message is queued, with the #input_epoch read before queueing it.
|
|
48
|
+
# @return [Boolean] whether a generation was cut now
|
|
49
|
+
def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
|
|
50
|
+
|
|
51
|
+
# The drains that took input so far: read it before queueing a message
|
|
52
|
+
# for #cut_for_steer.
|
|
53
|
+
def input_epoch = @steer_cut.input_epoch
|
|
54
|
+
|
|
55
|
+
# The running generation's phase (SteerCut#generation_snapshot): what
|
|
56
|
+
# Bridge#snapshot puts in current_turn.generation.
|
|
57
|
+
def generation_snapshot = @steer_cut.generation_snapshot
|
|
58
|
+
|
|
59
|
+
# Like #steer, for the turn that begins next (a continue turn answered
|
|
60
|
+
# with a text): it joins that turn at its first boundary. Callable from
|
|
61
|
+
# any thread; a turn that never begins keeps it for the next one, unless
|
|
62
|
+
# #drop_carried_steers drops it.
|
|
63
|
+
# @return [Boolean] whether it was queued (false: blank)
|
|
64
|
+
def steer_next_turn(text, source:)
|
|
65
|
+
@turn_state.steer_next_turn(text, source: source)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Drop the #steer_next_turn steers no turn began with (the continue turn
|
|
69
|
+
# failed before it began), logged; a no-op once a turn took them.
|
|
70
|
+
def drop_carried_steers
|
|
71
|
+
log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
75
|
+
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
76
|
+
# boundary the steers are dropped: the model answered, and a nudge would
|
|
77
|
+
# only restart the turn. The steer part fails on its own, never taking
|
|
78
|
+
# the user's lines with it. Items are a String (a user line), a
|
|
79
|
+
# Steer::Line (a worker's input line with its sender) or a steer Hash.
|
|
80
|
+
def turn_drain(pending_input)
|
|
81
|
+
lambda do |at_answer: false|
|
|
82
|
+
lines = pending_input ? Array(pending_input.call) : []
|
|
83
|
+
# The input went to the model: a message that waited to cut is in.
|
|
84
|
+
@steer_cut.delivered! unless lines.empty?
|
|
85
|
+
lines + take_steers(at_answer)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private :turn_drain
|
|
89
|
+
|
|
90
|
+
def take_steers(drop)
|
|
91
|
+
steers = @turn_state.take_steers
|
|
92
|
+
return steers unless drop
|
|
93
|
+
|
|
94
|
+
log_dropped_steers(steers, "answered")
|
|
95
|
+
[]
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
private :take_steers
|
|
101
|
+
|
|
102
|
+
def log_dropped_steers(steers, why)
|
|
103
|
+
steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
|
|
104
|
+
end
|
|
105
|
+
private :log_dropped_steers
|
|
106
|
+
|
|
107
|
+
# @return [CancellationController, nil] active turn's cancellation controller
|
|
108
|
+
def active_cancel_controller
|
|
109
|
+
@turn_state.controller
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether a message a person sent waits for the running turn's next
|
|
113
|
+
# boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
|
|
114
|
+
# returns on it, an execute hands its command over early
|
|
115
|
+
# (PeerView#input_waiting?). False outside a turn, for a turn given no
|
|
116
|
+
# probe, and when the probe raises (warned once a turn; the tool never
|
|
117
|
+
# fails for it).
|
|
118
|
+
def input_waiting?
|
|
119
|
+
probe = @turn_state.input_waiting
|
|
120
|
+
!!probe&.call
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Cancel the currently running turn, if any.
|
|
127
|
+
# @param reason [Symbol] cancellation reason
|
|
128
|
+
# @return [Boolean] whether a cancellation was triggered
|
|
129
|
+
def cancel_current_turn!(reason = :manual)
|
|
130
|
+
@turn_state.cancel!(reason)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -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
|