samagotchi 0.58.0 → 0.60.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 +86 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +6 -4
- data/docs/cli.md +14 -7
- data/docs/configuration.md +96 -31
- data/docs/hooks.md +12 -6
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +20 -12
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +4 -3
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +75 -6
- data/docs/sessions.md +2 -2
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- 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 +44 -10
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +89 -15
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +35 -9
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +11 -6
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +124 -0
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +15 -3
- data/lib/samagotchi/session_commands.rb +15 -3
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +74 -18
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
- data/lib/samagotchi/terminal_ui.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/refs.js +107 -15
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -1
|
@@ -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,124 @@
|
|
|
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, or a URL that isn't an
|
|
14
|
+
# http(s) one with a host, at most MAX_URL characters
|
|
15
|
+
def initialize(id:, label:, url:)
|
|
16
|
+
id = id.to_s.strip
|
|
17
|
+
label = label.to_s.strip
|
|
18
|
+
url = url.to_s.strip
|
|
19
|
+
raise ArgumentError, "a ref action needs an id" if id.empty?
|
|
20
|
+
raise ArgumentError, "a ref action needs a label" if label.empty?
|
|
21
|
+
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)
|
|
22
|
+
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class RefAction
|
|
28
|
+
MAX_URL = 2_000
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] +url+ is an http or https URL with a host
|
|
31
|
+
def self.http_url?(url)
|
|
32
|
+
return false if url.length > MAX_URL
|
|
33
|
+
|
|
34
|
+
uri = URI.parse(url)
|
|
35
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
36
|
+
rescue URI::InvalidURIError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param value [RefAction, Hash] a plugin's {id:, label:, url:} (String
|
|
41
|
+
# or Symbol keys)
|
|
42
|
+
# @raise [ArgumentError] anything else, or an invalid action
|
|
43
|
+
def self.from(value)
|
|
44
|
+
return value if value.is_a?(RefAction)
|
|
45
|
+
raise ArgumentError, "a ref action is a Hash {id:, label:, url:}" unless value.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
value = value.transform_keys(&:to_sym)
|
|
48
|
+
new(id: value[:id], label: value[:label], url: value[:url])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A ref as data: a kind, a target, an optional label and the plugins'
|
|
53
|
+
# extra actions (RefAction), for a UI to trace it. The kind's own actions
|
|
54
|
+
# are the UI's (a file ref keeps the editor, show in turn and copy), so
|
|
55
|
+
# +actions+ only adds to them.
|
|
56
|
+
# - +kind+ "file": +target+ {path:, line:, end_line:}, the lines only when
|
|
57
|
+
# positive, the end only past the start (FileRef's shape).
|
|
58
|
+
# - Any other kind ("issue", "commit", …): +target+ {id:}. The page shows
|
|
59
|
+
# its +label+ (or the text it matched) and runs its first action.
|
|
60
|
+
# +to_h+ is the shape the page reads (refs.js bindRef). FileRef stays the
|
|
61
|
+
# rows' own ref; this is the one a plugin gives.
|
|
62
|
+
Ref = Data.define(:kind, :target, :label, :actions) do
|
|
63
|
+
# @raise [ArgumentError] a kind that isn't a short lowercase word, a
|
|
64
|
+
# target without its kind's key, a non-list of actions or a bad action
|
|
65
|
+
def initialize(kind:, target:, label: nil, actions: [])
|
|
66
|
+
kind = kind.to_s
|
|
67
|
+
raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
|
|
68
|
+
raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
label = label.to_s.strip
|
|
71
|
+
super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
|
|
72
|
+
actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash] {kind:, target:, label:, actions: [{id:, label:, url:}]},
|
|
76
|
+
# no label when there is none
|
|
77
|
+
def to_h = { kind: kind, target: target, label: label, actions: actions.map(&:to_h) }.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Ref
|
|
81
|
+
KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
82
|
+
# FileRef::KIND, not required here (it loads the file tools).
|
|
83
|
+
FILE = "file"
|
|
84
|
+
|
|
85
|
+
# A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
|
|
86
|
+
# @raise [ArgumentError] as +new+
|
|
87
|
+
def self.from(value)
|
|
88
|
+
raise ArgumentError, "a ref is a Hash {kind:, target:, label:, actions:}" unless value.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
value = value.transform_keys(&:to_sym)
|
|
91
|
+
new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# +kind+'s target from +raw+: a file's path and lines, any other kind's id.
|
|
95
|
+
# @raise [ArgumentError] not a Hash, or without the kind's key
|
|
96
|
+
def self.target_for(kind, raw)
|
|
97
|
+
raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
raw = raw.transform_keys(&:to_sym)
|
|
100
|
+
kind == FILE ? file_target(raw) : { id: required(raw[:id], :id) }.freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.file_target(raw)
|
|
104
|
+
line = positive(raw[:line])
|
|
105
|
+
end_line = line && positive(raw[:end_line])
|
|
106
|
+
{ path: required(raw[:path], :path), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
|
|
107
|
+
end
|
|
108
|
+
private_class_method :file_target
|
|
109
|
+
|
|
110
|
+
def self.required(value, key)
|
|
111
|
+
value = value.to_s.strip
|
|
112
|
+
raise ArgumentError, "a ref's target needs #{key}" if value.empty?
|
|
113
|
+
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
private_class_method :required
|
|
117
|
+
|
|
118
|
+
def self.positive(value)
|
|
119
|
+
number = Integer(value.to_s.strip, exception: false)
|
|
120
|
+
number&.positive? ? number : nil
|
|
121
|
+
end
|
|
122
|
+
private_class_method :positive
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -586,7 +586,7 @@ module Samagotchi
|
|
|
586
586
|
outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
|
|
587
587
|
return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
|
|
588
588
|
|
|
589
|
-
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
|
|
589
|
+
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
|
|
590
590
|
end
|
|
591
591
|
|
|
592
592
|
# More keys for --format json, with --cut only (the line is unchanged
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -53,6 +53,15 @@ module Samagotchi
|
|
|
53
53
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
54
54
|
# Names as given; the engine normalizes them.
|
|
55
55
|
attr_accessor :preloaded_memory_names, :muted_memory_names
|
|
56
|
+
# The files chi --append-system-prompt put into the session's system
|
|
57
|
+
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
58
|
+
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
59
|
+
# (SessionSetup).
|
|
60
|
+
attr_reader :appended_prompt_files
|
|
61
|
+
|
|
62
|
+
def appended_prompt_files=(paths)
|
|
63
|
+
@appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
|
|
64
|
+
end
|
|
56
65
|
# The model notes the session's system prompt carried at its last build
|
|
57
66
|
# (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
|
|
58
67
|
# Engine at each build (a start, a resume's or a woken worker's first
|
|
@@ -182,7 +191,7 @@ module Samagotchi
|
|
|
182
191
|
used_memory_names: [], project_root: nil,
|
|
183
192
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
184
193
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
185
|
-
continues: nil, thinking: nil, reasoning_history: nil)
|
|
194
|
+
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
|
|
186
195
|
@id = id
|
|
187
196
|
@metadata_version = metadata_version
|
|
188
197
|
@mode = mode
|
|
@@ -210,6 +219,7 @@ module Samagotchi
|
|
|
210
219
|
self.llm_context = llm_context
|
|
211
220
|
self.thinking = thinking
|
|
212
221
|
self.reasoning_history = reasoning_history
|
|
222
|
+
self.appended_prompt_files = appended_prompt_files
|
|
213
223
|
@archived = false
|
|
214
224
|
end
|
|
215
225
|
|
|
@@ -289,6 +299,7 @@ module Samagotchi
|
|
|
289
299
|
llm_context: setup.llm_context,
|
|
290
300
|
thinking: setup.thinking,
|
|
291
301
|
reasoning_history: setup.reasoning_history,
|
|
302
|
+
appended_prompt_files: setup.appended_prompt_files,
|
|
292
303
|
continues: continues
|
|
293
304
|
)
|
|
294
305
|
end
|
|
@@ -351,7 +362,8 @@ module Samagotchi
|
|
|
351
362
|
"llm_context" => nil,
|
|
352
363
|
"continues" => nil,
|
|
353
364
|
"thinking" => nil,
|
|
354
|
-
"reasoning_history" => nil
|
|
365
|
+
"reasoning_history" => nil,
|
|
366
|
+
"appended_prompt_files" => []
|
|
355
367
|
}.freeze
|
|
356
368
|
|
|
357
369
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -476,7 +488,7 @@ module Samagotchi
|
|
|
476
488
|
when "reasoning_history" then reasoning_history_file
|
|
477
489
|
when "prompt_notes" then @prompt_notes.map(&:to_file)
|
|
478
490
|
when "last_turn" then @last_turn&.to_file
|
|
479
|
-
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
491
|
+
when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
|
|
480
492
|
Array(instance_variable_get(:"@#{key}"))
|
|
481
493
|
else instance_variable_get(:"@#{key}")
|
|
482
494
|
end
|
|
@@ -328,7 +328,9 @@ module Samagotchi
|
|
|
328
328
|
command = text.delete_prefix(SHELL_BANG_PREFIX).strip
|
|
329
329
|
return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
# Killed at the limit, never handed over: the output goes in a user
|
|
332
|
+
# message, which no one reads a task id from (created_ids_in).
|
|
333
|
+
output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset, on_timeout: :kill)
|
|
332
334
|
@engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
|
|
333
335
|
# Rolling back past this would silently drop the command output.
|
|
334
336
|
@turn_flow.note_conversation_changed
|
|
@@ -484,9 +486,9 @@ module Samagotchi
|
|
|
484
486
|
def model_command(input)
|
|
485
487
|
suffix = input.delete_prefix(MODEL_COMMAND).strip
|
|
486
488
|
if suffix.empty?
|
|
487
|
-
return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
|
|
489
|
+
return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
|
|
488
490
|
|
|
489
|
-
return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
|
|
491
|
+
return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
|
|
490
492
|
end
|
|
491
493
|
|
|
492
494
|
# Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
|
|
@@ -606,6 +608,16 @@ module Samagotchi
|
|
|
606
608
|
""
|
|
607
609
|
end
|
|
608
610
|
|
|
611
|
+
# "; steer cut: after 60 s (models: qwen)", or "; steer cut: never
|
|
612
|
+
# (hosts.splash)" for 0 or less, when the model has one configured;
|
|
613
|
+
# "" for the default.
|
|
614
|
+
def steer_cut_after_note
|
|
615
|
+
summary = @engine.steer_cut_summary
|
|
616
|
+
summary ? "; steer cut: #{summary}" : ""
|
|
617
|
+
rescue StandardError
|
|
618
|
+
""
|
|
619
|
+
end
|
|
620
|
+
|
|
609
621
|
# "; notes: model_notes_deepseek (system, 612 chars)": the model notes
|
|
610
622
|
# the session's prompt carries (after a switch, the ones the new
|
|
611
623
|
# model's prompt loads); "" without any.
|
|
@@ -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
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -30,19 +30,23 @@ module Samagotchi
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# +clock+: monotonic seconds; +controller+: the running turn's cancel
|
|
33
|
-
# controller (nil between turns), read only when a cut is due
|
|
34
|
-
|
|
33
|
+
# controller (nil between turns), read only when a cut is due;
|
|
34
|
+
# +cut_after+: seconds a thinking-only generation may stream before a
|
|
35
|
+
# cut, called on every check (the Engine passes the model's own
|
|
36
|
+
# SteerCutAfter; the global steer.cut_after here).
|
|
37
|
+
def initialize(clock:, controller:, cut_after: -> { self.class.cut_after })
|
|
35
38
|
# What the running generation streams: whether a steer may cut it.
|
|
36
39
|
@phase = GenerationPhase.new(clock: clock)
|
|
37
40
|
# A message that may cut it but came too early: it cuts later.
|
|
38
41
|
@waiting = WaitingSteer.new
|
|
42
|
+
@cut_after = cut_after
|
|
39
43
|
@controller = controller
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
# A message for the running turn from +source+ (Steer.source_for_client:
|
|
43
47
|
# nil for the user, "chi_send", "parent_agent") cuts the streaming
|
|
44
|
-
# generation when it has streamed only thinking for
|
|
45
|
-
#
|
|
48
|
+
# generation when it has streamed only thinking for cut_after seconds
|
|
49
|
+
# (GenerationPhase): the loops' cut path (CutPolicy) then starts
|
|
46
50
|
# the step again with the message, no nudge, no retry spent. Too early,
|
|
47
51
|
# the message waits (WaitingSteer) and cuts once the thinking passes it
|
|
48
52
|
# (#recheck, on each thinking chunk), unless a boundary hands it to the
|
|
@@ -52,13 +56,13 @@ module Samagotchi
|
|
|
52
56
|
# before queueing it. Outside any lock; a cut that lands just after the
|
|
53
57
|
# generation ended is harmless (the loop re-asks).
|
|
54
58
|
# @return [Symbol] :now (a generation was cut now), :waits (the message
|
|
55
|
-
# waits for the thinking to pass
|
|
59
|
+
# waits for the thinking to pass cut_after), or :off (cutting
|
|
56
60
|
# off for this source: a plugin, no turn, cut_after 0, a generation
|
|
57
61
|
# that isn't thinking-only, or a drain already took the message)
|
|
58
62
|
def cut_for_steer(source, epoch: nil)
|
|
59
63
|
return :off unless Steer.cuts?(source)
|
|
60
64
|
|
|
61
|
-
after =
|
|
65
|
+
after = @cut_after.call.to_i
|
|
62
66
|
return :off unless after.positive?
|
|
63
67
|
|
|
64
68
|
ctrl = @controller.call
|
|
@@ -107,11 +111,11 @@ module Samagotchi
|
|
|
107
111
|
private
|
|
108
112
|
|
|
109
113
|
# A thinking chunk streamed: a waiting message cuts once the thinking
|
|
110
|
-
# passes
|
|
114
|
+
# passes cut_after.
|
|
111
115
|
def recheck
|
|
112
116
|
return unless @waiting.waiting?
|
|
113
117
|
|
|
114
|
-
after =
|
|
118
|
+
after = @cut_after.call.to_i
|
|
115
119
|
return unless after.positive? && @phase.cuttable?(after)
|
|
116
120
|
|
|
117
121
|
source = @waiting.take
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# How long a message sent as a cut waits for the generation it came during:
|
|
7
|
+
# the seconds a generation must have streamed only thinking before a cut may
|
|
8
|
+
# land (SteerCut), one per model, host or run, like Thinking for the level.
|
|
9
|
+
#
|
|
10
|
+
# Order (#resolve): the env (SAMAGOTCHI_STEER_CUT_AFTER), then
|
|
11
|
+
# models.<key>.steer_cut_after, hosts.<name>.steer_cut_after,
|
|
12
|
+
# steer.cut_after in config.yml, then 20. 0 = never cut (a slow local
|
|
13
|
+
# thinker is left alone); it is a value, not an unset.
|
|
14
|
+
module SteerCutAfter
|
|
15
|
+
KEY = "steer_cut_after"
|
|
16
|
+
ENV_KEY = "SAMAGOTCHI_STEER_CUT_AFTER"
|
|
17
|
+
DEFAULT = 20
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# A `steer_cut_after:` setting (hosts: entry or models: entry): whole
|
|
22
|
+
# seconds ≥ 0 (0 = never), nil when unset; anything else warns once,
|
|
23
|
+
# naming +where+, and counts as unset.
|
|
24
|
+
# @return [Integer, nil]
|
|
25
|
+
def seconds(value, where)
|
|
26
|
+
return nil if value.nil?
|
|
27
|
+
|
|
28
|
+
text = value.to_s.strip
|
|
29
|
+
return text.to_i if text.match?(/\A\d+\z/)
|
|
30
|
+
|
|
31
|
+
ConfigFile.warn_once "Warning: #{where}: steer_cut_after must be whole seconds (0 = never); ignored"
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The seconds for +target+ and where they came from
|
|
36
|
+
# ("SAMAGOTCHI_STEER_CUT_AFTER", "models: qwen", "hosts.splash",
|
|
37
|
+
# "steer.cut_after"; nil for the default).
|
|
38
|
+
# @param target [HostRegistry::ModelTarget]
|
|
39
|
+
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
40
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
41
|
+
# @return [Array(Integer, String|nil)]
|
|
42
|
+
def resolve(target, names:, models: nil)
|
|
43
|
+
value, origin = Config.get_with_origin("steer.cut_after")
|
|
44
|
+
return [value.to_i, ENV_KEY] if origin == :env
|
|
45
|
+
|
|
46
|
+
models ||= begin
|
|
47
|
+
ConfigFile.model_settings
|
|
48
|
+
rescue StandardError
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
setting = ConfigFile.model_setting(names, :steer_cut_after, models: models)
|
|
52
|
+
return [setting.value, "models: #{setting.key}"] if setting
|
|
53
|
+
|
|
54
|
+
host = target.entry.steer_cut_after
|
|
55
|
+
return [host, "hosts.#{target.entry.name}"] unless host.nil?
|
|
56
|
+
|
|
57
|
+
return [value.to_i, "steer.cut_after"] if origin == :file
|
|
58
|
+
|
|
59
|
+
[DEFAULT, nil]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# "after 60 s (models: qwen)"; 0 or less is "never (hosts.splash)".
|
|
63
|
+
# No source: just the seconds.
|
|
64
|
+
def label(seconds, source)
|
|
65
|
+
text = seconds.to_i <= 0 ? "never" : "after #{seconds} s"
|
|
66
|
+
source ? "#{text} (#{source})" : text
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|