samagotchi 0.59.0 → 0.61.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 +89 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +11 -6
- data/docs/cli.md +9 -3
- data/docs/configuration.md +62 -12
- data/docs/hooks.md +82 -5
- data/docs/internals/engine.md +50 -13
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +6 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +57 -4
- data/docs/sessions.md +7 -3
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +33 -5
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +101 -23
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +16 -8
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +8 -4
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +141 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/session.rb +53 -5
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/system_prompt.rb +71 -15
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/terminal_ui.rb +21 -5
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -0
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +15 -18
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +6 -3
- data/lib/samagotchi/web/public/ref_menu.js +9 -1
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +129 -15
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/turn_view.js +17 -7
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +11 -1
|
@@ -111,13 +111,15 @@ module Samagotchi
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
# The continue offer was answered yes: resume the conversation without a
|
|
114
|
-
# user message, with the iteration limit the offer's turn had
|
|
114
|
+
# user message, with the iteration limit the offer's turn had, from the
|
|
115
|
+
# run that turn started (TurnFlow#resume_from: its reasoning stays). A failure
|
|
115
116
|
# keeps the offer, as the REPL does ("continue prompt preserved"), and
|
|
116
117
|
# asks again, one before the turn began too (logged, no :turn_failed).
|
|
117
118
|
# The answer's text is carried into the turn as it begins; a turn that
|
|
118
119
|
# never began leaves it to no other.
|
|
119
120
|
def run_continue_turn(command)
|
|
120
121
|
offer = @turn_flow.offer
|
|
122
|
+
resume_from = @turn_flow.resume_from
|
|
121
123
|
close("answered")
|
|
122
124
|
steer = @continue_steer
|
|
123
125
|
@continue_steer = nil
|
|
@@ -129,7 +131,8 @@ module Samagotchi
|
|
|
129
131
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
130
132
|
return announce_offer(offer)
|
|
131
133
|
end
|
|
132
|
-
@run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
|
|
134
|
+
@run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
|
|
135
|
+
resume_from: resume_from,
|
|
133
136
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
134
137
|
if error
|
|
135
138
|
announce_offer(offer)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -64,6 +64,7 @@ require_relative "sampling_settings"
|
|
|
64
64
|
require_relative "thinking"
|
|
65
65
|
require_relative "reasoning_history"
|
|
66
66
|
require_relative "answer_display"
|
|
67
|
+
require_relative "answer_refs"
|
|
67
68
|
require_relative "edit_preview"
|
|
68
69
|
require_relative "question_desk"
|
|
69
70
|
require_relative "child_reports"
|
|
@@ -105,11 +106,13 @@ module Samagotchi
|
|
|
105
106
|
# @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
|
|
106
107
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
107
108
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
109
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's files (absolute paths; a session's
|
|
110
|
+
# appended_prompt_files): their text goes into the system prompt after config.yml's append_system_prompt:
|
|
108
111
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
109
112
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
110
113
|
# delegate (a child would outlive it) nor plugin fork
|
|
111
114
|
def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
112
|
-
plugins: true, scratch: false, model_typed: nil)
|
|
115
|
+
plugins: true, scratch: false, model_typed: nil, appended_prompt_files: [])
|
|
113
116
|
# The turn as other threads see it (flag, cancel controller, sink,
|
|
114
117
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
115
118
|
# may steer or ask whether a turn runs while it loads.
|
|
@@ -205,7 +208,7 @@ module Samagotchi
|
|
|
205
208
|
@prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
|
|
206
209
|
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
207
210
|
model_notes: -> { model_notes }, memories: memories,
|
|
208
|
-
muted_memory_names: @muted_memory_names)
|
|
211
|
+
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
|
|
209
212
|
@session = nil
|
|
210
213
|
@session_observer = SessionObserver.new
|
|
211
214
|
@metrics = SessionMetrics.new
|
|
@@ -1013,7 +1016,8 @@ module Samagotchi
|
|
|
1013
1016
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1014
1017
|
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
1015
1018
|
thinking: thinking_explained&.summary,
|
|
1016
|
-
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
|
|
1019
|
+
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
|
|
1020
|
+
prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
|
|
1017
1021
|
unless snapshot.dig(:context, :window_tokens)
|
|
1018
1022
|
window = current_context_window(target)
|
|
1019
1023
|
if window
|
|
@@ -1475,19 +1479,31 @@ module Samagotchi
|
|
|
1475
1479
|
private :save_quietly
|
|
1476
1480
|
|
|
1477
1481
|
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1478
|
-
# and
|
|
1479
|
-
#
|
|
1482
|
+
# and the refs they attached as its `display_refs` (AnswerRefs, one
|
|
1483
|
+
# replace for both), and tell the observers (:answer_display, the
|
|
1484
|
+
# display or nil, and how many refs), after the turn_completed they
|
|
1485
|
+
# already had: the web re-reads the answer then. A turn_completed
|
|
1480
1486
|
# with display_pending (after_turn hooks were about to run) always gets
|
|
1481
1487
|
# one, `display: nil` when nothing was presented, so the web holds the
|
|
1482
1488
|
# answer's pop until then and never swaps it after. Terminals are not
|
|
1483
1489
|
# sinks of it; they printed the answer already. The session is saved by
|
|
1484
1490
|
# the caller, as after every turn.
|
|
1485
|
-
def store_answer_display(session, display, pending: false)
|
|
1491
|
+
def store_answer_display(session, display, refs, pending: false)
|
|
1486
1492
|
synchronize_events do
|
|
1493
|
+
# A plugin that kept event[:refs] and calls it later gets nil: the
|
|
1494
|
+
# list is stored now (and the turn is over on every path).
|
|
1495
|
+
refs.close!
|
|
1487
1496
|
messages = Array(session.messages)
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1497
|
+
changed = display.changed? && messages.last.equal?(display.target)
|
|
1498
|
+
added = refs.added? && messages.last.equal?(refs.target)
|
|
1499
|
+
if changed || added
|
|
1500
|
+
merged = messages.last.dup
|
|
1501
|
+
merged[AnswerDisplay::KEY] = display.text if changed
|
|
1502
|
+
merged[AnswerRefs::KEY] = refs.to_a if added
|
|
1503
|
+
replace_session_messages(session, messages[0...-1] + [merged])
|
|
1504
|
+
event = { type: :answer_display, display: changed ? display.text : nil }
|
|
1505
|
+
event[:refs] = refs.count if added
|
|
1506
|
+
@session_observer.notify(event)
|
|
1491
1507
|
elsif pending
|
|
1492
1508
|
@session_observer.notify({ type: :answer_display, display: nil })
|
|
1493
1509
|
end
|
|
@@ -1518,7 +1534,7 @@ module Samagotchi
|
|
|
1518
1534
|
private :hook_source
|
|
1519
1535
|
|
|
1520
1536
|
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
1521
|
-
NOTICE_FALLBACKS = %i[display].freeze
|
|
1537
|
+
NOTICE_FALLBACKS = %i[display editor].freeze
|
|
1522
1538
|
|
|
1523
1539
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1524
1540
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
@@ -1527,9 +1543,10 @@ module Samagotchi
|
|
|
1527
1543
|
#
|
|
1528
1544
|
# +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
|
|
1529
1545
|
# :display, the answer's display (AnswerDisplay), for a UI that doesn't
|
|
1530
|
-
# render its links
|
|
1531
|
-
#
|
|
1532
|
-
#
|
|
1546
|
+
# render its links; :editor, a ref the viewer could open in its editor
|
|
1547
|
+
# (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
|
|
1548
|
+
# does leaves the line out; the others, the log among them, show it. An
|
|
1549
|
+
# unknown value is dropped, so the line shows everywhere.
|
|
1533
1550
|
def hook_notify(text, level, hook, fallback_for: nil)
|
|
1534
1551
|
level = (level || :info).to_sym
|
|
1535
1552
|
level = :info unless %i[info warn].include?(level)
|
|
@@ -1801,8 +1818,14 @@ module Samagotchi
|
|
|
1801
1818
|
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1802
1819
|
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1803
1820
|
# +limit+: the iteration limit #generate ran it with.
|
|
1821
|
+
# +resumed+: a step-limit Continue (run_turn's resumed:).
|
|
1822
|
+
# +resume_from+: where its reasoning starts, the run it resumes
|
|
1823
|
+
# (run_turn's resume_from:); nil for every other turn, and for a
|
|
1824
|
+
# Continue that starts cold (after a model change).
|
|
1804
1825
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1805
|
-
:settings, :id, :limit) do
|
|
1826
|
+
:settings, :id, :limit, :resumed, :resume_from) do
|
|
1827
|
+
def resumed? = resumed ? true : false
|
|
1828
|
+
|
|
1806
1829
|
# The turn's boundary events carry the origin only when there is one,
|
|
1807
1830
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1808
1831
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1832,6 +1855,14 @@ module Samagotchi
|
|
|
1832
1855
|
# @param continue [Boolean] resume the conversation without appending a
|
|
1833
1856
|
# user prompt (continue after the iteration limit, reminder turns);
|
|
1834
1857
|
# +prompt+ is ignored and :turn_started carries `continue: true`
|
|
1858
|
+
# @param resumed [Boolean] a step-limit Continue (with continue:):
|
|
1859
|
+
# :turn_started carries `resumed: true`, so do the before_turn payload
|
|
1860
|
+
# and the turn's analytics record. false for every other continue (a
|
|
1861
|
+
# reminder, a wake).
|
|
1862
|
+
# @param resume_from [Integer, nil] a step-limit Continue's: where the
|
|
1863
|
+
# run it resumes started (ModelResult#run_start, kept in the offer by
|
|
1864
|
+
# TurnFlow), so it keeps that run's reasoning; nil starts cold (a
|
|
1865
|
+
# model change while the offer stood, every other turn).
|
|
1835
1866
|
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
|
|
1836
1867
|
# when given, the turn's boundary events (:turn_started, :turn_completed,
|
|
1837
1868
|
# :turn_canceled, :turn_failed) carry it as `origin:`
|
|
@@ -1854,9 +1885,13 @@ module Samagotchi
|
|
|
1854
1885
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1855
1886
|
# one-line summary:.
|
|
1856
1887
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
|
|
1857
|
-
images: [], id: nil)
|
|
1888
|
+
images: [], id: nil, resumed: false, resume_from: nil)
|
|
1858
1889
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1859
1890
|
continue: continue, id: id)
|
|
1891
|
+
if continue
|
|
1892
|
+
turn.resumed = resumed
|
|
1893
|
+
turn.resume_from = resume_from
|
|
1894
|
+
end
|
|
1860
1895
|
begin
|
|
1861
1896
|
# A Stop cuts the /props probes this thread makes for the turn
|
|
1862
1897
|
# (window, served model, vision) instead of waiting their timeout.
|
|
@@ -1939,6 +1974,7 @@ module Samagotchi
|
|
|
1939
1974
|
bind_metrics(session)
|
|
1940
1975
|
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
1941
1976
|
turn_started[:continue] = true if turn.continue
|
|
1977
|
+
turn_started[:resumed] = true if turn.resumed?
|
|
1942
1978
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1943
1979
|
turn_started[:cut_after] = steer_cut_after
|
|
1944
1980
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
@@ -1980,9 +2016,9 @@ module Samagotchi
|
|
|
1980
2016
|
end
|
|
1981
2017
|
|
|
1982
2018
|
# Fire :before_turn hook, with a read-only copy of the history so far
|
|
1983
|
-
# and the prompt (nil on a continue).
|
|
2019
|
+
# and the prompt (nil on a continue); resumed: a step-limit Continue.
|
|
1984
2020
|
@hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
|
|
1985
|
-
messages: hook_messages(session.messages) })
|
|
2021
|
+
resumed: turn.resumed?, messages: hook_messages(session.messages) })
|
|
1986
2022
|
|
|
1987
2023
|
turn_messages(turn, image_refs)
|
|
1988
2024
|
true
|
|
@@ -2118,18 +2154,39 @@ module Samagotchi
|
|
|
2118
2154
|
log_llm_context(explained)
|
|
2119
2155
|
|
|
2120
2156
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
2121
|
-
|
|
2157
|
+
# Where this run starts (ChatLoop::Run's start): an offer made at its
|
|
2158
|
+
# end keeps it, so a Continue resumes this run.
|
|
2159
|
+
run_start = turn.messages.length
|
|
2160
|
+
reasoning_since = resumed_run_start(turn, run_start)
|
|
2161
|
+
result = backend.complete(
|
|
2122
2162
|
messages: turn.messages,
|
|
2123
2163
|
max_iterations: turn.limit,
|
|
2124
2164
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
2125
2165
|
cancel_controller: turn.controller,
|
|
2126
2166
|
model_name: bare_for_backend,
|
|
2127
2167
|
max_tool_output_chars: max_tool_output_chars,
|
|
2128
|
-
pending_input: turn_drain(pending_input)
|
|
2168
|
+
pending_input: turn_drain(pending_input),
|
|
2169
|
+
reasoning_since: reasoning_since
|
|
2129
2170
|
)
|
|
2171
|
+
result.run_start = run_start
|
|
2172
|
+
result
|
|
2130
2173
|
end
|
|
2131
2174
|
private :generate
|
|
2132
2175
|
|
|
2176
|
+
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2177
|
+
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2178
|
+
# Entries before it never move between the turns (#turn_messages adds
|
|
2179
|
+
# at the tail, and the system head is replaced in place).
|
|
2180
|
+
def resumed_run_start(turn, run_start)
|
|
2181
|
+
start = turn.resume_from
|
|
2182
|
+
return nil if start.nil?
|
|
2183
|
+
return start if start.is_a?(Integer) && start.between?(0, run_start)
|
|
2184
|
+
|
|
2185
|
+
Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
|
|
2186
|
+
nil
|
|
2187
|
+
end
|
|
2188
|
+
private :resumed_run_start
|
|
2189
|
+
|
|
2133
2190
|
# The turn's LLM context strategy and where it came from, in the turn
|
|
2134
2191
|
# log: when it runs a layer, or the session set it (none too).
|
|
2135
2192
|
def log_llm_context(explained)
|
|
@@ -2184,15 +2241,18 @@ module Samagotchi
|
|
|
2184
2241
|
|
|
2185
2242
|
# Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
|
|
2186
2243
|
# ends the turn in #run_turn without it), with a read-only
|
|
2187
|
-
# copy of the conversation the turn stored,
|
|
2188
|
-
# a display version of the answer (AnswerDisplay)
|
|
2244
|
+
# copy of the conversation the turn stored, event[:present] for
|
|
2245
|
+
# a display version of the answer (AnswerDisplay) and event[:refs]
|
|
2246
|
+
# for refs on it (AnswerRefs).
|
|
2189
2247
|
session = turn.session
|
|
2190
2248
|
display = AnswerDisplay.new(session.messages)
|
|
2249
|
+
refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
|
|
2191
2250
|
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
2192
2251
|
messages: hook_messages(session.messages) }
|
|
2193
2252
|
after_turn[:present] = display.presenter(after_turn)
|
|
2253
|
+
after_turn[:refs] = refs.adder(after_turn)
|
|
2194
2254
|
@hooks.fire(:after_turn, after_turn)
|
|
2195
|
-
store_answer_display(session, display, pending: display_pending)
|
|
2255
|
+
store_answer_display(session, display, refs, pending: display_pending)
|
|
2196
2256
|
|
|
2197
2257
|
# Fire :session_end after the turn (turn-level lifecycle), as :after_turn
|
|
2198
2258
|
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
@@ -2203,7 +2263,8 @@ module Samagotchi
|
|
|
2203
2263
|
# replace). Runs under the event lock: it also adds the turn's note to
|
|
2204
2264
|
# result.conversation, which the REPL keeps as-is.
|
|
2205
2265
|
# A turn that ran out of iterations ends at its tool results, so a
|
|
2206
|
-
# continue resumes from them rather than after a made-up reply
|
|
2266
|
+
# continue resumes from them rather than after a made-up reply (and a
|
|
2267
|
+
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
2207
2268
|
def kept_messages(turn, result, seconds)
|
|
2208
2269
|
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
2209
2270
|
# Nothing visible (no text) is a turn the model should know ended
|
|
@@ -2502,6 +2563,19 @@ module Samagotchi
|
|
|
2502
2563
|
clone_messages(@session&.messages)
|
|
2503
2564
|
end
|
|
2504
2565
|
|
|
2566
|
+
# The running turn's conversation as its loop builds it (both loops
|
|
2567
|
+
# register it on the kernel's llm_context_turn): the same array the loop
|
|
2568
|
+
# appends to, not a copy, so read it on the turn's thread (the pending
|
|
2569
|
+
# input drain, at an iteration boundary: TurnCheckpoint). nil when no
|
|
2570
|
+
# turn runs (llm_context_turn is never cleared, and still holds the
|
|
2571
|
+
# last turn's then).
|
|
2572
|
+
# @return [Array<Hash>, nil]
|
|
2573
|
+
def live_conversation
|
|
2574
|
+
return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
|
|
2575
|
+
|
|
2576
|
+
@kernel.llm_context_turn&.conversation
|
|
2577
|
+
end
|
|
2578
|
+
|
|
2505
2579
|
# Append messages to the current session's conversation.
|
|
2506
2580
|
# @param messages [Array<Hash>]
|
|
2507
2581
|
# @return [Array<Hash>] the session's messages
|
|
@@ -2624,6 +2698,10 @@ module Samagotchi
|
|
|
2624
2698
|
state_dir: -> { session_state_dir },
|
|
2625
2699
|
scratch: -> { @scratch },
|
|
2626
2700
|
setup: -> { SessionSetup.of(@session) },
|
|
2701
|
+
# The files whose text the built system prompt holds; [] until a
|
|
2702
|
+
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
2703
|
+
# returns [] itself: the || keeps a nil builder safe).
|
|
2704
|
+
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2627
2705
|
frontend: -> { interface }
|
|
2628
2706
|
)
|
|
2629
2707
|
end
|
|
@@ -253,9 +253,11 @@ module Samagotchi
|
|
|
253
253
|
# event[:steer].call(text) -> true when the text was queued for the
|
|
254
254
|
# running turn's next boundary (its own user message, source: the
|
|
255
255
|
# hook's bundle); from after_turn / session_end false
|
|
256
|
-
# and after_turn's fire site adds
|
|
256
|
+
# and after_turn's fire site adds two more (AnswerDisplay, AnswerRefs):
|
|
257
257
|
# event[:present].call { |text| new_text } -> the answer's display
|
|
258
258
|
# text after the call (chained in hook order), nil without an answer
|
|
259
|
+
# event[:refs].call(match:, kind:, target:, label: nil, actions: [])
|
|
260
|
+
# -> true (kept), false (refused, logged), nil without an answer
|
|
259
261
|
#
|
|
260
262
|
# @param name [Symbol] the hook name to fire
|
|
261
263
|
# @param event [Hash] the event payload (may be mutated by hooks)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The mark of a turn a worker checkpointed and never finished
|
|
5
|
+
# (Session#interrupted_turn, saved as session.json's "interrupted_turn"):
|
|
6
|
+
# TurnCheckpoint writes it with the turn's messages so far while the turn
|
|
7
|
+
# runs; the turn's end saves without it; the next worker to start finds it
|
|
8
|
+
# and settles the turn (Worker#settle_interrupted_turn).
|
|
9
|
+
#
|
|
10
|
+
# id: the turn's id (nil when unknown); started_at / at: iso8601, when the
|
|
11
|
+
# turn began and when this checkpoint was taken; iteration: the loop
|
|
12
|
+
# boundaries it had passed (Integer).
|
|
13
|
+
InterruptedTurn = Data.define(:id, :started_at, :at, :iteration) do
|
|
14
|
+
def initialize(id: nil, started_at: nil, at: nil, iteration: nil) = super
|
|
15
|
+
|
|
16
|
+
# A saved record (string or symbol keys), an InterruptedTurn as it is;
|
|
17
|
+
# nil for anything else.
|
|
18
|
+
# @return [InterruptedTurn, nil]
|
|
19
|
+
def self.from_file(raw)
|
|
20
|
+
return raw if raw.is_a?(InterruptedTurn)
|
|
21
|
+
return nil unless raw.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash{String => Object}] string keys, unset fields left out
|
|
27
|
+
def to_file = to_h.compact.transform_keys(&:to_s)
|
|
28
|
+
|
|
29
|
+
# Seconds from the turn's start to this checkpoint (nil when unknown).
|
|
30
|
+
# @return [Float, nil]
|
|
31
|
+
def seconds
|
|
32
|
+
return nil unless started_at && at
|
|
33
|
+
|
|
34
|
+
(Time.iso8601(at) - Time.iso8601(started_at)).clamp(0, Float::INFINITY)
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -10,8 +10,9 @@ module Samagotchi
|
|
|
10
10
|
module IterationLimit
|
|
11
11
|
KEY = "turn.max_iterations"
|
|
12
12
|
# A turn's limit unless turn.max_iterations says otherwise, and the
|
|
13
|
-
# loops' own default.
|
|
14
|
-
|
|
13
|
+
# loops' own default. Loops are caught by the loop-guard bundle and a
|
|
14
|
+
# long turn shows check-in's card well before it; this catches the rest.
|
|
15
|
+
DEFAULT = 300
|
|
15
16
|
# A --no-interrupt / --non-interactive turn's, at least: nobody is there
|
|
16
17
|
# to answer a continue offer.
|
|
17
18
|
NO_INTERRUPT = 1000
|
|
@@ -204,12 +204,18 @@ module Samagotchi
|
|
|
204
204
|
# steering mid-stream); drained lines merge into ONE user message appended
|
|
205
205
|
# at the conversation tail (prefix KV cache preserved) and a
|
|
206
206
|
# :pending_input_merged stream event is emitted.
|
|
207
|
+
# @param reasoning_since [Integer, nil] a step-limit Continue's: where the
|
|
208
|
+
# run it resumes started in +messages+; that run's model messages keep
|
|
209
|
+
# their thought blocks (#prepare_conversation), as within one run. nil:
|
|
210
|
+
# every earlier thought is stripped.
|
|
207
211
|
# @return [LLM::ModelResult] final visible response with continuation metadata
|
|
208
|
-
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil
|
|
212
|
+
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil,
|
|
213
|
+
reasoning_since: nil)
|
|
209
214
|
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
210
215
|
@last_slot = nil
|
|
211
216
|
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
212
|
-
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars)
|
|
217
|
+
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
|
|
218
|
+
reasoning_since: reasoning_since)
|
|
213
219
|
|
|
214
220
|
max_iterations.times do |iteration_index|
|
|
215
221
|
turn.iteration = iteration_index + 1
|
|
@@ -284,8 +290,8 @@ module Samagotchi
|
|
|
284
290
|
:buffer_mark, keyword_init: true)
|
|
285
291
|
private_constant :Turn, :Request, :Generation
|
|
286
292
|
|
|
287
|
-
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
288
|
-
conversation = prepare_conversation(messages)
|
|
293
|
+
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
|
|
294
|
+
conversation = prepare_conversation(messages, keep_thought_from: thought_floor(messages, reasoning_since))
|
|
289
295
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
290
296
|
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
291
297
|
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
@@ -992,20 +998,42 @@ module Samagotchi
|
|
|
992
998
|
@profile.uses_role_prefixes? ? @profile.user_prefix : "#{@profile.turn_start}user\n"
|
|
993
999
|
end
|
|
994
1000
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
{ role: m[:role], content:
|
|
1001
|
+
# +keep_thought_from+: model messages from this index on keep their
|
|
1002
|
+
# thought blocks (a resumed run's); nil strips every one.
|
|
1003
|
+
def sanitize_history(messages, keep_thought_from: nil)
|
|
1004
|
+
messages.each_with_index.map do |m, index|
|
|
1005
|
+
if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
|
|
1006
|
+
{ role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs] }.compact
|
|
1007
|
+
elsif m[:role] == "model"
|
|
1008
|
+
# `display` and `display_refs` ride along so the stored
|
|
1009
|
+
# conversation keeps them; the prompt formatter never reads them
|
|
1010
|
+
# (AnswerDisplay, AnswerRefs).
|
|
1011
|
+
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
|
|
1012
|
+
display_refs: m[:display_refs] }.compact
|
|
1001
1013
|
else
|
|
1002
1014
|
m.dup
|
|
1003
1015
|
end
|
|
1004
1016
|
end
|
|
1005
1017
|
end
|
|
1006
1018
|
|
|
1007
|
-
# Standard multi-turn compliance: never pass prior raw thought blocks
|
|
1008
|
-
|
|
1019
|
+
# Standard multi-turn compliance: never pass prior raw thought blocks,
|
|
1020
|
+
# but a resumed run's (+keep_thought_from+, #thought_floor): the same
|
|
1021
|
+
# run going on after a step-limit Continue.
|
|
1022
|
+
def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
|
|
1023
|
+
|
|
1024
|
+
# Where a resumed run's thought blocks start: its start (+since+). Inside
|
|
1025
|
+
# one native run nothing re-sanitizes, so a user entry the run merged (a
|
|
1026
|
+
# steer, a delegate report, the Qwen recovery prompt) left the thoughts
|
|
1027
|
+
# before it in the prompt: they stay. Only a user or turn_start entry
|
|
1028
|
+
# added after the run ended (after its last model message: a !cmd's
|
|
1029
|
+
# output) starts them again past itself. nil without +since+.
|
|
1030
|
+
def thought_floor(messages, since)
|
|
1031
|
+
return nil if since.nil?
|
|
1032
|
+
|
|
1033
|
+
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
1034
|
+
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
1035
|
+
query && query > last_reply ? [query + 1, since].max : since
|
|
1036
|
+
end
|
|
1009
1037
|
|
|
1010
1038
|
def duplicate_conversation(messages)
|
|
1011
1039
|
messages.map(&:dup)
|
|
@@ -14,9 +14,14 @@ module Samagotchi
|
|
|
14
14
|
# extra surface lives on backend-specific subclasses, never on this interface.
|
|
15
15
|
#
|
|
16
16
|
# The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
|
|
17
|
+
#
|
|
18
|
+
# +reasoning_since+: where the run a step-limit Continue resumes started
|
|
19
|
+
# in +messages+ (Engine#run_turn resume_from:); the replies from there on
|
|
20
|
+
# keep the model's reasoning (thought blocks), as within one run. nil:
|
|
21
|
+
# the run starts here, as every other turn's does.
|
|
17
22
|
class ModelBackend
|
|
18
23
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
19
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
24
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
20
25
|
raise NotImplementedError, "#{self.class}#complete must be implemented"
|
|
21
26
|
end
|
|
22
27
|
end
|
|
@@ -11,6 +11,7 @@ require_relative "native_tool_normalizer"
|
|
|
11
11
|
require_relative "stream_drops"
|
|
12
12
|
require_relative "../kernel_loop"
|
|
13
13
|
require_relative "../answer_display"
|
|
14
|
+
require_relative "../answer_refs"
|
|
14
15
|
require_relative "../cut_policy"
|
|
15
16
|
require_relative "../context_status"
|
|
16
17
|
require_relative "../context_window"
|
|
@@ -68,12 +69,14 @@ module Samagotchi
|
|
|
68
69
|
|
|
69
70
|
def provider = :chat
|
|
70
71
|
|
|
72
|
+
# +reasoning_since+ (ModelBackend#complete): the resumed run's start,
|
|
73
|
+
# the Run's own start then (#reasoning_boundary).
|
|
71
74
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
72
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
75
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
73
76
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
74
77
|
|
|
75
78
|
conversation = Array(messages).map(&:dup)
|
|
76
|
-
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
|
|
79
|
+
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
|
|
77
80
|
.call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
78
81
|
rescue StandardError => e
|
|
79
82
|
FailedTurn.attach(e, conversation && plain(conversation))
|
|
@@ -195,7 +198,8 @@ module Samagotchi
|
|
|
195
198
|
# user message: a prompt, a steer, merged input, the chat template's
|
|
196
199
|
# last query; or an entry that starts a turn, turn_start) and +since+
|
|
197
200
|
# (the Run's start, so a continue turn -- a reminder, a context wake,
|
|
198
|
-
# a
|
|
201
|
+
# a delegate report's wake -- sends none of the run before it; a
|
|
202
|
+
# step-limit Continue's Run starts where the run it resumes did).
|
|
199
203
|
# @return [Integer, nil]
|
|
200
204
|
def reasoning_boundary(conversation, since: nil, level: reasoning_history)
|
|
201
205
|
case level
|
|
@@ -299,8 +303,9 @@ module Samagotchi
|
|
|
299
303
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
300
304
|
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
301
305
|
# (the live row's params line and label, never sent back), an
|
|
302
|
-
# edit/write result's tool_diffs (never sent),
|
|
303
|
-
# note's marker (TurnNote.empty, never sent)
|
|
306
|
+
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
307
|
+
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
308
|
+
# and display_refs (AnswerDisplay, AnswerRefs; never sent).
|
|
304
309
|
def plain(conversation)
|
|
305
310
|
conversation.map do |entry|
|
|
306
311
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -316,6 +321,7 @@ module Samagotchi
|
|
|
316
321
|
marker = TurnNote.empty_answer(entry)
|
|
317
322
|
message[TurnNote::EMPTY_ANSWER] = marker if marker
|
|
318
323
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
324
|
+
message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
|
|
319
325
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
320
326
|
message
|
|
321
327
|
end
|
|
@@ -402,7 +408,9 @@ module Samagotchi
|
|
|
402
408
|
# One turn's state: the conversation, the stream sink and tool
|
|
403
409
|
# activity.
|
|
404
410
|
class Run
|
|
405
|
-
|
|
411
|
+
# +start+: where the run a step-limit Continue resumes started (nil:
|
|
412
|
+
# this run starts at the conversation's end).
|
|
413
|
+
def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
|
|
406
414
|
@loop = loop
|
|
407
415
|
@conversation = conversation
|
|
408
416
|
@on_stream_event = on_stream_event
|
|
@@ -410,8 +418,8 @@ module Samagotchi
|
|
|
410
418
|
@model_name = model_name
|
|
411
419
|
@pending_input = pending_input
|
|
412
420
|
# Where this run's entries start: the reasoning it sends back
|
|
413
|
-
# (ChatLoop#reasoning_boundary) is its own.
|
|
414
|
-
@start = conversation.length
|
|
421
|
+
# (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
|
|
422
|
+
@start = start || conversation.length
|
|
415
423
|
@tool_activity = []
|
|
416
424
|
@empty_retry = EmptyAnswerRetry.new
|
|
417
425
|
# The empty generations, never saved: the UIs draw the turn's
|
|
@@ -14,9 +14,15 @@ module Samagotchi
|
|
|
14
14
|
# generations as model messages, which the loops keep out of the
|
|
15
15
|
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
16
|
# them on the turn's note for the UIs (TurnNote.empty).
|
|
17
|
+
#
|
|
18
|
+
# .run_start is where the turn's run started in the conversation (the
|
|
19
|
+
# length of the messages the loop got), set by the Engine after the loop
|
|
20
|
+
# returns; nil from a loop called directly. A step-limit offer keeps it
|
|
21
|
+
# (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
|
|
17
22
|
class ModelResult
|
|
18
23
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
19
24
|
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
25
|
+
attr_accessor :run_start
|
|
20
26
|
|
|
21
27
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
22
28
|
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
|
|
@@ -57,8 +63,11 @@ module Samagotchi
|
|
|
57
63
|
!!pending_tool_calls
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
#
|
|
61
|
-
#
|
|
66
|
+
# A run that ran out of iterations can be continued (the step-limit
|
|
67
|
+
# offer). KernelLoop marks one exhausted only when it stopped on pending
|
|
68
|
+
# tool calls; ChatLoop also leaves it exhausted when its last iteration
|
|
69
|
+
# ended in a retry (an empty answer's nudge, a cut) or merged input
|
|
70
|
+
# after an answer, which a Continue then picks up.
|
|
62
71
|
def resumable?
|
|
63
72
|
exhausted?
|
|
64
73
|
end
|
|
@@ -17,7 +17,7 @@ module Samagotchi
|
|
|
17
17
|
def provider = :native
|
|
18
18
|
|
|
19
19
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
20
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
20
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
21
21
|
@kernel.run(
|
|
22
22
|
messages,
|
|
23
23
|
max_iterations: max_iterations,
|
|
@@ -25,7 +25,8 @@ module Samagotchi
|
|
|
25
25
|
cancel_controller: cancel_controller,
|
|
26
26
|
model_name: model_name,
|
|
27
27
|
max_tool_output_chars: max_tool_output_chars,
|
|
28
|
-
pending_input: pending_input
|
|
28
|
+
pending_input: pending_input,
|
|
29
|
+
reasoning_since: reasoning_since
|
|
29
30
|
)
|
|
30
31
|
end
|
|
31
32
|
end
|
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
@generation_started_at.clear
|
|
64
64
|
@tool_started_at.clear
|
|
65
65
|
log(:info, :turn_started, session: event[:session_id], prompt_chars: event[:prompt].to_s.length,
|
|
66
|
-
continue: event[:continue] || nil,
|
|
66
|
+
continue: event[:continue] || nil, resumed: event[:resumed] || nil, **items(event),
|
|
67
|
+
**origin(event))
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def on_turn_completed(event)
|
|
@@ -151,10 +152,13 @@ module Samagotchi
|
|
|
151
152
|
log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
|
|
152
153
|
end
|
|
153
154
|
|
|
154
|
-
# after_turn hooks presented the answer (AnswerDisplay)
|
|
155
|
-
# (
|
|
155
|
+
# after_turn hooks presented the answer (AnswerDisplay) or attached refs
|
|
156
|
+
# to it (AnswerRefs): the display's size and the refs' count only (none
|
|
157
|
+
# for the display: nil that says nothing was presented).
|
|
156
158
|
def on_answer_display(event)
|
|
157
|
-
|
|
159
|
+
return unless event[:display] || event[:refs]
|
|
160
|
+
|
|
161
|
+
log(:info, :answer_display, chars: event[:display]&.length, refs: event[:refs])
|
|
158
162
|
end
|
|
159
163
|
|
|
160
164
|
# A card shown to the user (Engine#show_card): whose, which, and its
|