samagotchi 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +1 -0
- data/docs/guardrails.md +44 -4
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/small-models.yml +27 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -2
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +9 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos.rb +2 -3
- data/lib/samagotchi/engine.rb +19 -7
- data/lib/samagotchi/guardrail_wiring.rb +6 -6
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/guardrails/gate.rb +7 -4
- data/lib/samagotchi/guardrails/model_size.rb +87 -0
- data/lib/samagotchi/guardrails/rules.rb +35 -3
- data/lib/samagotchi/guardrails/targets.rb +22 -4
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_commands.rb +6 -1
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tool_runner.rb +12 -3
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +9 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../tools/tool_path"
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
5
|
require_relative "../log"
|
|
6
|
+
require_relative "model_size"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Guardrails
|
|
@@ -16,6 +17,10 @@ module Samagotchi
|
|
|
16
17
|
|
|
17
18
|
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
18
19
|
|
|
20
|
+
# @return [String, nil] the effective model: its bare name (no host
|
|
21
|
+
# prefix) and its key (ModelOverlay.key_for), for a rule's models:
|
|
22
|
+
attr_reader :model_name, :model_key
|
|
23
|
+
|
|
19
24
|
# @return [Hash, nil] a plugin tool's arguments (call[:args]), for an
|
|
20
25
|
# approval of a call whose targets name no command or path; nil for
|
|
21
26
|
# chi's own tools
|
|
@@ -23,10 +28,12 @@ module Samagotchi
|
|
|
23
28
|
|
|
24
29
|
# @param call [Hash] the parsed tool call
|
|
25
30
|
# @param context [Context]
|
|
26
|
-
# @param model_key [String, nil] for a memory_write model overlay
|
|
31
|
+
# @param model_key [String, nil] for a memory_write model overlay, and
|
|
32
|
+
# a rule's models:
|
|
33
|
+
# @param model_name [String, nil] the bare model name, for a rule's models:
|
|
27
34
|
# @param registry [Tools::Registry, nil] the session's tools: a plugin
|
|
28
35
|
# tool's entry says what it acts on (its targets:)
|
|
29
|
-
def self.for(call, context, model_key: nil, registry: nil)
|
|
36
|
+
def self.for(call, context, model_key: nil, model_name: nil, registry: nil)
|
|
30
37
|
tool = call[:name].to_s
|
|
31
38
|
base = context.cwd
|
|
32
39
|
command = nil
|
|
@@ -53,7 +60,8 @@ module Samagotchi
|
|
|
53
60
|
entry = registry && registry[tool]
|
|
54
61
|
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
55
62
|
end
|
|
56
|
-
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args
|
|
63
|
+
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
|
|
64
|
+
model_name: model_name, model_key: model_key)
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
# What a plugin tool's targets: callable says the call acts on:
|
|
@@ -93,8 +101,10 @@ module Samagotchi
|
|
|
93
101
|
nil
|
|
94
102
|
end
|
|
95
103
|
|
|
96
|
-
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil)
|
|
104
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil)
|
|
97
105
|
@args = args
|
|
106
|
+
@model_name = model_name
|
|
107
|
+
@model_key = model_key
|
|
98
108
|
@tool = tool
|
|
99
109
|
@command = command
|
|
100
110
|
@paths = paths
|
|
@@ -104,6 +114,14 @@ module Samagotchi
|
|
|
104
114
|
|
|
105
115
|
def shell? = SHELL_TOOLS.include?(@tool)
|
|
106
116
|
|
|
117
|
+
# Whether the effective model is a small one (guardrails.small_models,
|
|
118
|
+
# read when first asked: once per call's targets).
|
|
119
|
+
def small_model?
|
|
120
|
+
return @small_model if defined?(@small_model)
|
|
121
|
+
|
|
122
|
+
@small_model = ModelSize.small?(@model_name, @model_key)
|
|
123
|
+
end
|
|
124
|
+
|
|
107
125
|
# Whether any path is outside the repo root (the cwd outside a repo).
|
|
108
126
|
def outside_repo?
|
|
109
127
|
root = @repo_root || @cwd
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "guardrails/verdict"
|
|
4
4
|
require_relative "guardrails/context"
|
|
5
5
|
require_relative "guardrails/targets"
|
|
6
|
+
require_relative "guardrails/model_size"
|
|
6
7
|
require_relative "guardrails/approval"
|
|
7
8
|
require_relative "guardrails/approvals"
|
|
8
9
|
require_relative "guardrails/protected_paths"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "registry"
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../log"
|
|
6
|
+
require_relative "../memory_bundle/provenance"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
|
|
@@ -79,12 +80,14 @@ module Samagotchi
|
|
|
79
80
|
|
|
80
81
|
begin
|
|
81
82
|
plugin = instantiate(bundle_name, basename, file, settings: settings)
|
|
83
|
+
log_failure = Hooks.failure_log_gate(event_sym)
|
|
82
84
|
|
|
83
85
|
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority) do |event|
|
|
84
86
|
begin
|
|
85
87
|
plugin.call(event)
|
|
86
88
|
rescue Exception => e
|
|
87
|
-
|
|
89
|
+
# A veto always applies; only the log line is throttled.
|
|
90
|
+
handle_error(bundle_name, basename, on_error, fail_closed, event, e) if fail_closed || log_failure.call
|
|
88
91
|
end
|
|
89
92
|
end
|
|
90
93
|
loaded += 1
|
|
@@ -99,10 +102,10 @@ module Samagotchi
|
|
|
99
102
|
# Why the file doesn't match the sha256 recorded at install, or nil.
|
|
100
103
|
# With none recorded, only a required hook counts as a mismatch.
|
|
101
104
|
def sha_mismatch(file, recorded, required:)
|
|
102
|
-
expected =
|
|
105
|
+
expected = MemoryBundle::Provenance.recorded_sha(recorded)
|
|
103
106
|
return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
|
|
104
107
|
|
|
105
|
-
actual =
|
|
108
|
+
actual = MemoryBundle::Provenance.file_sha(file)
|
|
106
109
|
return nil if actual == expected
|
|
107
110
|
|
|
108
111
|
"its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
|
|
@@ -60,6 +60,7 @@ module Samagotchi
|
|
|
60
60
|
definitions.each do |defn|
|
|
61
61
|
begin
|
|
62
62
|
plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
|
|
63
|
+
log_failure = Hooks.failure_log_gate(defn[:event_type])
|
|
63
64
|
# Persistent: config hooks must fire on every turn, not be wiped
|
|
64
65
|
# by Engine#run_turn's per-turn clear_hooks after turn 1.
|
|
65
66
|
registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)") do |event|
|
|
@@ -69,7 +70,7 @@ module Samagotchi
|
|
|
69
70
|
if defn[:required] && defn[:event_type] == "before_tool_call"
|
|
70
71
|
deny_for_raise(event, defn[:path], e)
|
|
71
72
|
else
|
|
72
|
-
handle_error(defn[:on_error] || "skip", defn[:path], e)
|
|
73
|
+
handle_error(defn[:on_error] || "skip", defn[:path], e) if log_failure.call
|
|
73
74
|
end
|
|
74
75
|
end
|
|
75
76
|
end
|
|
@@ -15,6 +15,27 @@ module Samagotchi
|
|
|
15
15
|
takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# A stream hook (:generation_progress) fires ~once a second: a broken
|
|
19
|
+
# one logs its failure once a minute, not on every fire.
|
|
20
|
+
FAILED_STREAM_HOOK_LOG_SECONDS = 60
|
|
21
|
+
|
|
22
|
+
# One hook's failure-log gate: called on each failure, it says whether
|
|
23
|
+
# to log this one (a :generation_progress hook's at most once a
|
|
24
|
+
# minute, any other hook's every time).
|
|
25
|
+
# @param event [Symbol, String] the hook's event
|
|
26
|
+
# @return [Proc] -> Boolean
|
|
27
|
+
def self.failure_log_gate(event)
|
|
28
|
+
quiet_for = event.to_s == "generation_progress" ? FAILED_STREAM_HOOK_LOG_SECONDS : 0
|
|
29
|
+
logged_at = nil
|
|
30
|
+
lambda do
|
|
31
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
next false if logged_at && now - logged_at < quiet_for
|
|
33
|
+
|
|
34
|
+
logged_at = now
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
18
39
|
# What a hook can do beyond reading its event: the Engine's
|
|
19
40
|
# callables, each given the hook's label. +notify+ takes
|
|
20
41
|
# (text:, level:, hook:) and shows one line to the user; +ask_user+
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "tmpdir"
|
|
8
|
+
require_relative "atomic_file"
|
|
8
9
|
require_relative "config"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -384,9 +385,7 @@ module Samagotchi
|
|
|
384
385
|
path = File.join(session_dir.to_s, file)
|
|
385
386
|
unless File.file?(path)
|
|
386
387
|
FileUtils.mkdir_p(File.dirname(path))
|
|
387
|
-
|
|
388
|
-
File.binwrite(tmp, bytes)
|
|
389
|
-
File.rename(tmp, path)
|
|
388
|
+
AtomicFile.write(path, bytes)
|
|
390
389
|
end
|
|
391
390
|
{ file: file, mime: MIME.fetch(info.format), width: info.width, height: info.height,
|
|
392
391
|
bytes: bytes.bytesize, name: name, source: source.to_s }
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "model_profile"
|
|
4
4
|
require_relative "tool_call_parser"
|
|
5
5
|
require_relative "config"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "context_status"
|
|
7
7
|
require_relative "context_window"
|
|
8
8
|
require_relative "prompt"
|
|
9
9
|
require_relative "prompt_literal_guard"
|
|
@@ -37,20 +37,6 @@ module Samagotchi
|
|
|
37
37
|
# - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
38
38
|
class KernelLoop
|
|
39
39
|
Result = Struct.new(:output, :conversation, :exhausted, :pending_tool_calls, :tool_activity, :canceled, :cancellation_reason, :context_status, keyword_init: true) do
|
|
40
|
-
def to_s
|
|
41
|
-
output.to_s
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
alias to_str to_s
|
|
45
|
-
|
|
46
|
-
def ==(other)
|
|
47
|
-
if other.is_a?(self.class)
|
|
48
|
-
super
|
|
49
|
-
else
|
|
50
|
-
to_s == other
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
40
|
def exhausted?
|
|
55
41
|
exhausted
|
|
56
42
|
end
|
|
@@ -66,25 +52,6 @@ module Samagotchi
|
|
|
66
52
|
def canceled?
|
|
67
53
|
canceled
|
|
68
54
|
end
|
|
69
|
-
|
|
70
|
-
# Struct/Enumerable defines include? with collection semantics, but the
|
|
71
|
-
# historical KernelLoop#run contract returned a String. Keep include?
|
|
72
|
-
# aligned with String#include? for backward compatibility.
|
|
73
|
-
def include?(needle)
|
|
74
|
-
to_s.include?(needle)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Keep compatibility with existing callers/specs that treat run() as a
|
|
78
|
-
# plain string (e.g., include?, match, start_with?).
|
|
79
|
-
def method_missing(name, *args, &block)
|
|
80
|
-
return to_s.public_send(name, *args, &block) if to_s.respond_to?(name)
|
|
81
|
-
|
|
82
|
-
super
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def respond_to_missing?(name, include_private = false)
|
|
86
|
-
to_s.respond_to?(name, include_private) || super
|
|
87
|
-
end
|
|
88
55
|
end
|
|
89
56
|
|
|
90
57
|
# The built-in tool classes (Tools::Builtins registers them).
|
|
@@ -105,16 +72,6 @@ module Samagotchi
|
|
|
105
72
|
def ask_user_question(call) = @kernel.__send__(:handle_ask_user_question, call)
|
|
106
73
|
end
|
|
107
74
|
|
|
108
|
-
CONTEXT_STATUS_PREFIX = "CONTEXT_STATUS"
|
|
109
|
-
# The model's own line about its context (a tail system message, kind
|
|
110
|
-
# CONTEXT_LINE_KIND), left once per rise into a bucket whose guidance
|
|
111
|
-
# asks for a change: from the second threshold (40% by default) up.
|
|
112
|
-
CONTEXT_LINE_PREFIX = "[CONTEXT: "
|
|
113
|
-
CONTEXT_LINE_KIND = "context"
|
|
114
|
-
CONTEXT_GUIDANCE_FROM_RANK = 2
|
|
115
|
-
|
|
116
|
-
DEFAULT_CONTEXT_CHARS_PER_TOKEN = 4.0
|
|
117
|
-
DEFAULT_CONTEXT_THRESHOLDS = [20, 40, 60, 80].freeze
|
|
118
75
|
DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
|
|
119
76
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
|
|
120
77
|
QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT = "Continue the previous assistant message by finishing the open <tool_call> XML block. Output only the remaining XML needed to complete the tool call."
|
|
@@ -204,7 +161,7 @@ module Samagotchi
|
|
|
204
161
|
@current_model_name = resolved_model_name
|
|
205
162
|
|
|
206
163
|
conversation = prepare_conversation(messages)
|
|
207
|
-
|
|
164
|
+
context = ContextStatus.new(conversation: conversation)
|
|
208
165
|
exhausted = false
|
|
209
166
|
pending_tool_calls = false
|
|
210
167
|
tool_activity = []
|
|
@@ -213,7 +170,6 @@ module Samagotchi
|
|
|
213
170
|
empty_retries = 0
|
|
214
171
|
empty_retry_limit = EmptyAnswerRetry.limit
|
|
215
172
|
@retry_generation = false
|
|
216
|
-
context_status = nil
|
|
217
173
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
218
174
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
219
175
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
@@ -228,9 +184,9 @@ module Samagotchi
|
|
|
228
184
|
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
229
185
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
|
|
230
186
|
context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (line =
|
|
187
|
+
emit_context_status_event(on_stream_event, context, prompt, iteration_index: iteration_index, window: context_window,
|
|
188
|
+
image_tokens: image_tokens)
|
|
189
|
+
if (line = context.take_guidance)
|
|
234
190
|
# The model's own copy, on the tail (the prompt cache keeps its
|
|
235
191
|
# prefix), then the prompt again with it.
|
|
236
192
|
conversation << line
|
|
@@ -246,8 +202,8 @@ module Samagotchi
|
|
|
246
202
|
profile_source: @profile_source
|
|
247
203
|
)
|
|
248
204
|
served_model = nil
|
|
249
|
-
# This generation's own server counts (the run-long
|
|
250
|
-
#
|
|
205
|
+
# This generation's own server counts (the run-long ones in
|
|
206
|
+
# `context` can be an earlier one's).
|
|
251
207
|
generation_usage = nil
|
|
252
208
|
# The thinking this generation streamed, for the log (a stuck
|
|
253
209
|
# thinking generation shows as thinking_chars=N content_length=…).
|
|
@@ -266,7 +222,7 @@ module Samagotchi
|
|
|
266
222
|
cancel_controller: cancel_controller,
|
|
267
223
|
model_name: resolved_model_name,
|
|
268
224
|
on_chunk: lambda { |chunk|
|
|
269
|
-
generation_usage =
|
|
225
|
+
generation_usage = context.capture(chunk[:payload]) || generation_usage
|
|
270
226
|
# llama.cpp names the loaded model in the stream's last payload.
|
|
271
227
|
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
272
228
|
served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
@@ -333,14 +289,11 @@ module Samagotchi
|
|
|
333
289
|
# No retry left: the turn ends as cancelled (hook), with nothing
|
|
334
290
|
# salvaged and without the spent nudge.
|
|
335
291
|
drop_last_empty_retry!(conversation)
|
|
336
|
-
cancel_controller.cancel!(:hook)
|
|
292
|
+
cancel_controller.cancel!(:hook, cut)
|
|
337
293
|
emit_stream_event(on_stream_event, type: :generation_cancelled, iteration: iteration_index + 1, reason: :hook)
|
|
338
294
|
return cancelled_result(conversation, tool_activity: tool_activity, reason: :hook, partial_assistant_text: "")
|
|
339
295
|
end
|
|
340
|
-
|
|
341
|
-
# only what the turn appended since (answer, tool results).
|
|
342
|
-
context_state[:counted] = { chars: prompt.length, image_tokens: image_tokens } if generation_usage
|
|
343
|
-
refresh_context_display(context_state, generation_usage, context_window)
|
|
296
|
+
context.generation_done(generation_usage, prompt_chars: prompt.length, image_tokens: image_tokens, window: context_window)
|
|
344
297
|
emit_stream_event(
|
|
345
298
|
on_stream_event,
|
|
346
299
|
type: :generation_completed,
|
|
@@ -465,7 +418,7 @@ module Samagotchi
|
|
|
465
418
|
tool_activity: tool_activity,
|
|
466
419
|
canceled: false,
|
|
467
420
|
cancellation_reason: nil,
|
|
468
|
-
context_status:
|
|
421
|
+
context_status: context.display
|
|
469
422
|
)
|
|
470
423
|
rescue StandardError => e
|
|
471
424
|
LLM::FailedTurn.attach(e, conversation && duplicate_conversation(conversation))
|
|
@@ -667,227 +620,16 @@ module Samagotchi
|
|
|
667
620
|
Log.debug(:model, event, payload: payload, model: @current_model_name, **fields)
|
|
668
621
|
end
|
|
669
622
|
|
|
670
|
-
# Estimate context usage for this iteration's prompt
|
|
671
|
-
# gate fires, surface it to stream consumers as a
|
|
672
|
-
#
|
|
673
|
-
#
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
def emit_context_status_event(on_stream_event, prompt, iteration_index:, state:, window: nil, image_tokens: 0)
|
|
678
|
-
return nil unless context_status_enabled?
|
|
679
|
-
|
|
680
|
-
usage = estimate_context_usage(prompt, server_usage: state[:server_usage], window: window, image_tokens: image_tokens,
|
|
681
|
-
counted: state[:counted])
|
|
682
|
-
bucket = context_status_bucket(usage[:estimated_pct])
|
|
683
|
-
# The status line's value, every iteration; the gate below decides
|
|
684
|
-
# only the event and the model's guidance line.
|
|
685
|
-
state[:display] = { est_pct: usage[:estimated_pct], bucket: bucket }
|
|
686
|
-
emit_status = should_emit_context_status?(state: state, bucket: bucket, iteration_index: iteration_index)
|
|
687
|
-
previous_bucket = state[:last_bucket]
|
|
688
|
-
state[:last_bucket] = bucket
|
|
689
|
-
return nil unless emit_status
|
|
690
|
-
|
|
691
|
-
state[:guidance] = context_guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
|
|
692
|
-
|
|
693
|
-
status_message = context_status_message(usage: usage, bucket: bucket, source: usage[:source])
|
|
694
|
-
emit_stream_event(
|
|
695
|
-
on_stream_event,
|
|
696
|
-
type: :context_status,
|
|
697
|
-
iteration: iteration_index + 1,
|
|
698
|
-
status: status_message,
|
|
699
|
-
usage: usage,
|
|
700
|
-
bucket: bucket,
|
|
701
|
-
source: usage[:source]
|
|
702
|
-
)
|
|
703
|
-
dump_log("context_status", status_message, iteration: iteration_index + 1, bucket: bucket)
|
|
704
|
-
{ est_pct: usage[:estimated_pct], bucket: bucket }
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
# @return [Hash, nil] the payload's normalized counts, when it has any
|
|
708
|
-
def capture_server_usage(payload, state)
|
|
709
|
-
normalized = ContextUsage.normalize(payload)
|
|
710
|
-
state[:server_usage] = normalized if normalized
|
|
711
|
-
normalized
|
|
712
|
-
end
|
|
713
|
-
|
|
714
|
-
# After a generation: the status line's value from what the server
|
|
715
|
-
# reported for it (prompt + answer), so a turn's value counts its last
|
|
716
|
-
# answer. Without counts the pre-generation estimate stays.
|
|
717
|
-
def refresh_context_display(state, usage, window)
|
|
718
|
-
return unless usage
|
|
719
|
-
|
|
720
|
-
display = context_display(used_tokens: usage[:total_tokens],
|
|
721
|
-
window_tokens: usage[:context_window_tokens] || window&.tokens)
|
|
722
|
-
state[:display] = display if display
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
def initial_context_status_state(conversation)
|
|
726
|
-
{ last_bucket: extract_last_context_status_bucket(conversation) }
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
# The bucket of the last status line the conversation holds: the model's
|
|
730
|
-
# own line, or a legacy session's injected telemetry.
|
|
731
|
-
def extract_last_context_status_bucket(conversation)
|
|
732
|
-
message = conversation.reverse.find do |entry|
|
|
733
|
-
content = entry[:content].to_s
|
|
734
|
-
entry[:role] == "system" && (content.start_with?(CONTEXT_STATUS_PREFIX) || content.start_with?(CONTEXT_LINE_PREFIX))
|
|
735
|
-
end
|
|
736
|
-
return nil unless message
|
|
737
|
-
|
|
738
|
-
match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
|
|
739
|
-
match && match[1]
|
|
740
|
-
end
|
|
741
|
-
|
|
742
|
-
def context_status_enabled?
|
|
743
|
-
Samagotchi::Config.get("context.status") != false
|
|
744
|
-
end
|
|
745
|
-
|
|
746
|
-
# `window` is this iteration's ContextWindow::Resolved (resolved here when
|
|
747
|
-
# not given). A window the stream payload reports itself still wins.
|
|
748
|
-
# +image_tokens+: the images' estimate (their base64 is not in +prompt+).
|
|
749
|
-
# +counted+: the prompt the server's prompt_tokens counted ({chars:,
|
|
750
|
-
# image_tokens:}); what +prompt+ has on top of it (the answer, tool
|
|
751
|
-
# results since) is added as an estimate, so the value doesn't read low
|
|
752
|
-
# during a long tool loop. A prompt shorter than that one (trimmed) or
|
|
753
|
-
# none known: the server's count alone.
|
|
754
|
-
def estimate_context_usage(prompt, server_usage: nil, window: nil, image_tokens: 0, counted: nil)
|
|
755
|
-
window ||= ContextWindow.resolve(client: @client, model: @current_model_name)
|
|
756
|
-
window_source = window.source
|
|
757
|
-
if server_usage && server_usage[:context_window_tokens]
|
|
758
|
-
window_source = :server
|
|
759
|
-
end
|
|
760
|
-
|
|
761
|
-
if server_usage && server_usage[:prompt_tokens]
|
|
762
|
-
window_tokens = server_usage[:context_window_tokens] || window.tokens
|
|
763
|
-
estimated_used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
|
|
764
|
-
estimated_remaining_tokens = [window_tokens - estimated_used_tokens, 0].max
|
|
765
|
-
estimated_pct = (estimated_used_tokens.to_f / window_tokens) * 100.0
|
|
766
|
-
|
|
767
|
-
return {
|
|
768
|
-
window_tokens: window_tokens,
|
|
769
|
-
window_source: window_source,
|
|
770
|
-
estimated_used_tokens: estimated_used_tokens,
|
|
771
|
-
estimated_remaining_tokens: estimated_remaining_tokens,
|
|
772
|
-
estimated_pct: estimated_pct,
|
|
773
|
-
source: "server"
|
|
774
|
-
}
|
|
775
|
-
end
|
|
776
|
-
|
|
777
|
-
window_tokens = window.tokens
|
|
778
|
-
estimated_used_tokens = (prompt.length / context_chars_per_token).ceil + image_tokens
|
|
779
|
-
estimated_remaining_tokens = [window_tokens - estimated_used_tokens, 0].max
|
|
780
|
-
estimated_pct = (estimated_used_tokens.to_f / window_tokens) * 100.0
|
|
781
|
-
|
|
782
|
-
{
|
|
783
|
-
window_tokens: window_tokens,
|
|
784
|
-
window_source: window_source,
|
|
785
|
-
estimated_used_tokens: estimated_used_tokens,
|
|
786
|
-
estimated_remaining_tokens: estimated_remaining_tokens,
|
|
787
|
-
estimated_pct: estimated_pct,
|
|
788
|
-
source: "estimate"
|
|
789
|
-
}
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
# The estimate for what +prompt+ added since the +counted+ one.
|
|
793
|
-
def appended_tokens(prompt, image_tokens, counted)
|
|
794
|
-
return 0 unless counted
|
|
795
|
-
|
|
796
|
-
chars = prompt.length - counted[:chars]
|
|
797
|
-
return 0 unless chars.positive?
|
|
798
|
-
|
|
799
|
-
(chars / context_chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
def context_chars_per_token
|
|
803
|
-
value = Samagotchi::Config.get("context.chars_per_token").to_f
|
|
804
|
-
value.positive? ? value : DEFAULT_CONTEXT_CHARS_PER_TOKEN
|
|
805
|
-
end
|
|
806
|
-
|
|
807
|
-
def context_status_thresholds
|
|
808
|
-
raw = Samagotchi::Config.get("context.status_thresholds").to_s
|
|
809
|
-
parsed = raw.split(",").map { |value| value.strip.to_i }.select { |value| value.between?(1, 99) }.uniq.sort
|
|
810
|
-
parsed.empty? ? DEFAULT_CONTEXT_THRESHOLDS : parsed
|
|
811
|
-
end
|
|
812
|
-
|
|
813
|
-
def context_status_cadence
|
|
814
|
-
[Samagotchi::Config.get("context.status_cadence").to_i, 0].max
|
|
815
|
-
end
|
|
816
|
-
|
|
817
|
-
# 0 for the bucket under the first threshold, then one per threshold.
|
|
818
|
-
def bucket_rank(bucket)
|
|
819
|
-
return 0 if bucket.nil? || bucket.to_s.start_with?("under")
|
|
820
|
-
|
|
821
|
-
(context_status_thresholds.index(bucket.to_s.delete_suffix("plus").to_i) || -1) + 1
|
|
822
|
-
end
|
|
823
|
-
|
|
824
|
-
# A rise (never a fall or a cadence tick) into a bucket whose guidance
|
|
825
|
-
# asks for a change. With no previous bucket (a first turn, a resumed
|
|
826
|
-
# session with no line yet), the first bucket counts as a rise from 0.
|
|
827
|
-
def guidance_due?(previous, bucket)
|
|
828
|
-
rank = bucket_rank(bucket)
|
|
829
|
-
rank >= CONTEXT_GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
|
|
830
|
-
end
|
|
831
|
-
|
|
832
|
-
def context_guidance_message(usage:, bucket:)
|
|
833
|
-
how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
|
|
834
|
-
{ role: "system", kind: CONTEXT_LINE_KIND,
|
|
835
|
-
content: "#{CONTEXT_LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
|
|
836
|
-
"(#{how}; bucket=#{bucket}). #{context_status_guidance(bucket)}]" }
|
|
837
|
-
end
|
|
838
|
-
|
|
839
|
-
def context_status_bucket(estimated_pct)
|
|
840
|
-
thresholds = context_status_thresholds
|
|
841
|
-
bucket = "under#{thresholds.first}"
|
|
842
|
-
thresholds.each do |threshold|
|
|
843
|
-
bucket = "#{threshold}plus" if estimated_pct >= threshold
|
|
844
|
-
end
|
|
845
|
-
bucket
|
|
846
|
-
end
|
|
847
|
-
|
|
848
|
-
def should_emit_context_status?(state:, bucket:, iteration_index:)
|
|
849
|
-
last_bucket = state[:last_bucket]
|
|
850
|
-
below_threshold_bucket = "under#{context_status_thresholds.first}"
|
|
851
|
-
bucket_changed = if last_bucket.nil?
|
|
852
|
-
bucket != below_threshold_bucket
|
|
853
|
-
else
|
|
854
|
-
bucket != last_bucket
|
|
855
|
-
end
|
|
856
|
-
|
|
857
|
-
cadence = context_status_cadence
|
|
858
|
-
cadence_due = cadence.positive? && ((iteration_index + 1) % cadence).zero?
|
|
859
|
-
bucket_changed || cadence_due
|
|
860
|
-
end
|
|
623
|
+
# Estimate context usage for this iteration's prompt (ContextStatus#observe)
|
|
624
|
+
# and, when the emit gate fires, surface it to stream consumers as a
|
|
625
|
+
# :context_status event. The telemetry is not for the model (it used to
|
|
626
|
+
# be injected as a synthetic system message).
|
|
627
|
+
def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
|
|
628
|
+
event = context.observe(prompt, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
|
|
629
|
+
return unless event
|
|
861
630
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
"%<prefix>s window_tokens=%<window>d window_src=%<window_src>s est_used_tokens=%<used>d est_remaining_tokens=%<remaining>d est_pct=%<pct>.1f bucket=%<bucket>s thresholds=%<thresholds>s src=%<src>s guidance=%<guidance>s",
|
|
865
|
-
prefix: CONTEXT_STATUS_PREFIX,
|
|
866
|
-
window: usage[:window_tokens],
|
|
867
|
-
window_src: usage[:window_source] || "default",
|
|
868
|
-
used: usage[:estimated_used_tokens],
|
|
869
|
-
remaining: usage[:estimated_remaining_tokens],
|
|
870
|
-
pct: usage[:estimated_pct],
|
|
871
|
-
bucket: bucket,
|
|
872
|
-
thresholds: context_status_thresholds.join(","),
|
|
873
|
-
src: source,
|
|
874
|
-
guidance: context_status_guidance(bucket)
|
|
875
|
-
)
|
|
876
|
-
end
|
|
877
|
-
|
|
878
|
-
def context_status_guidance(bucket)
|
|
879
|
-
case bucket
|
|
880
|
-
when "under20", "20plus"
|
|
881
|
-
"context healthy — proceed normally"
|
|
882
|
-
when "40plus"
|
|
883
|
-
"context moderate — prefer targeted and range reads over full-file dumps"
|
|
884
|
-
when "60plus"
|
|
885
|
-
"context elevated — be concise, prefer range reads, avoid re-reading large files"
|
|
886
|
-
when "80plus"
|
|
887
|
-
"context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
|
|
888
|
-
else
|
|
889
|
-
"context healthy — proceed normally"
|
|
890
|
-
end
|
|
631
|
+
emit_stream_event(on_stream_event, type: :context_status, iteration: iteration_index + 1, **event)
|
|
632
|
+
dump_log("context_status", event[:status], iteration: iteration_index + 1, bucket: event[:bucket])
|
|
891
633
|
end
|
|
892
634
|
|
|
893
635
|
public
|
|
@@ -904,11 +646,7 @@ module Samagotchi
|
|
|
904
646
|
# of +window_tokens+; nil without both, or with context.status off. The
|
|
905
647
|
# chat loop builds its value with it too.
|
|
906
648
|
def context_display(used_tokens:, window_tokens:)
|
|
907
|
-
|
|
908
|
-
return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
|
|
909
|
-
|
|
910
|
-
pct = (used_tokens.to_f / window_tokens) * 100.0
|
|
911
|
-
{ est_pct: pct, bucket: context_status_bucket(pct) }
|
|
649
|
+
ContextStatus.new.display_for(used_tokens: used_tokens, window_tokens: window_tokens)
|
|
912
650
|
end
|
|
913
651
|
|
|
914
652
|
# Per-profile parse strategy. Rebuilt when the active profile changes
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
3
|
require "socket"
|
|
6
4
|
require_relative "session"
|
|
7
5
|
require_relative "version"
|
|
6
|
+
require_relative "worker_sidecar"
|
|
7
|
+
require_relative "web/info_probe"
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# Which chi versions the running processes run, for `chi update`: session
|
|
@@ -22,13 +22,12 @@ module Samagotchi
|
|
|
22
22
|
|
|
23
23
|
# @return [Array<Worker>] the workers whose Bridge answers, by session id
|
|
24
24
|
def workers(state_dir: Session.default_state_dir)
|
|
25
|
-
Dir[File.join(state_dir, "*",
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
|
|
26
|
+
session_dir = File.dirname(path)
|
|
27
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
28
|
+
next unless sidecar && listening?(sidecar.port)
|
|
28
29
|
|
|
29
|
-
Worker.new(session_id: File.basename(
|
|
30
|
-
rescue JSON::ParserError, SystemCallError
|
|
31
|
-
nil
|
|
30
|
+
Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
@@ -46,11 +45,8 @@ module Samagotchi
|
|
|
46
45
|
# The /api/info of a chi web on host:port, or nil when nothing (or not
|
|
47
46
|
# chi web) answers.
|
|
48
47
|
def web_info(host, port, timeout: WEB_TIMEOUT)
|
|
49
|
-
|
|
50
|
-
info
|
|
51
|
-
info.is_a?(Hash) && info["app"] == "chi-web" ? info : nil
|
|
52
|
-
rescue StandardError
|
|
53
|
-
nil
|
|
48
|
+
info = Web::InfoProbe.call(host, port, timeout: timeout)
|
|
49
|
+
info.is_a?(Hash) ? info : nil
|
|
54
50
|
end
|
|
55
51
|
|
|
56
52
|
def listening?(port, host: "127.0.0.1")
|
|
@@ -423,7 +423,7 @@ module Samagotchi
|
|
|
423
423
|
end
|
|
424
424
|
index = @conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
|
|
425
425
|
@conversation.delete_at(index) if index
|
|
426
|
-
@cancel_controller.cancel!(:hook)
|
|
426
|
+
@cancel_controller.cancel!(:hook, response.cut)
|
|
427
427
|
canceled(iteration, :hook)
|
|
428
428
|
end
|
|
429
429
|
|
|
@@ -32,17 +32,17 @@ module Samagotchi
|
|
|
32
32
|
pending_input: pending_input
|
|
33
33
|
)
|
|
34
34
|
ModelResult.new(
|
|
35
|
-
text: kernel_result.
|
|
35
|
+
text: kernel_result.output.to_s,
|
|
36
36
|
tool_calls: nil,
|
|
37
37
|
provider: :native,
|
|
38
38
|
usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
|
|
39
|
-
conversation: kernel_result.
|
|
40
|
-
canceled: kernel_result.
|
|
41
|
-
cancellation_reason: kernel_result.
|
|
42
|
-
exhausted: kernel_result.
|
|
43
|
-
tool_activity: kernel_result.
|
|
44
|
-
context_status: kernel_result.
|
|
45
|
-
pending_tool_calls: kernel_result.
|
|
39
|
+
conversation: kernel_result.conversation,
|
|
40
|
+
canceled: kernel_result.canceled?,
|
|
41
|
+
cancellation_reason: kernel_result.cancellation_reason,
|
|
42
|
+
exhausted: kernel_result.exhausted,
|
|
43
|
+
tool_activity: kernel_result.tool_activity,
|
|
44
|
+
context_status: kernel_result.context_status,
|
|
45
|
+
pending_tool_calls: kernel_result.pending_tool_calls
|
|
46
46
|
)
|
|
47
47
|
end
|
|
48
48
|
end
|