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
|
@@ -19,7 +19,9 @@ module Samagotchi
|
|
|
19
19
|
# (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
|
-
# +
|
|
22
|
+
# +prompt_files+ returns the files whose text the built system prompt
|
|
23
|
+
# holds (SystemPrompt#prompt_files, [] before a build); +model_name+
|
|
24
|
+
# (the session's resolved model ref, live after /model) and
|
|
23
25
|
# +state_dir+ are what ctx.sessions forks with, and +setup+ (the
|
|
24
26
|
# session's own SessionSetup), which a fork copies; +model_key+ is the
|
|
25
27
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
@@ -28,8 +30,8 @@ module Samagotchi
|
|
|
28
30
|
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
31
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
30
32
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
31
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :
|
|
32
|
-
:frontend, keyword_init: true)
|
|
33
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
34
|
+
:steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
|
|
33
35
|
|
|
34
36
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
35
37
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -159,6 +161,17 @@ module Samagotchi
|
|
|
159
161
|
# a plugin can say what its answer is about.
|
|
160
162
|
def messages_partial? = !!@host.messages_partial&.call
|
|
161
163
|
|
|
164
|
+
# The files whose text the session's system prompt holds now, as
|
|
165
|
+
# absolute paths in prompt order (SystemPrompt#prompt_files): the
|
|
166
|
+
# AGENTS.md chain (the work tree top down to the cwd), then the
|
|
167
|
+
# appended files (append_system_prompt:, --append-system-prompt).
|
|
168
|
+
# [] before the first build and after one that drops them (a model
|
|
169
|
+
# or tools change). A Host without the member (a spec's) reads [].
|
|
170
|
+
# @return [Array<String>] frozen
|
|
171
|
+
def prompt_files
|
|
172
|
+
Array(@host.prompt_files&.call || []).freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
162
175
|
# Run +block+ as this plugin's handler for +event+: until it returns,
|
|
163
176
|
# the helpers called on this thread act as the event's own. Nests (a
|
|
164
177
|
# fire inside a handler gets its own event; the outer one is back
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "memory_paths"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The files whose text goes into the system prompt as they are
|
|
7
|
+
# (SystemPrompt): which AGENTS.md files a cwd loads, and how a file
|
|
8
|
+
# becomes a prompt section.
|
|
9
|
+
module PromptFiles
|
|
10
|
+
AGENTS_FILE = "AGENTS.md"
|
|
11
|
+
|
|
12
|
+
AGENTS_HEADER = "Project specific description:"
|
|
13
|
+
|
|
14
|
+
# A file's text as the prompt carries it: +path+ absolute, +text+ the
|
|
15
|
+
# section with its header.
|
|
16
|
+
Section = Data.define(:path, :text)
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The AGENTS.md files a session in +cwd+ loads, as absolute paths,
|
|
21
|
+
# outermost first: inside a git work tree every one from its top (a
|
|
22
|
+
# linked worktree's own checkout) down to the cwd; outside a repository
|
|
23
|
+
# only the cwd's ([] with none).
|
|
24
|
+
# @param cwd [String] where the prompt is built (Dir.pwd, not the
|
|
25
|
+
# session's working directory: /tmp vs /private/tmp would change the
|
|
26
|
+
# prompt bytes)
|
|
27
|
+
# @return [Array<String>]
|
|
28
|
+
def agents_paths(cwd)
|
|
29
|
+
chain_dirs(cwd).map { |dir| File.join(dir, AGENTS_FILE) }.select { |path| File.file?(path) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The sections of the AGENTS.md chain (#agents_paths) with text: the
|
|
33
|
+
# first keeps the plain header (a one-file session's prompt is as it
|
|
34
|
+
# always was), each further one names its directory relative to the
|
|
35
|
+
# work tree top ("Project specific description for pkg/:").
|
|
36
|
+
# @return [Array<Section>]
|
|
37
|
+
def agents_sections(cwd)
|
|
38
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
39
|
+
sections = []
|
|
40
|
+
agents_paths(cwd).each do |path|
|
|
41
|
+
dir = File.dirname(path)
|
|
42
|
+
header = sections.empty? ? AGENTS_HEADER : "Project specific description for #{relative_dir(dir, top) || dir}/:"
|
|
43
|
+
text = section(path, header)
|
|
44
|
+
sections << Section.new(path: path, text: text) if text
|
|
45
|
+
end
|
|
46
|
+
sections
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The directories whose AGENTS.md a session in +cwd+ reads, outermost first.
|
|
50
|
+
def chain_dirs(cwd)
|
|
51
|
+
cwd = File.expand_path(cwd)
|
|
52
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
53
|
+
return [cwd] unless top
|
|
54
|
+
|
|
55
|
+
rel = relative_dir(cwd, top)
|
|
56
|
+
return [top] if rel == "."
|
|
57
|
+
# A cwd the walk up didn't reach from (not expected): only its own and the top's.
|
|
58
|
+
return [top, cwd] if rel.nil?
|
|
59
|
+
|
|
60
|
+
parts = rel.split("/")
|
|
61
|
+
[top] + parts.each_index.map { |i| File.join(top, *parts[0..i]) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# +dir+ relative to +top+ ("." for the top itself), nil when it isn't under it.
|
|
65
|
+
def relative_dir(dir, top)
|
|
66
|
+
return "." if dir == top
|
|
67
|
+
|
|
68
|
+
prefix = top.end_with?("/") ? top : "#{top}/"
|
|
69
|
+
dir.start_with?(prefix) ? dir.delete_prefix(prefix) : nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# How a prompt file is named in the prompt and /stats: relative to the
|
|
73
|
+
# work tree +top+ when inside it, else with ~ for the home directory.
|
|
74
|
+
# @return [String]
|
|
75
|
+
def display_path(path, top:)
|
|
76
|
+
rel = top && relative_dir(path, top)
|
|
77
|
+
return rel if rel && rel != "."
|
|
78
|
+
|
|
79
|
+
home = Dir.home
|
|
80
|
+
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
81
|
+
rescue ArgumentError
|
|
82
|
+
path
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A chi --append-system-prompt value: [absolute path, nil], or
|
|
86
|
+
# [absolute path, the reason it can't be used].
|
|
87
|
+
# @param cwd [String] the shell's cwd the value is relative to
|
|
88
|
+
# @return [Array(String, String)]
|
|
89
|
+
def check_cli_file(value, cwd: Dir.pwd)
|
|
90
|
+
begin
|
|
91
|
+
path = File.expand_path(value.to_s, cwd)
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
# A quoted "~nosuchuser/x" reaches chi unexpanded.
|
|
94
|
+
return [value.to_s, "no such user"]
|
|
95
|
+
end
|
|
96
|
+
reason = if !File.exist?(path) then "no such file"
|
|
97
|
+
elsif !File.file?(path) then "not a file"
|
|
98
|
+
elsif !File.readable?(path) then "can't be read"
|
|
99
|
+
end
|
|
100
|
+
[path, reason]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Text this long goes into every request: chi warns once, at start.
|
|
104
|
+
LARGE_CHARS = 32_000
|
|
105
|
+
|
|
106
|
+
# The number of chars of +path+ when above LARGE_CHARS, else nil.
|
|
107
|
+
# @return [Integer, nil]
|
|
108
|
+
def large_size(path)
|
|
109
|
+
chars = File.read(path, encoding: "UTF-8").length
|
|
110
|
+
chars > LARGE_CHARS ? chars : nil
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# +path+'s text under +header+ ("<header>\n<text>", stripped); nil when
|
|
116
|
+
# the file is empty or can't be read.
|
|
117
|
+
# @return [String, nil]
|
|
118
|
+
def section(path, header)
|
|
119
|
+
content = File.read(path, encoding: "UTF-8").strip
|
|
120
|
+
return nil if content.empty?
|
|
121
|
+
|
|
122
|
+
"#{header}\n#{content}"
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# One thing a click on a ref can do besides the kind's own (the web keeps
|
|
7
|
+
# those per kind, refs.js REF_ACTIONS): open +url+ in a new tab, shown as
|
|
8
|
+
# +label+ in the ref's menu. A plugin names it ("PR diff", "Open in Jira").
|
|
9
|
+
# The URL is http(s) only, checked here and again in the page
|
|
10
|
+
# (refs.js httpUrl): the page opens it as is, so a javascript:, data: or
|
|
11
|
+
# editor URL never gets there.
|
|
12
|
+
RefAction = Data.define(:id, :label, :url) do
|
|
13
|
+
# @raise [ArgumentError] an empty id or label, one over its cap
|
|
14
|
+
# (MAX_ID, MAX_LABEL), or a URL that isn't an http(s) one with a host,
|
|
15
|
+
# at most MAX_URL characters
|
|
16
|
+
def initialize(id:, label:, url:)
|
|
17
|
+
id = id.to_s.strip
|
|
18
|
+
label = label.to_s.strip
|
|
19
|
+
url = url.to_s.strip
|
|
20
|
+
raise ArgumentError, "a ref action needs an id" if id.empty?
|
|
21
|
+
raise ArgumentError, "a ref action needs a label" if label.empty?
|
|
22
|
+
raise ArgumentError, "a ref action's id is at most #{RefAction::MAX_ID} characters" if id.length > RefAction::MAX_ID
|
|
23
|
+
raise ArgumentError, "a ref action's label is at most #{RefAction::MAX_LABEL} characters" if label.length > RefAction::MAX_LABEL
|
|
24
|
+
raise ArgumentError, "a ref action's url must be http(s) with a host, at most #{RefAction::MAX_URL} characters" unless RefAction.http_url?(url)
|
|
25
|
+
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class RefAction
|
|
31
|
+
MAX_URL = 2_000
|
|
32
|
+
MAX_ID = 64
|
|
33
|
+
MAX_LABEL = 100
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] +url+ is an http or https URL with a host and no
|
|
36
|
+
# userinfo (a "https://github.com@evil.example/" shows one host and
|
|
37
|
+
# opens another, and a token in it would be copied around)
|
|
38
|
+
def self.http_url?(url)
|
|
39
|
+
return false if url.length > MAX_URL
|
|
40
|
+
|
|
41
|
+
uri = URI.parse(url)
|
|
42
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
|
|
43
|
+
rescue URI::InvalidURIError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param value [RefAction, Hash] a plugin's {id:, label:, url:} (String
|
|
48
|
+
# or Symbol keys)
|
|
49
|
+
# @raise [ArgumentError] anything else, or an invalid action
|
|
50
|
+
def self.from(value)
|
|
51
|
+
return value if value.is_a?(RefAction)
|
|
52
|
+
raise ArgumentError, "a ref action is a Hash {id:, label:, url:}" unless value.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
value = value.transform_keys(&:to_sym)
|
|
55
|
+
new(id: value[:id], label: value[:label], url: value[:url])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A ref as data: a kind, a target, an optional label and the plugins'
|
|
60
|
+
# extra actions (RefAction), for a UI to trace it. The kind's own actions
|
|
61
|
+
# are the UI's (a file ref keeps the editor, show in turn and copy), so
|
|
62
|
+
# +actions+ only adds to them.
|
|
63
|
+
# - +kind+ "file": +target+ {path:, line:, end_line:}, the lines only when
|
|
64
|
+
# positive, the end only past the start (FileRef's shape).
|
|
65
|
+
# - Any other kind ("issue", "commit", …): +target+ {id:}. The page shows
|
|
66
|
+
# its +label+ (or the text it matched) and runs its first action.
|
|
67
|
+
# +to_h+ is the shape the page reads (refs.js bindRef). FileRef stays the
|
|
68
|
+
# rows' own ref; this is the one a plugin gives.
|
|
69
|
+
Ref = Data.define(:kind, :target, :label, :actions) do
|
|
70
|
+
# @raise [ArgumentError] a kind that isn't a short lowercase word, a
|
|
71
|
+
# target without its kind's key, a non-list of actions, a bad action,
|
|
72
|
+
# or a value over its cap (MAX_LABEL, MAX_ACTIONS, MAX_ID, MAX_PATH)
|
|
73
|
+
def initialize(kind:, target:, label: nil, actions: [])
|
|
74
|
+
kind = kind.to_s
|
|
75
|
+
raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
|
|
76
|
+
raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
|
|
77
|
+
raise ArgumentError, "a ref has at most #{Ref::MAX_ACTIONS} actions" if Array(actions).length > Ref::MAX_ACTIONS
|
|
78
|
+
|
|
79
|
+
label = label.to_s.strip
|
|
80
|
+
raise ArgumentError, "a ref's label is at most #{Ref::MAX_LABEL} characters" if label.length > Ref::MAX_LABEL
|
|
81
|
+
|
|
82
|
+
super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
|
|
83
|
+
actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Hash] {kind:, target:, label:, actions: [{id:, label:, url:}]},
|
|
87
|
+
# no label when there is none
|
|
88
|
+
def to_h = { kind: kind, target: target, label: label, actions: actions.map(&:to_h) }.compact
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Ref
|
|
92
|
+
KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
93
|
+
# FileRef::KIND, not required here (it loads the file tools).
|
|
94
|
+
FILE = "file"
|
|
95
|
+
# Caps, so a plugin can't bloat session.json or the page.
|
|
96
|
+
MAX_LABEL = 200
|
|
97
|
+
MAX_ACTIONS = 8
|
|
98
|
+
MAX_ID = 200
|
|
99
|
+
MAX_PATH = 4_096
|
|
100
|
+
|
|
101
|
+
# A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
|
|
102
|
+
# @raise [ArgumentError] as +new+
|
|
103
|
+
def self.from(value)
|
|
104
|
+
raise ArgumentError, "a ref is a Hash {kind:, target:, label:, actions:}" unless value.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
value = value.transform_keys(&:to_sym)
|
|
107
|
+
new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# +kind+'s target from +raw+: a file's path and lines, any other kind's id.
|
|
111
|
+
# @raise [ArgumentError] not a Hash, or without the kind's key
|
|
112
|
+
def self.target_for(kind, raw)
|
|
113
|
+
raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
raw = raw.transform_keys(&:to_sym)
|
|
116
|
+
kind == FILE ? file_target(raw) : { id: required(raw[:id], :id, MAX_ID) }.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.file_target(raw)
|
|
120
|
+
line = positive(raw[:line])
|
|
121
|
+
end_line = line && positive(raw[:end_line])
|
|
122
|
+
{ path: required(raw[:path], :path, MAX_PATH), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
|
|
123
|
+
end
|
|
124
|
+
private_class_method :file_target
|
|
125
|
+
|
|
126
|
+
def self.required(value, key, max)
|
|
127
|
+
value = value.to_s.strip
|
|
128
|
+
raise ArgumentError, "a ref's target needs #{key}" if value.empty?
|
|
129
|
+
raise ArgumentError, "a ref's target #{key} is at most #{max} characters" if value.length > max
|
|
130
|
+
|
|
131
|
+
value
|
|
132
|
+
end
|
|
133
|
+
private_class_method :required
|
|
134
|
+
|
|
135
|
+
def self.positive(value)
|
|
136
|
+
number = Integer(value.to_s.strip, exception: false)
|
|
137
|
+
number&.positive? ? number : nil
|
|
138
|
+
end
|
|
139
|
+
private_class_method :positive
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -106,10 +106,14 @@ module Samagotchi
|
|
|
106
106
|
|
|
107
107
|
if session.status == Session::STATUS_RUNNING
|
|
108
108
|
seen_running = true
|
|
109
|
-
elsif seen_running || turn_ended_since?(session, baseline)
|
|
109
|
+
elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
|
|
110
110
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
111
111
|
# empty. Before it was ever seen running, idle means a
|
|
112
|
-
# file-delivered message its worker has not picked up yet.
|
|
112
|
+
# file-delivered message its worker has not picked up yet. With
|
|
113
|
+
# the baseline's last_turn only a turn that ended since counts:
|
|
114
|
+
# a session left running by a worker that died goes idle when the
|
|
115
|
+
# next worker settles that turn (TurnCheckpoint.settle), before
|
|
116
|
+
# the sent message's turn runs.
|
|
113
117
|
# The worker writes the reply before its idle save, so the read
|
|
114
118
|
# above has it; one more look costs nothing should that change.
|
|
115
119
|
return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "context_note"
|
|
9
|
+
require_relative "interrupted_turn"
|
|
9
10
|
require_relative "last_turn"
|
|
10
11
|
require_relative "llm_context_override"
|
|
11
12
|
require_relative "session_setup"
|
|
@@ -53,6 +54,15 @@ module Samagotchi
|
|
|
53
54
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
54
55
|
# Names as given; the engine normalizes them.
|
|
55
56
|
attr_accessor :preloaded_memory_names, :muted_memory_names
|
|
57
|
+
# The files chi --append-system-prompt put into the session's system
|
|
58
|
+
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
59
|
+
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
60
|
+
# (SessionSetup).
|
|
61
|
+
attr_reader :appended_prompt_files
|
|
62
|
+
|
|
63
|
+
def appended_prompt_files=(paths)
|
|
64
|
+
@appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
|
|
65
|
+
end
|
|
56
66
|
# The model notes the session's system prompt carried at its last build
|
|
57
67
|
# (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
|
|
58
68
|
# Engine at each build (a start, a resume's or a woken worker's first
|
|
@@ -98,6 +108,26 @@ module Samagotchi
|
|
|
98
108
|
def last_turn=(value)
|
|
99
109
|
@last_turn = LastTurn.from_file(value)
|
|
100
110
|
end
|
|
111
|
+
|
|
112
|
+
# The mark of a turn a worker checkpointed and never finished
|
|
113
|
+
# (InterruptedTurn), as loaded; the next worker settles it and sets it
|
|
114
|
+
# to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
|
|
115
|
+
attr_reader :interrupted_turn
|
|
116
|
+
|
|
117
|
+
# @param value [InterruptedTurn, Hash, nil]
|
|
118
|
+
def interrupted_turn=(value)
|
|
119
|
+
@interrupted_turn = InterruptedTurn.from_file(value)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
|
|
123
|
+
# (marker an InterruptedTurn). While +status+ is running, every save
|
|
124
|
+
# writes these messages and the marker instead of #messages and
|
|
125
|
+
# #interrupted_turn, so a save in the middle of the turn (a question
|
|
126
|
+
# asked, the checkpoint's own) keeps the turn so far on disk; #messages
|
|
127
|
+
# itself is never touched. The turn's end sets status idle before it
|
|
128
|
+
# saves, so that save writes the real messages, without a marker. Not
|
|
129
|
+
# saved; the worker clears it after each turn.
|
|
130
|
+
attr_accessor :turn_checkpoint
|
|
101
131
|
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
102
132
|
# apply rule, budget), before the model's; nil when it has none.
|
|
103
133
|
# Saved, so a --resume and a respawned worker keep them; a plugin's
|
|
@@ -182,7 +212,8 @@ module Samagotchi
|
|
|
182
212
|
used_memory_names: [], project_root: nil,
|
|
183
213
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
184
214
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
185
|
-
continues: nil, thinking: nil, reasoning_history: nil
|
|
215
|
+
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
216
|
+
interrupted_turn: nil)
|
|
186
217
|
@id = id
|
|
187
218
|
@metadata_version = metadata_version
|
|
188
219
|
@mode = mode
|
|
@@ -210,6 +241,9 @@ module Samagotchi
|
|
|
210
241
|
self.llm_context = llm_context
|
|
211
242
|
self.thinking = thinking
|
|
212
243
|
self.reasoning_history = reasoning_history
|
|
244
|
+
self.appended_prompt_files = appended_prompt_files
|
|
245
|
+
self.interrupted_turn = interrupted_turn
|
|
246
|
+
@turn_checkpoint = nil
|
|
213
247
|
@archived = false
|
|
214
248
|
end
|
|
215
249
|
|
|
@@ -289,6 +323,7 @@ module Samagotchi
|
|
|
289
323
|
llm_context: setup.llm_context,
|
|
290
324
|
thinking: setup.thinking,
|
|
291
325
|
reasoning_history: setup.reasoning_history,
|
|
326
|
+
appended_prompt_files: setup.appended_prompt_files,
|
|
292
327
|
continues: continues
|
|
293
328
|
)
|
|
294
329
|
end
|
|
@@ -351,7 +386,9 @@ module Samagotchi
|
|
|
351
386
|
"llm_context" => nil,
|
|
352
387
|
"continues" => nil,
|
|
353
388
|
"thinking" => nil,
|
|
354
|
-
"reasoning_history" => nil
|
|
389
|
+
"reasoning_history" => nil,
|
|
390
|
+
"appended_prompt_files" => [],
|
|
391
|
+
"interrupted_turn" => nil
|
|
355
392
|
}.freeze
|
|
356
393
|
|
|
357
394
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -463,11 +500,15 @@ module Samagotchi
|
|
|
463
500
|
self
|
|
464
501
|
end
|
|
465
502
|
|
|
466
|
-
# The session as #save writes it (FIELDS, in their order)
|
|
503
|
+
# The session as #save writes it (FIELDS, in their order); a running
|
|
504
|
+
# turn's checkpoint (#turn_checkpoint) in place of its messages.
|
|
467
505
|
def to_h
|
|
506
|
+
checkpoint = checkpointed
|
|
468
507
|
FIELDS.keys.to_h do |key|
|
|
469
508
|
value = case key
|
|
470
|
-
when "messages"
|
|
509
|
+
when "messages"
|
|
510
|
+
(checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
511
|
+
when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
|
|
471
512
|
when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
|
|
472
513
|
when "test_run" then !!@test_run
|
|
473
514
|
when "delegate" then @delegate
|
|
@@ -476,7 +517,7 @@ module Samagotchi
|
|
|
476
517
|
when "reasoning_history" then reasoning_history_file
|
|
477
518
|
when "prompt_notes" then @prompt_notes.map(&:to_file)
|
|
478
519
|
when "last_turn" then @last_turn&.to_file
|
|
479
|
-
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
520
|
+
when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
|
|
480
521
|
Array(instance_variable_get(:"@#{key}"))
|
|
481
522
|
else instance_variable_get(:"@#{key}")
|
|
482
523
|
end
|
|
@@ -484,6 +525,13 @@ module Samagotchi
|
|
|
484
525
|
end
|
|
485
526
|
end
|
|
486
527
|
|
|
528
|
+
# The running turn's checkpoint, while the turn runs (#turn_checkpoint).
|
|
529
|
+
def checkpointed
|
|
530
|
+
checkpoint = @turn_checkpoint
|
|
531
|
+
checkpoint if checkpoint && @status == STATUS_RUNNING
|
|
532
|
+
end
|
|
533
|
+
private :checkpointed
|
|
534
|
+
|
|
487
535
|
# Mark a session as errored.
|
|
488
536
|
def self.mark_error(session_id, reason:, state_dir: default_state_dir)
|
|
489
537
|
session = load(session_id, state_dir: state_dir)
|
|
@@ -22,6 +22,7 @@ require_relative "turn_note"
|
|
|
22
22
|
require_relative "owner_lock"
|
|
23
23
|
require_relative "bridge_client"
|
|
24
24
|
require_relative "worker_sidecar"
|
|
25
|
+
require_relative "worker_signals"
|
|
25
26
|
require_relative "log"
|
|
26
27
|
require_relative "log_path"
|
|
27
28
|
require_relative "model_profile"
|
|
@@ -210,7 +211,8 @@ module Samagotchi
|
|
|
210
211
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
211
212
|
private_class_method def self.spawn_options(session)
|
|
212
213
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
213
|
-
# terminal must not reach them
|
|
214
|
+
# terminal must not reach them (the worker then leaves the process
|
|
215
|
+
# tree too: WORKER_DETACH_ENV).
|
|
214
216
|
opts = { out: File::NULL, err: File::NULL, pgroup: true }
|
|
215
217
|
# The worker's tools (and `!cmd`) run in the session's directory, not
|
|
216
218
|
# in the cwd of whoever woke it (`chi web`, another terminal).
|
|
@@ -253,6 +255,10 @@ module Samagotchi
|
|
|
253
255
|
SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
|
|
254
256
|
# A smoke's fake "newest installed" is for this process's notices only.
|
|
255
257
|
child_env[InstalledVersions::ENV_KEY] = nil
|
|
258
|
+
# A worker process: run_session_loop traps signals, and leaves this
|
|
259
|
+
# process's tree.
|
|
260
|
+
child_env[WORKER_PROCESS_ENV] = "1"
|
|
261
|
+
child_env[WORKER_DETACH_ENV] = "1"
|
|
256
262
|
# A gem-mode worker activates the newest installed chi itself: under
|
|
257
263
|
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
258
264
|
# hold it to the bundle's version, and its badge would never clear.
|
|
@@ -688,8 +694,8 @@ module Samagotchi
|
|
|
688
694
|
# so a resume right after spawns a fresh worker instead of finding the
|
|
689
695
|
# dying one.
|
|
690
696
|
# A turn still running is canceled first (STOP_CANCEL_WAIT), as the
|
|
691
|
-
# web's Cancel does: its
|
|
692
|
-
#
|
|
697
|
+
# web's Cancel does: its worker saves it. The TERM stops (and saves)
|
|
698
|
+
# what still runs (WorkerSignals), with a short grace after the marker.
|
|
693
699
|
# @return [Boolean, nil] with +wait+: whether the owner was gone in time
|
|
694
700
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
695
701
|
def self.stop_session(session_id, state_dir: nil, wait: nil)
|
|
@@ -930,7 +936,8 @@ module Samagotchi
|
|
|
930
936
|
# @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
|
|
931
937
|
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
932
938
|
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
933
|
-
# A stopped session's worker exits 0
|
|
939
|
+
# A stopped session's worker exits 0 (a signalled one too) and a
|
|
940
|
+
# crashed one 1, there.
|
|
934
941
|
# @return [Symbol] :idle_exit, :exit_requested or :restart
|
|
935
942
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
936
943
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
@@ -942,6 +949,12 @@ module Samagotchi
|
|
|
942
949
|
Log.configure(stderr: false)
|
|
943
950
|
Log.session_id = session_id
|
|
944
951
|
session_dir = Session.session_dir(session_id, state_dir: sd)
|
|
952
|
+
# Read (and taken out of ENV, so nothing the worker runs inherits it)
|
|
953
|
+
# before anything else: set by spawn_options for a spawned worker.
|
|
954
|
+
spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
|
|
955
|
+
# Out of the spawner's process tree before the lock (it records this
|
|
956
|
+
# process's pid) and before any thread starts.
|
|
957
|
+
detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
|
|
945
958
|
# Kept in a class ivar so the lock's File lives as long as the worker.
|
|
946
959
|
@owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
|
|
947
960
|
exit(0) unless @owner_lock
|
|
@@ -949,8 +962,11 @@ module Samagotchi
|
|
|
949
962
|
fallback = ENV.delete(BOOT_FALLBACK_ENV)
|
|
950
963
|
Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
|
|
951
964
|
Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
|
|
965
|
+
# A spawned worker stops its turn on TERM/HUP/INT and saves it; a
|
|
966
|
+
# loop run in-process (specs) traps nothing.
|
|
967
|
+
signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
|
|
952
968
|
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
953
|
-
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
|
|
969
|
+
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals)
|
|
954
970
|
result = begin
|
|
955
971
|
worker.run
|
|
956
972
|
rescue StandardError, ScriptError => e
|
|
@@ -959,11 +975,19 @@ module Samagotchi
|
|
|
959
975
|
raise
|
|
960
976
|
ensure
|
|
961
977
|
@owner_lock.release
|
|
978
|
+
# A signal from here on ends the process as Ruby's default does.
|
|
979
|
+
signals.restore!
|
|
962
980
|
end
|
|
963
981
|
Log.info(:worker, "stop", reason: result)
|
|
964
982
|
# A stopped session is left as it is: no resume for input that came
|
|
965
|
-
# in, no discard.
|
|
983
|
+
# in, no discard. One whose worker a signal stopped isn't discarded
|
|
984
|
+
# either; input that came in (queued behind the turn it stopped)
|
|
985
|
+
# wakes a new worker, as after an idle exit.
|
|
966
986
|
exit(0) if result == :stopped
|
|
987
|
+
if result == :signal
|
|
988
|
+
resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
|
|
989
|
+
exit(0)
|
|
990
|
+
end
|
|
967
991
|
exit(1) if result == :crashed
|
|
968
992
|
# Only after the release: a writer that still saw this worker as the
|
|
969
993
|
# owner may have queued input since the last check. Either it finds no
|
|
@@ -981,6 +1005,31 @@ module Samagotchi
|
|
|
981
1005
|
result
|
|
982
1006
|
end
|
|
983
1007
|
|
|
1008
|
+
# A spawned worker leaves its spawner's process tree: a killer that
|
|
1009
|
+
# walks the tree by parent pid (a terminal closing, a tool's teardown
|
|
1010
|
+
# of a `chi send --wait` it ran in the background) no longer reaches it.
|
|
1011
|
+
# This process (the spawner's child, which its Process.detach waits on)
|
|
1012
|
+
# forks the worker, which starts a session of its own (setsid: no
|
|
1013
|
+
# controlling terminal, no SIGHUP from one) and returns to run the loop;
|
|
1014
|
+
# this one waits until the worker holds the session's OwnerLock, or has
|
|
1015
|
+
# exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
|
|
1016
|
+
# stays alive exactly while the worker starts (#still_starting?). The
|
|
1017
|
+
# worker is reparented to init (launchd), which reaps it.
|
|
1018
|
+
private_class_method def self.detach_from_spawner(session_dir)
|
|
1019
|
+
worker = Process.fork
|
|
1020
|
+
unless worker
|
|
1021
|
+
Process.setsid
|
|
1022
|
+
return
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
|
|
1026
|
+
until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
|
|
1027
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
1028
|
+
sleep(OwnerLock::RETRY_INTERVAL)
|
|
1029
|
+
end
|
|
1030
|
+
Process.exit!(0)
|
|
1031
|
+
end
|
|
1032
|
+
|
|
984
1033
|
# Delete a session its worker left empty. Checked again now the lock is
|
|
985
1034
|
# free: a note or input may have come in since the worker looked, and a
|
|
986
1035
|
# worker woken meanwhile owns it (delete_session refuses). A `chi send`
|
|
@@ -1024,6 +1073,13 @@ module Samagotchi
|
|
|
1024
1073
|
# format are read by a NEWER chi than the one writing them. Change them
|
|
1025
1074
|
# only additively (spec/worker_contract_spec.rb).
|
|
1026
1075
|
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
1076
|
+
# "1" in a worker spawn_options started: it traps signals (WorkerSignals).
|
|
1077
|
+
# run_session_loop deletes it from ENV as it reads it.
|
|
1078
|
+
WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
|
|
1079
|
+
# "1" in a worker spawn_options started: run_session_loop leaves the
|
|
1080
|
+
# spawner's process tree first (detach_from_spawner); deleted from ENV
|
|
1081
|
+
# as it is read, so an rspec run by the worker's execute never forks.
|
|
1082
|
+
WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
|
|
1027
1083
|
|
|
1028
1084
|
def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
|
|
1029
1085
|
boot = "require 'samagotchi/session_manager'; " \
|
|
@@ -1046,6 +1102,8 @@ module Samagotchi
|
|
|
1046
1102
|
command = worker_command(session.id, state_dir: state_dir)
|
|
1047
1103
|
pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
|
|
1048
1104
|
# Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
|
|
1105
|
+
# +pid+ is the worker's starter, which exits once the worker it forked
|
|
1106
|
+
# holds the lock (detach_from_spawner): the lock's pid is the worker's.
|
|
1049
1107
|
note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
|
|
1050
1108
|
Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
|
|
1051
1109
|
pid
|
|
@@ -142,9 +142,13 @@ module Samagotchi
|
|
|
142
142
|
:cuts,
|
|
143
143
|
:capped,
|
|
144
144
|
:model,
|
|
145
|
-
#
|
|
146
|
-
# Continue
|
|
145
|
+
# A turn with no prompt of its own (:turn_started's +continue+): a
|
|
146
|
+
# step-limit Continue, a reminder's or a wake's turn; its record
|
|
147
|
+
# carries it.
|
|
147
148
|
:continue,
|
|
149
|
+
# The turn the user started by answering the step-limit question with
|
|
150
|
+
# Continue (:turn_started's +resumed+): it resumed the run before it.
|
|
151
|
+
:resumed,
|
|
148
152
|
:id,
|
|
149
153
|
:started_at,
|
|
150
154
|
:started_monotonic,
|
|
@@ -558,6 +562,7 @@ module Samagotchi
|
|
|
558
562
|
capped: 0,
|
|
559
563
|
model: nil,
|
|
560
564
|
continue: event[:continue] ? true : false,
|
|
565
|
+
resumed: event[:resumed] ? true : false,
|
|
561
566
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
562
567
|
started_at: now.iso8601(3),
|
|
563
568
|
started_monotonic: monotonic_time,
|
|
@@ -597,6 +602,7 @@ module Samagotchi
|
|
|
597
602
|
# Every record says, so the report can tell a file with no Continue
|
|
598
603
|
# from one written before the mark.
|
|
599
604
|
record[:continue] = @turn.continue
|
|
605
|
+
record[:resumed] = @turn.resumed
|
|
600
606
|
record.merge!(turn_token_fields(@turn))
|
|
601
607
|
@turn_records << record
|
|
602
608
|
count_turn(record)
|
|
@@ -10,15 +10,20 @@ module Samagotchi
|
|
|
10
10
|
# (SessionManager.spawn_session, a continue, a plugin's fork, the web's
|
|
11
11
|
# create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
|
|
12
12
|
# +thinking+ a level Symbol (Thinking.session_level) or nil,
|
|
13
|
-
# +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil
|
|
13
|
+
# +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil,
|
|
14
|
+
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
15
|
+
# named (their text goes into the system prompt; [] with none).
|
|
14
16
|
#
|
|
15
17
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
16
18
|
# and file key, and resolves it on its own. A delegate child starts
|
|
17
19
|
# without one; a continue and a fork copy their session's (.of).
|
|
18
|
-
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history) do
|
|
19
|
-
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil
|
|
20
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files) do
|
|
21
|
+
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
|
|
22
|
+
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
23
|
+
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze)
|
|
24
|
+
end
|
|
20
25
|
|
|
21
|
-
def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil?
|
|
26
|
+
def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty?
|
|
22
27
|
|
|
23
28
|
# A session's own setup, for a session that starts from it.
|
|
24
29
|
# @param session [Session, nil]
|
|
@@ -26,7 +31,8 @@ module Samagotchi
|
|
|
26
31
|
def self.of(session)
|
|
27
32
|
return new if session.nil?
|
|
28
33
|
|
|
29
|
-
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history
|
|
34
|
+
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
35
|
+
appended_prompt_files: session.appended_prompt_files)
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
end
|