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
|
@@ -11,6 +11,7 @@ require_relative "model_notes"
|
|
|
11
11
|
require_relative "bundle_needs"
|
|
12
12
|
require_relative "memory_bundle/index_size"
|
|
13
13
|
require_relative "thinking"
|
|
14
|
+
require_relative "prompt_files"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# The system prompt an Engine gives its model: the base prompt (tool
|
|
@@ -24,8 +25,6 @@ module Samagotchi
|
|
|
24
25
|
# tools a plugin replaces, the attached session, the thinking level) is
|
|
25
26
|
# read at build time through the lookups; the memory lists are fixed.
|
|
26
27
|
class SystemPrompt
|
|
27
|
-
AGENT_DESCRIPTION_FILE = "AGENTS.md"
|
|
28
|
-
|
|
29
28
|
# The model a session runs on, as the prompt names it: the resolved
|
|
30
29
|
# ref, its host ("name, host:port" or url), the memory overlay key, and
|
|
31
30
|
# what a llama.cpp server says it serves when that differs (else nil).
|
|
@@ -51,8 +50,10 @@ module Samagotchi
|
|
|
51
50
|
# model's (none: the section is left out)
|
|
52
51
|
# @param memories [Array<String>] the --memory list
|
|
53
52
|
# @param muted_memory_names [Array<String>] normalized (MutedMemories)
|
|
53
|
+
# @param appended_files [Array<String>] chi --append-system-prompt's
|
|
54
|
+
# files (absolute paths), after config.yml's append_system_prompt:
|
|
54
55
|
def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
|
|
55
|
-
muted_memory_names: [])
|
|
56
|
+
muted_memory_names: [], appended_files: [])
|
|
56
57
|
@profile_lookup = profile
|
|
57
58
|
@model_lookup = model
|
|
58
59
|
@model_notes_lookup = model_notes
|
|
@@ -61,6 +62,14 @@ module Samagotchi
|
|
|
61
62
|
@thinking_lookup = thinking
|
|
62
63
|
@muted_memory_names = muted_memory_names
|
|
63
64
|
@requested_memories = effective_preload_list(preload_memory_list(memories))
|
|
65
|
+
# Read once, as the memories: list is: a config edit is picked up by
|
|
66
|
+
# a new process. The files' text is read at each build.
|
|
67
|
+
@config_appended = begin
|
|
68
|
+
ConfigFile.appended_prompt_files
|
|
69
|
+
rescue StandardError
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
@cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
|
|
64
73
|
end
|
|
65
74
|
|
|
66
75
|
# @return [Array<String>] the names the session preloads, known before
|
|
@@ -81,16 +90,20 @@ module Samagotchi
|
|
|
81
90
|
@built ||= {}
|
|
82
91
|
@index_sizes ||= {}
|
|
83
92
|
@notes_loaded ||= {}
|
|
93
|
+
@files_loaded ||= {}
|
|
84
94
|
unless @built.key?(key)
|
|
85
95
|
@section_notes = nil
|
|
96
|
+
@section_files = []
|
|
86
97
|
@built[key] = system_prompt_with_index(
|
|
87
98
|
assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
88
99
|
)
|
|
89
100
|
@index_sizes[key] = @measured_index
|
|
90
101
|
@notes_loaded[key] = @section_notes || []
|
|
102
|
+
@files_loaded[key] = @section_files.freeze
|
|
91
103
|
end
|
|
92
104
|
@memory_index = @index_sizes[key]
|
|
93
105
|
@prompt_notes = @notes_loaded[key]
|
|
106
|
+
@prompt_files = @files_loaded[key]
|
|
94
107
|
@built[key]
|
|
95
108
|
end
|
|
96
109
|
|
|
@@ -108,6 +121,14 @@ module Samagotchi
|
|
|
108
121
|
# @return [Array<ModelNotes::Note>, nil]
|
|
109
122
|
attr_reader :prompt_notes
|
|
110
123
|
|
|
124
|
+
# The files whose text the prompt #build returned last holds, as
|
|
125
|
+
# absolute paths in prompt order: the AGENTS.md chain, then the
|
|
126
|
+
# appended files (append_system_prompt:, --append-system-prompt). [] before a
|
|
127
|
+
# build and after #reset!. Read when the prompt is built, as the
|
|
128
|
+
# sections are.
|
|
129
|
+
# @return [Array<String>]
|
|
130
|
+
def prompt_files = @prompt_files || []
|
|
131
|
+
|
|
111
132
|
# Drops the built prompts: the next #build reads the profile, tools,
|
|
112
133
|
# indexes and memories again (a model or profile switch, changed tools).
|
|
113
134
|
def reset!
|
|
@@ -115,6 +136,8 @@ module Samagotchi
|
|
|
115
136
|
@index_sizes = nil
|
|
116
137
|
@notes_loaded = nil
|
|
117
138
|
@prompt_notes = nil
|
|
139
|
+
@files_loaded = nil
|
|
140
|
+
@prompt_files = nil
|
|
118
141
|
@stable_lengths = nil
|
|
119
142
|
end
|
|
120
143
|
|
|
@@ -295,7 +318,7 @@ module Samagotchi
|
|
|
295
318
|
"System memories:\n#{indexes[:system]}"
|
|
296
319
|
].join("\n\n")
|
|
297
320
|
stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
298
|
-
project_specific_description, memory_sections]
|
|
321
|
+
project_specific_description, appended_section, memory_sections]
|
|
299
322
|
stable_text = stable.compact.join("\n")
|
|
300
323
|
volatile = [current_model, project_location, current_session].compact
|
|
301
324
|
return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
|
|
@@ -464,26 +487,59 @@ module Samagotchi
|
|
|
464
487
|
|
|
465
488
|
# ── Project / rg helpers ───────────────────────────────────────────────────
|
|
466
489
|
|
|
467
|
-
# AGENTS.md
|
|
490
|
+
# The AGENTS.md chain from the top of the git work tree down to the cwd
|
|
491
|
+
# (PromptFiles.agents_sections), one section per file with text.
|
|
468
492
|
def project_specific_description
|
|
469
493
|
return nil if skip_agent_description?
|
|
470
494
|
|
|
471
|
-
|
|
472
|
-
return nil
|
|
473
|
-
|
|
474
|
-
content = File.read(path, encoding: "UTF-8").strip
|
|
475
|
-
return nil if content.empty?
|
|
495
|
+
sections = PromptFiles.agents_sections(Dir.pwd)
|
|
496
|
+
return nil if sections.empty?
|
|
476
497
|
|
|
477
|
-
|
|
498
|
+
@section_files&.concat(sections.map(&:path))
|
|
499
|
+
sections.map(&:text).join("\n\n")
|
|
478
500
|
rescue StandardError
|
|
479
501
|
nil
|
|
480
502
|
end
|
|
481
503
|
|
|
482
|
-
|
|
504
|
+
# The appended files' text, right after AGENTS.md (the stable, cached
|
|
505
|
+
# part): config.yml's append_system_prompt: entries first (a relative
|
|
506
|
+
# one against the cwd, as AGENTS.md is found; a missing one is skipped),
|
|
507
|
+
# then --append-system-prompt's. A file the AGENTS.md chain holds, a
|
|
508
|
+
# repeat and an empty or vanished file are left out.
|
|
509
|
+
def appended_section
|
|
483
510
|
cwd = Dir.pwd
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
511
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
512
|
+
seen = Set.new((skip_agent_description? ? [] : PromptFiles.agents_paths(cwd)).map { |path| canonical_path(path) })
|
|
513
|
+
sections = []
|
|
514
|
+
appended_candidates(cwd).each do |path, source|
|
|
515
|
+
next unless seen.add?(canonical_path(path))
|
|
516
|
+
|
|
517
|
+
unless File.file?(path)
|
|
518
|
+
Log.debug(:config, "appended_prompt_file_missing", path: path, source: source)
|
|
519
|
+
next
|
|
520
|
+
end
|
|
521
|
+
text = PromptFiles.section(path, "Appended from #{PromptFiles.display_path(path, top: top)}:")
|
|
522
|
+
next unless text
|
|
523
|
+
|
|
524
|
+
@section_files&.push(path)
|
|
525
|
+
sections << text
|
|
526
|
+
end
|
|
527
|
+
sections.empty? ? nil : sections.join("\n\n")
|
|
528
|
+
rescue StandardError
|
|
529
|
+
nil
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# [absolute path, "config" | "cli"], in prompt order.
|
|
533
|
+
def appended_candidates(cwd)
|
|
534
|
+
@config_appended.map { |entry| [File.expand_path(entry, cwd), "config"] } +
|
|
535
|
+
@cli_appended.map { |path| [path, "cli"] }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# The same file however it's spelled (/tmp vs /private/tmp on macOS).
|
|
539
|
+
def canonical_path(path)
|
|
540
|
+
File.realpath(path)
|
|
541
|
+
rescue SystemCallError
|
|
542
|
+
File.expand_path(path)
|
|
487
543
|
end
|
|
488
544
|
|
|
489
545
|
# Where the session runs and which project memory folder it uses. Only
|
|
@@ -37,6 +37,8 @@ module Samagotchi
|
|
|
37
37
|
# @param memories [Array<String>] --memory: a new session's worker
|
|
38
38
|
# preloads them (an existing session keeps its own list)
|
|
39
39
|
# @param muted_memories [Array<String>] --mute: hidden from a new session
|
|
40
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's
|
|
41
|
+
# files (absolute): a new session's setup carries them
|
|
40
42
|
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
41
43
|
# (LLMContextOverride.update): a new session starts with them; a
|
|
42
44
|
# resumed or attached one's worker gets a /llm-context, after the
|
|
@@ -48,9 +50,10 @@ module Samagotchi
|
|
|
48
50
|
# :failed when the --model switch didn't go through, or (input from a
|
|
49
51
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
50
52
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
51
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil)
|
|
53
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
|
|
52
54
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
53
|
-
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking
|
|
55
|
+
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
56
|
+
appended_prompt_files: appended_prompt_files)
|
|
54
57
|
commands = []
|
|
55
58
|
commands << "/model #{model}" if model && (attach || resume)
|
|
56
59
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -117,9 +120,11 @@ module Samagotchi
|
|
|
117
120
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
118
121
|
# @raise [Error]
|
|
119
122
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
120
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
|
|
123
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], err: $stderr)
|
|
121
124
|
sd = state_dir || Session.default_state_dir
|
|
122
|
-
|
|
125
|
+
if attach || resume
|
|
126
|
+
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files)
|
|
127
|
+
end
|
|
123
128
|
if attach
|
|
124
129
|
live = connect_existing(attach, sd)
|
|
125
130
|
return live if live
|
|
@@ -134,7 +139,8 @@ module Samagotchi
|
|
|
134
139
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
135
140
|
memories: memories, muted_memories: muted_memories,
|
|
136
141
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
137
|
-
thinking: Thinking.session_level(thinking)
|
|
142
|
+
thinking: Thinking.session_level(thinking),
|
|
143
|
+
appended_prompt_files: appended_prompt_files))
|
|
138
144
|
end
|
|
139
145
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
140
146
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -150,16 +156,19 @@ module Samagotchi
|
|
|
150
156
|
end
|
|
151
157
|
|
|
152
158
|
# An existing session's prompt is built from its own session fields:
|
|
153
|
-
# --memory/--mute given with --attach or
|
|
154
|
-
# line saying so (printed before the live
|
|
155
|
-
|
|
159
|
+
# --memory/--mute/--append-system-prompt given with --attach or
|
|
160
|
+
# --resume are ignored, with a line saying so (printed before the live
|
|
161
|
+
# region opens).
|
|
162
|
+
def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [])
|
|
156
163
|
flags = []
|
|
157
164
|
flags << "--memory" unless Array(memories).empty?
|
|
158
165
|
flags << "--mute" unless Array(muted_memories).empty?
|
|
166
|
+
flags << "--append-system-prompt" unless Array(appended_prompt_files).empty?
|
|
159
167
|
return if flags.empty?
|
|
160
168
|
|
|
161
169
|
verb = flags.size == 1 ? "applies" : "apply"
|
|
162
|
-
|
|
170
|
+
names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
|
|
171
|
+
err.puts "(#{names} #{verb} to a new session; #{session_id}'s prompt is already built)"
|
|
163
172
|
end
|
|
164
173
|
|
|
165
174
|
# @return [BridgeClient, nil] the running worker's, or nil when none runs
|
|
@@ -8,6 +8,7 @@ require_relative "../steer"
|
|
|
8
8
|
require_relative "../steer_cut"
|
|
9
9
|
require_relative "../memory_bundle/index_size"
|
|
10
10
|
require_relative "../prompt_note"
|
|
11
|
+
require_relative "../prompt_files"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
class TerminalUI
|
|
@@ -404,6 +405,16 @@ module Samagotchi
|
|
|
404
405
|
"#{"~" if source.to_s == "estimate"}#{number} tok/s"
|
|
405
406
|
end
|
|
406
407
|
|
|
408
|
+
# The prompt files (absolute paths) named as the prompt names them:
|
|
409
|
+
# relative to this checkout's top, else with ~; nil with none.
|
|
410
|
+
def prompt_files_text(paths)
|
|
411
|
+
list = Array(paths).map(&:to_s).reject(&:empty?)
|
|
412
|
+
return nil if list.empty?
|
|
413
|
+
|
|
414
|
+
top = MemoryPaths.work_tree_root(Dir.pwd)
|
|
415
|
+
list.map { |path| PromptFiles.display_path(path, top: top) }.join(", ")
|
|
416
|
+
end
|
|
417
|
+
|
|
407
418
|
# "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
|
|
408
419
|
# the memory indexes the session's prompt holds (the snapshot's
|
|
409
420
|
# memory_index, symbol or string keys); "" without one. As ctx.js
|
|
@@ -600,6 +611,9 @@ module Samagotchi
|
|
|
600
611
|
# The model notes the session's prompt carried (Engine#prompt_notes).
|
|
601
612
|
notes = PromptNote.text(snapshot[:prompt_notes])
|
|
602
613
|
lines << "model notes: #{notes}" unless notes.empty?
|
|
614
|
+
# The files whose text is in the prompt (SystemPrompt#prompt_files).
|
|
615
|
+
files = prompt_files_text(snapshot[:prompt_files])
|
|
616
|
+
lines << "prompt files: #{files}" if files
|
|
603
617
|
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
604
618
|
lines << "llm context: #{llm_context}" if llm_context
|
|
605
619
|
thinking = setting_stats_text(snapshot[:thinking])
|
|
@@ -9,6 +9,7 @@ require_relative "client_id"
|
|
|
9
9
|
require_relative "iteration_limit"
|
|
10
10
|
require_relative "model_profile"
|
|
11
11
|
require_relative "turn_note"
|
|
12
|
+
require_relative "turn_checkpoint"
|
|
12
13
|
require_relative "config"
|
|
13
14
|
require_relative "context_note"
|
|
14
15
|
require_relative "cancellation_controller"
|
|
@@ -92,8 +93,12 @@ module Samagotchi
|
|
|
92
93
|
# (LLMContextOverride.update): set on the new or resumed session
|
|
93
94
|
# @param thinking [String, nil] --thinking as typed: the new or resumed
|
|
94
95
|
# session's own level (default unsets it)
|
|
96
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's
|
|
97
|
+
# files (absolute): a new session saves them; a resumed one's own come
|
|
98
|
+
# first, and the merge goes only to the Engine (as --memory's does)
|
|
95
99
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
96
|
-
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil
|
|
100
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
101
|
+
appended_prompt_files: [])
|
|
97
102
|
@scratch = scratch
|
|
98
103
|
@thinking_word = thinking
|
|
99
104
|
@prompt = prompt
|
|
@@ -142,9 +147,11 @@ module Samagotchi
|
|
|
142
147
|
# session started attached with mutes keeps them here.
|
|
143
148
|
@requested_memories = Array(memories)
|
|
144
149
|
@muted_memory_names = Array(muted_memories)
|
|
150
|
+
@appended_prompt_files = Array(appended_prompt_files)
|
|
145
151
|
if @resume_session
|
|
146
152
|
@requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
|
|
147
153
|
@muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
|
|
154
|
+
@appended_prompt_files = (@resume_session.appended_prompt_files + @appended_prompt_files).uniq
|
|
148
155
|
end
|
|
149
156
|
# Every terminal write goes through the surface. The REPL swaps in a
|
|
150
157
|
# live region when the terminal can show one (#assist_loop), unless it
|
|
@@ -178,6 +185,7 @@ module Samagotchi
|
|
|
178
185
|
model_typed: no_default ? @resumed_model_typed : nil,
|
|
179
186
|
memories: @requested_memories,
|
|
180
187
|
muted_memories: @muted_memory_names,
|
|
188
|
+
appended_prompt_files: @appended_prompt_files,
|
|
181
189
|
recap: scratch ? nil : recap_config,
|
|
182
190
|
scratch: scratch,
|
|
183
191
|
reminders: {
|
|
@@ -258,7 +266,8 @@ module Samagotchi
|
|
|
258
266
|
working_directory: Dir.pwd,
|
|
259
267
|
preloaded_memory_names: @requested_memories,
|
|
260
268
|
muted_memory_names: @muted_memory_names,
|
|
261
|
-
scratch: @scratch
|
|
269
|
+
scratch: @scratch,
|
|
270
|
+
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
|
|
262
271
|
))
|
|
263
272
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
264
273
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -269,6 +278,9 @@ module Samagotchi
|
|
|
269
278
|
session.save if @resume_session
|
|
270
279
|
end
|
|
271
280
|
claim_session!(session.id) unless @owner_lock
|
|
281
|
+
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
282
|
+
# note, before this REPL's first turn appends after it.
|
|
283
|
+
session.save if @resume_session && TurnCheckpoint.settle(session)
|
|
272
284
|
keep_cards(session)
|
|
273
285
|
# Saved at once, so a process killed before its first turn leaves a
|
|
274
286
|
# file the sweep knows to delete.
|
|
@@ -628,7 +640,7 @@ module Samagotchi
|
|
|
628
640
|
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
629
641
|
# Engine#run_turn, rendering via @renderer.
|
|
630
642
|
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
631
|
-
def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
|
|
643
|
+
def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [], resumed: false, resume_from: nil)
|
|
632
644
|
cancellation_controller = CancellationController.new
|
|
633
645
|
@active_cancel_controller = cancellation_controller
|
|
634
646
|
@renderer.begin_turn
|
|
@@ -646,7 +658,9 @@ module Samagotchi
|
|
|
646
658
|
cancel_controller: cancellation_controller,
|
|
647
659
|
pending_input: method(:drain_steering),
|
|
648
660
|
continue: continue,
|
|
649
|
-
images: images
|
|
661
|
+
images: images,
|
|
662
|
+
resumed: resumed,
|
|
663
|
+
resume_from: resume_from
|
|
650
664
|
)
|
|
651
665
|
end
|
|
652
666
|
rescue Interrupt
|
|
@@ -774,9 +788,11 @@ module Samagotchi
|
|
|
774
788
|
end
|
|
775
789
|
return show_command_result(answer) unless answer.resume
|
|
776
790
|
|
|
791
|
+
# The run the offer's turn started: the Continue keeps its reasoning.
|
|
792
|
+
resume_from = @turn_flow.resume_from
|
|
777
793
|
@turn_flow.before_continue_turn
|
|
778
794
|
begin
|
|
779
|
-
result = run_engine_turn(session, nil, continue: true)
|
|
795
|
+
result = run_engine_turn(session, nil, continue: true, resumed: true, resume_from: resume_from)
|
|
780
796
|
rescue LLM::ProviderError
|
|
781
797
|
# The renderer showed the failure (:turn_failed).
|
|
782
798
|
@surface.commit(turn_end_hint("continue prompt preserved"))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "interrupted_turn"
|
|
5
|
+
require_relative "log"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
require_relative "steer"
|
|
8
|
+
require_relative "turn_note"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# A worker's running turn, kept on disk at the loop's iteration
|
|
12
|
+
# boundaries, so a worker killed mid-turn (SIGKILL, a crash of the
|
|
13
|
+
# process) loses at most INTERVAL seconds of it instead of all of it.
|
|
14
|
+
#
|
|
15
|
+
# At each boundary (Worker#pending_input_drain, on the turn's thread) the
|
|
16
|
+
# turn's live conversation (Engine#live_conversation) is copied, with the
|
|
17
|
+
# lines that boundary merges (their input files are deleted as they are
|
|
18
|
+
# claimed), into the session's overlay (Session#turn_checkpoint), marked
|
|
19
|
+
# with an InterruptedTurn. Every save while the turn runs writes the
|
|
20
|
+
# overlay instead of the session's messages: this one's, written at the
|
|
21
|
+
# first boundary (the prompt reaches the disk) and then at most every
|
|
22
|
+
# INTERVAL (or when the boundary merged input), and any other save in the
|
|
23
|
+
# turn (a question asked). The session's in-memory messages never change
|
|
24
|
+
# (a Bridge snapshot shows the turn in progress or in the messages, never
|
|
25
|
+
# both). The turn's end saves the real messages without a marker.
|
|
26
|
+
#
|
|
27
|
+
# The copy is what a Stop at that boundary would keep, minus its note, so
|
|
28
|
+
# the history before the turn is never rewritten (prompt caching); later
|
|
29
|
+
# steps of the same turn may still edit their own entries in place.
|
|
30
|
+
class TurnCheckpoint
|
|
31
|
+
INTERVAL = 10.0
|
|
32
|
+
|
|
33
|
+
# A session loaded with an InterruptedTurn mark (its worker died in the
|
|
34
|
+
# turn): the turn ends now, as canceled. Its note goes at the tail
|
|
35
|
+
# (append-only: the model reads on its next turn that the previous one
|
|
36
|
+
# was cut, after how long), the status goes idle and the mark goes. The
|
|
37
|
+
# dead turn is not run again. The caller saves.
|
|
38
|
+
#
|
|
39
|
+
# last_turn is left as it was: a new last_turn saved idle here, before
|
|
40
|
+
# the turn a `chi send --wait` started this worker for, would end that
|
|
41
|
+
# wait at once as the sent turn's (ReplyWait compares last_turn), with
|
|
42
|
+
# the dead turn's outcome.
|
|
43
|
+
# @return [InterruptedTurn, nil] the mark settled, nil when there was none
|
|
44
|
+
def self.settle(session)
|
|
45
|
+
mark = session.interrupted_turn
|
|
46
|
+
return nil unless mark
|
|
47
|
+
|
|
48
|
+
messages = Array(session.messages)
|
|
49
|
+
# How long it ran up to its last checkpoint (it died later); none
|
|
50
|
+
# when that was its first boundary ("after 0s" would mislead).
|
|
51
|
+
seconds = mark.seconds
|
|
52
|
+
seconds = nil if seconds && seconds < 1
|
|
53
|
+
note = TurnNote.cancelled(:worker_stopped, seconds: seconds, shown: TurnNote.interrupted_tail?(messages))
|
|
54
|
+
session.messages = messages + [note]
|
|
55
|
+
session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
|
|
56
|
+
session.interrupted_turn = nil
|
|
57
|
+
mark
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param session [Session] the worker's session (saved as a whole)
|
|
61
|
+
# @param clock [#call] monotonic seconds (specs)
|
|
62
|
+
def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
63
|
+
@session = session
|
|
64
|
+
@state_dir = state_dir
|
|
65
|
+
@interval = interval
|
|
66
|
+
@clock = clock
|
|
67
|
+
@boundaries = 0
|
|
68
|
+
@written_at = nil
|
|
69
|
+
@started_at = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A turn begins (before Engine#run_turn and the worker's running save):
|
|
73
|
+
# the overlay starts as the history plus +prompt+ (text only: no
|
|
74
|
+
# images, no turn id), marked at iteration 0, so a worker killed before
|
|
75
|
+
# the loop's first boundary leaves the prompt and a mark the next one
|
|
76
|
+
# settles. A continue turn (no prompt) is the history, marked.
|
|
77
|
+
# @param prompt [String, nil]
|
|
78
|
+
def begin_turn(prompt: nil)
|
|
79
|
+
@boundaries = 0
|
|
80
|
+
@written_at = nil
|
|
81
|
+
@started_at = Time.now.iso8601(3)
|
|
82
|
+
messages = Array(@session.messages).dup
|
|
83
|
+
messages << { role: "user", content: prompt } unless prompt.to_s.empty?
|
|
84
|
+
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The turn ended (or never began): the session saves its own messages.
|
|
88
|
+
def end_turn
|
|
89
|
+
@session.turn_checkpoint = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# An iteration boundary of the running turn.
|
|
93
|
+
# @param conversation [Array<Hash>, nil] the loop's live conversation;
|
|
94
|
+
# nil (no turn): nothing
|
|
95
|
+
# @param merging [Array<Steer::Line>] the lines this boundary merges
|
|
96
|
+
# (appended after the conversation, as Steer.inject! will)
|
|
97
|
+
# @param turn_id [String, nil]
|
|
98
|
+
# @return [Boolean] whether the session file was written
|
|
99
|
+
def boundary(conversation, merging: [], turn_id: nil)
|
|
100
|
+
return false unless conversation
|
|
101
|
+
|
|
102
|
+
@boundaries += 1
|
|
103
|
+
merged = merging.empty? ? [] : Steer.merge(merging).messages
|
|
104
|
+
# One level deep: the loop may edit an entry in place later (a
|
|
105
|
+
# forget stub) while a save on another thread serializes this one.
|
|
106
|
+
messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
|
|
107
|
+
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
|
|
108
|
+
@session.turn_checkpoint = { messages: messages, marker: marker }
|
|
109
|
+
return false unless due? || !merged.empty?
|
|
110
|
+
|
|
111
|
+
write
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def due? = @written_at.nil? || @clock.call - @written_at >= @interval
|
|
117
|
+
|
|
118
|
+
def write
|
|
119
|
+
@session.save(state_dir: @state_dir)
|
|
120
|
+
@written_at = @clock.call
|
|
121
|
+
true
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
# The turn goes on; the next boundary tries again.
|
|
124
|
+
Log.warn(:worker, "checkpoint_failed", error: e.class.name, msg: e.message)
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -42,8 +42,9 @@ module Samagotchi
|
|
|
42
42
|
[:invalid, nil]
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# @return [Hash, nil] the pending continue offer: {context:, no_interrupt
|
|
46
|
-
# (context summarizes the interrupted turn for a
|
|
45
|
+
# @return [Hash, nil] the pending continue offer: {context:, no_interrupt:,
|
|
46
|
+
# run_start:, model:} (context summarizes the interrupted turn for a
|
|
47
|
+
# "no, <reason>"; run_start and model are #resume_from's)
|
|
47
48
|
attr_reader :offer
|
|
48
49
|
|
|
49
50
|
# How many times the conversation went back to a checkpoint (a failed
|
|
@@ -94,7 +95,11 @@ module Samagotchi
|
|
|
94
95
|
end
|
|
95
96
|
|
|
96
97
|
if result.resumable?
|
|
97
|
-
|
|
98
|
+
# A Continue that ran out again goes on from the run the chain
|
|
99
|
+
# started; any other turn's offer (a prompt's, a wake's) from its own.
|
|
100
|
+
run_start = (continue && resume_from) || result.run_start
|
|
101
|
+
@offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt, run_start: run_start,
|
|
102
|
+
model: @engine.effective_model_name }
|
|
98
103
|
:continue_offered
|
|
99
104
|
else
|
|
100
105
|
@offer = nil
|
|
@@ -143,6 +148,20 @@ module Samagotchi
|
|
|
143
148
|
:restored
|
|
144
149
|
end
|
|
145
150
|
|
|
151
|
+
# Where a Continue of the pending offer resumes: the run its turn (or
|
|
152
|
+
# the first turn of a chain of Continues) started (ModelResult#run_start),
|
|
153
|
+
# so the Continue keeps that run's reasoning (Engine#run_turn
|
|
154
|
+
# resume_from:). nil with no offer, and after a model change while the
|
|
155
|
+
# offer stands: another model's reasoning isn't replayed, the Continue
|
|
156
|
+
# starts cold. A cancelled or failed Continue keeps the offer, and so
|
|
157
|
+
# this; every way the offer goes takes it along.
|
|
158
|
+
# @return [Integer, nil]
|
|
159
|
+
def resume_from
|
|
160
|
+
return nil unless @offer && @offer[:model] == @engine.effective_model_name
|
|
161
|
+
|
|
162
|
+
@offer[:run_start]
|
|
163
|
+
end
|
|
164
|
+
|
|
146
165
|
# Answer the offer with no (Stop): the interrupted turn stays, as
|
|
147
166
|
# after a Ctrl-C or a new prompt (!rollback still erases it), and the
|
|
148
167
|
# model reads that it wasn't continued: a note, or the user's reason
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "../analytics_file"
|
|
|
13
13
|
require_relative "../client_id"
|
|
14
14
|
require_relative "../delivery"
|
|
15
15
|
require_relative "../answer_tail"
|
|
16
|
+
require_relative "../answer_refs"
|
|
16
17
|
require_relative "../bridge_client"
|
|
17
18
|
require_relative "../bridge/bounded_queue"
|
|
18
19
|
require_relative "../bridge/card_store"
|
|
@@ -1907,6 +1908,10 @@ module Samagotchi
|
|
|
1907
1908
|
message[:display] = shown unless shown.strip.empty?
|
|
1908
1909
|
rendered = message[:display] || stripped
|
|
1909
1910
|
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
1911
|
+
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
1912
|
+
# a stored file is never trusted. Never raises.
|
|
1913
|
+
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
|
|
1914
|
+
message[:display_refs] = refs unless refs.empty?
|
|
1910
1915
|
end
|
|
1911
1916
|
message[:parts] = did if did
|
|
1912
1917
|
filtered << message
|