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,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
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# Verify and Revise anchors at a turn's end: the opening's stamp, a Revise
|
|
6
|
+
# turn's draft checked, a Verify turn's quote checked against its source
|
|
7
|
+
# session and its verdict recorded. No ivars of its own: it reads the turn,
|
|
8
|
+
# and the sessions it loads through Engine methods.
|
|
9
|
+
module VerifyPart
|
|
10
|
+
# The turn's anchors, stamped on its opening message (the prompt, or a
|
|
11
|
+
# wake turn's note) in the messages it keeps: display metadata, never
|
|
12
|
+
# in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
|
|
13
|
+
# A Verify turn's claim however it ended; a Revise turn's draft only
|
|
14
|
+
# once it completed with an answer (#revision_target). Returns the
|
|
15
|
+
# stamped ReviseAnchor (the end event's revises), or nil.
|
|
16
|
+
def stamp_opening(messages, turn, outcome)
|
|
17
|
+
return unless messages.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
revises = revision_target(messages, turn, outcome)
|
|
20
|
+
fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
|
|
21
|
+
return if fields.empty?
|
|
22
|
+
|
|
23
|
+
opening = messages.reverse.find do |message|
|
|
24
|
+
Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
25
|
+
end
|
|
26
|
+
return unless opening
|
|
27
|
+
|
|
28
|
+
opening.merge!(fields)
|
|
29
|
+
revises
|
|
30
|
+
end
|
|
31
|
+
private :stamp_opening
|
|
32
|
+
|
|
33
|
+
# The turn a Revise turn revised (D6: "this turn revised that one"):
|
|
34
|
+
# only a completed turn with an answer (AnswerTail) has revised
|
|
35
|
+
# anything. A draft no prompt in the messages carries is no target (a
|
|
36
|
+
# stale page, a rolled-back turn); one a stamped turn already revised
|
|
37
|
+
# is re-targeted to its stack's top, the newest version, which the
|
|
38
|
+
# person meant ("this draft").
|
|
39
|
+
def revision_target(messages, turn, outcome)
|
|
40
|
+
return unless turn.revises && outcome == "completed"
|
|
41
|
+
|
|
42
|
+
slice = AnswerTail.turn_slice(messages, turn.id)
|
|
43
|
+
return unless slice&.any? { |message| AnswerTail.answer?(message) }
|
|
44
|
+
|
|
45
|
+
target = turn.revises.turn_id
|
|
46
|
+
seen = [turn.id]
|
|
47
|
+
until seen.include?(target)
|
|
48
|
+
return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
|
|
49
|
+
|
|
50
|
+
seen << target
|
|
51
|
+
newer = messages.reverse.find do |message|
|
|
52
|
+
Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
|
|
53
|
+
end
|
|
54
|
+
return ReviseAnchor.new(turn_id: target) unless newer
|
|
55
|
+
|
|
56
|
+
target = (newer[:turn_id] || newer["turn_id"]).to_s
|
|
57
|
+
end
|
|
58
|
+
reject_revise_anchor(turn.revises, :cycle)
|
|
59
|
+
end
|
|
60
|
+
private :revision_target
|
|
61
|
+
|
|
62
|
+
def reject_revise_anchor(anchor, reason)
|
|
63
|
+
Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
private :reject_revise_anchor
|
|
67
|
+
|
|
68
|
+
def verified_anchor(session, anchor)
|
|
69
|
+
return unless anchor
|
|
70
|
+
|
|
71
|
+
source = verify_anchor_source_session(session, anchor)
|
|
72
|
+
return unless source
|
|
73
|
+
|
|
74
|
+
turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
|
|
75
|
+
return reject_verify_anchor(anchor, :turn_missing) unless turn
|
|
76
|
+
|
|
77
|
+
reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
|
|
78
|
+
return reject_verify_anchor(anchor, :reply_missing) unless reply
|
|
79
|
+
|
|
80
|
+
quote_text = verify_comparison_text(anchor.quote)
|
|
81
|
+
quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
|
|
82
|
+
return reject_verify_anchor(anchor, :quote_missing) unless quoted
|
|
83
|
+
|
|
84
|
+
anchor
|
|
85
|
+
end
|
|
86
|
+
private :verified_anchor
|
|
87
|
+
|
|
88
|
+
# Whether +quote_text+ is in +answer+ as the model wrote it, or as the
|
|
89
|
+
# pages show it: a page quotes its own text, which reads the session's
|
|
90
|
+
# folder relative, the home folder as ~ and a secret shape as
|
|
91
|
+
# [redacted] (a participant's page gets it so, Redact;
|
|
92
|
+
# the owner's page rewrites its quote the same way, claim_link.js
|
|
93
|
+
# normalizeClaim, so claim links match both pages). +source+ is the
|
|
94
|
+
# session whose folder that is.
|
|
95
|
+
def verify_quote_in?(quote_text, answer, source)
|
|
96
|
+
return true if verify_comparison_text(answer).include?(quote_text)
|
|
97
|
+
|
|
98
|
+
viewer = Redact::Viewer.new(cwd: source.working_directory)
|
|
99
|
+
verify_comparison_text(Redact.text(answer, viewer)).include?(quote_text)
|
|
100
|
+
end
|
|
101
|
+
private :verify_quote_in?
|
|
102
|
+
|
|
103
|
+
def verify_anchor_source_session(session, anchor)
|
|
104
|
+
return session unless session.verify_fresh
|
|
105
|
+
|
|
106
|
+
fresh = session.verify_fresh
|
|
107
|
+
return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
|
|
108
|
+
return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
|
|
109
|
+
|
|
110
|
+
Session.load(session.parent_id, state_dir: session_state_dir)
|
|
111
|
+
rescue ArgumentError, SystemCallError, IOError => e
|
|
112
|
+
Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
|
|
113
|
+
error: e.class.name)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
private :verify_anchor_source_session
|
|
117
|
+
|
|
118
|
+
def verify_comparison_text(text)
|
|
119
|
+
OutputFormatter.strip_markup(text)
|
|
120
|
+
.gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
|
|
121
|
+
.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
|
|
122
|
+
.gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
|
|
123
|
+
.gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
|
|
124
|
+
.gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
|
|
125
|
+
.gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
|
|
126
|
+
.tr("|", " ") # a table row's cells; the page copies them without the pipes
|
|
127
|
+
.gsub(/\s+/, " ")
|
|
128
|
+
.strip
|
|
129
|
+
end
|
|
130
|
+
private :verify_comparison_text
|
|
131
|
+
|
|
132
|
+
def reject_verify_anchor(anchor, reason)
|
|
133
|
+
Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
private :reject_verify_anchor
|
|
137
|
+
|
|
138
|
+
def record_verdict(turn, outcome)
|
|
139
|
+
return unless outcome == "completed" && turn.verify
|
|
140
|
+
|
|
141
|
+
answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
|
|
142
|
+
parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
|
|
143
|
+
return unless parsed
|
|
144
|
+
|
|
145
|
+
record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
|
|
146
|
+
by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
|
|
147
|
+
turn.session.verdicts = turn.session.verdicts + [record]
|
|
148
|
+
record
|
|
149
|
+
end
|
|
150
|
+
private :record_verdict
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|