samagotchi 0.67.0 → 0.69.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 +62 -1
- data/docs/architecture.md +7 -4
- data/docs/cli.md +6 -1
- data/docs/configuration.md +28 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +4 -0
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +73 -17
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/plugins.md +42 -19
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +3 -3
- data/docs/testing.md +8 -2
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +0 -19
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +216 -360
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +24 -7
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +6 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +24 -3
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +69 -93
- data/lib/samagotchi/web/message_parts.rb +5 -2
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +196 -526
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +21 -11
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
- data/lib/samagotchi/web/public/model_picker.js +47 -28
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- metadata +15 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_model"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# One per-session setting (the session's own value, before its model's):
|
|
7
|
+
# what the UIs show as a chip and the commands set. +key+ is the Session
|
|
8
|
+
# attribute and the chip's key everywhere (the session state, /stats, a
|
|
9
|
+
# :command_ran, the web's session JSON); +changed_by+ the commands'
|
|
10
|
+
# `changed` entries that move the chip; +snapshot_fallback+ how the web
|
|
11
|
+
# treats a worker snapshot's value: :or (a nil value falls back to the
|
|
12
|
+
# session file's) or :key (a key present stands, nil included: a worker
|
|
13
|
+
# says nil on a native host, which ignores reasoning_history; an older
|
|
14
|
+
# worker has no key). +chip+ the chip from an EffectiveModel and the
|
|
15
|
+
# session's own value; nil for the toolkit, which resolves against the
|
|
16
|
+
# worker's kits (Engine#toolkit_summary), not the model. +live+ the
|
|
17
|
+
# chip as an Engine's public readers give it (a :command_ran's).
|
|
18
|
+
SessionSetting = Data.define(:key, :changed_by, :snapshot_fallback, :chip, :live) do
|
|
19
|
+
# The session's own value, nil without a session or one.
|
|
20
|
+
def own(session) = session&.public_send(key)
|
|
21
|
+
|
|
22
|
+
# Sets the session's own value (nil unsets it); the caller saves.
|
|
23
|
+
def set(session, value) = session.public_send(:"#{key}=", value)
|
|
24
|
+
|
|
25
|
+
# Whether a command that changed +changed+ moves this chip.
|
|
26
|
+
def changed_by?(changed) = changed.intersect?(changed_by)
|
|
27
|
+
|
|
28
|
+
# Whether the chip comes from the model (not the toolkit).
|
|
29
|
+
def model? = !chip.nil?
|
|
30
|
+
|
|
31
|
+
# The chip for +session+ on +model+ (EffectiveModel, nil when it
|
|
32
|
+
# doesn't resolve: nil then).
|
|
33
|
+
def chip_for(model, session) = model && chip.call(model, own(session))
|
|
34
|
+
|
|
35
|
+
# The chip as +engine+ (a worker's) gives it now.
|
|
36
|
+
def summary(engine) = live.call(engine)
|
|
37
|
+
|
|
38
|
+
# The chip for a saved session no worker runs (Web::App, the session
|
|
39
|
+
# lists): resolved from its file, config and the registry (no network);
|
|
40
|
+
# nil when it can't be.
|
|
41
|
+
def saved_summary(session, registry:)
|
|
42
|
+
return saved_toolkit(session.toolkit) unless model?
|
|
43
|
+
|
|
44
|
+
chip_for(EffectiveModel.of_session(session, registry: registry), session)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The web's chip: a worker's snapshot (string keys) per
|
|
50
|
+
# +snapshot_fallback+, else the block's (the saved one).
|
|
51
|
+
def snapshot_value(snapshot)
|
|
52
|
+
name = key.to_s
|
|
53
|
+
return snapshot[name] if snapshot_fallback == :key && snapshot&.key?(name)
|
|
54
|
+
return snapshot[name] if snapshot_fallback == :or && snapshot && snapshot[name]
|
|
55
|
+
|
|
56
|
+
yield
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# A toolkit as its file names it ({name:, label:}), nil on full:
|
|
62
|
+
# without a worker nothing resolves it (a bundle's kits load there).
|
|
63
|
+
def saved_toolkit(toolkit)
|
|
64
|
+
toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class SessionSetting
|
|
69
|
+
LLM_CONTEXT = new(key: :llm_context, changed_by: %i[llm_context model], snapshot_fallback: :or,
|
|
70
|
+
chip: ->(model, own) { model.llm_context_explained(own&.resolve_args || {})&.summary(own) },
|
|
71
|
+
live: lambda(&:llm_context_summary))
|
|
72
|
+
THINKING = new(key: :thinking, changed_by: %i[thinking model], snapshot_fallback: :or,
|
|
73
|
+
chip: ->(model, own) { model.thinking_explained(own: own)&.summary },
|
|
74
|
+
live: ->(engine) { engine.thinking_explained&.summary })
|
|
75
|
+
# nil on a native host (its loop ignores the level).
|
|
76
|
+
REASONING_HISTORY = new(key: :reasoning_history, changed_by: %i[reasoning_history model], snapshot_fallback: :key,
|
|
77
|
+
chip: ->(model, own) { model.reasoning_history_explained(own: own)&.summary },
|
|
78
|
+
live: ->(engine) { engine.reasoning_history_explained&.summary })
|
|
79
|
+
TOOLKIT = new(key: :toolkit, changed_by: %i[toolkit], snapshot_fallback: :key, chip: nil, live: lambda(&:toolkit_summary))
|
|
80
|
+
|
|
81
|
+
# In the order the session state, a :command_ran and the web name them.
|
|
82
|
+
ALL = [LLM_CONTEXT, THINKING, REASONING_HISTORY, TOOLKIT].freeze
|
|
83
|
+
# /stats's (no toolkit: its JSON never had one).
|
|
84
|
+
STATS = [LLM_CONTEXT, THINKING, REASONING_HISTORY].freeze
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -331,7 +331,9 @@ module Samagotchi
|
|
|
331
331
|
# The tools the session's toolkit leaves out, named once right after the
|
|
332
332
|
# base prompt: sessions on another toolkit already differ at the tools,
|
|
333
333
|
# and nothing later (a memory write) moves it. nil when none are (full):
|
|
334
|
-
# the prompt is what it was before toolkits.
|
|
334
|
+
# the prompt is what it was before toolkits. The ones call_tool reaches
|
|
335
|
+
# and the ones the toolkit removes (only the user brings those back)
|
|
336
|
+
# get a sentence each; with call_tool not declared, one list.
|
|
335
337
|
def not_loaded_line(layers)
|
|
336
338
|
registry = @tools_lookup.call
|
|
337
339
|
return nil unless registry.respond_to?(:left_out_names)
|
|
@@ -339,8 +341,22 @@ module Samagotchi
|
|
|
339
341
|
names = registry.left_out_names(layers: layers)
|
|
340
342
|
return nil if names.empty?
|
|
341
343
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
+
toolkit = "(toolkit #{registry.toolkit&.name})"
|
|
345
|
+
reachable = registry.reachable_names(layers: layers)
|
|
346
|
+
if reachable.empty?
|
|
347
|
+
return "Not loaded in this session #{toolkit}: #{names.join(", ")}. Only the user can load one (/toolkit add <name>)."
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
removed = names - reachable
|
|
351
|
+
# find_tools removed (call_tool still works): the route without it.
|
|
352
|
+
how = if removed.include?("find_tools") then "Call one with call_tool(tool: \"chi/<name>\", args: {…})."
|
|
353
|
+
else "Search them with find_tools; call one with call_tool."
|
|
354
|
+
end
|
|
355
|
+
line = "Not declared in this session #{toolkit}: #{reachable.join(", ")}. #{how}"
|
|
356
|
+
return line if removed.empty?
|
|
357
|
+
|
|
358
|
+
"#{line} Removed #{toolkit}: #{removed.join(", ")}. Only the user can load #{removed.size == 1 ? "it" : "one"} " \
|
|
359
|
+
"(/toolkit add <name>)."
|
|
344
360
|
end
|
|
345
361
|
|
|
346
362
|
def with_tail_declarations(prompt, chat:, layers:)
|
|
@@ -34,7 +34,8 @@ module Samagotchi
|
|
|
34
34
|
# What a tool line says the call did: the model's description of a
|
|
35
35
|
# command (": List the specs") in place of its cut params, else the
|
|
36
36
|
# params, dim; then, dim, the name the model called it by when that was
|
|
37
|
-
# an alias's (" (called as bash)")
|
|
37
|
+
# an alias's (" (called as bash)"), or the tool it was reached through
|
|
38
|
+
# (" (via call_tool)").
|
|
38
39
|
def tool_params_suffix(activity)
|
|
39
40
|
description = activity[:description].to_s.strip
|
|
40
41
|
params = activity[:params].to_s.strip
|
|
@@ -43,7 +44,9 @@ module Samagotchi
|
|
|
43
44
|
else ""
|
|
44
45
|
end
|
|
45
46
|
called_as = activity[:called_as].to_s
|
|
46
|
-
|
|
47
|
+
suffix = "#{suffix}#{paint(" (called as #{called_as})", 90)}" unless called_as.empty?
|
|
48
|
+
via = activity[:via].to_s
|
|
49
|
+
via.empty? ? suffix : "#{suffix}#{paint(" (via #{via})", 90)}"
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "level_setting_command"
|
|
4
4
|
require_relative "thinking"
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
|
-
# /thinking, the session's own thinking level (Session#thinking):
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
# the reply says what it costs the prompt cache (Thinking.cache_cost).
|
|
14
|
-
# A costly change asks first (CacheConfirm); --yes (or -y) applies it.
|
|
15
|
-
class ThinkingCommand
|
|
7
|
+
# /thinking, the session's own thinking level (Session#thinking):
|
|
8
|
+
# `/thinking off|low|medium|high` sets it, `/thinking default` unsets it
|
|
9
|
+
# (the session follows the process default, the model, its host, then
|
|
10
|
+
# thinking.level). What a change costs the prompt cache:
|
|
11
|
+
# Thinking.cache_cost. See LevelSettingCommand.
|
|
12
|
+
class ThinkingCommand < LevelSettingCommand
|
|
16
13
|
NAME = "/thinking"
|
|
17
14
|
SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
|
|
18
15
|
USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
|
|
@@ -24,71 +21,22 @@ module Samagotchi
|
|
|
24
21
|
tail: "Only the prompt's tail changes: the cache keeps the rest."
|
|
25
22
|
}.freeze
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return [listing(@engine.thinking_explained), false, nil] if word.empty?
|
|
45
|
-
|
|
46
|
-
level = word.to_sym
|
|
47
|
-
raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
|
|
48
|
-
|
|
49
|
-
session = @engine.session or raise ArgumentError, "no session yet"
|
|
50
|
-
own = SETTABLE.include?(level) ? level : nil
|
|
51
|
-
before = @engine.thinking_explained
|
|
52
|
-
ask = confirm && !yes ? ask(before, own, word) : nil
|
|
53
|
-
return ask if ask
|
|
54
|
-
|
|
55
|
-
@engine.thinking_override = own
|
|
56
|
-
@save.call(session)
|
|
57
|
-
after = @engine.thinking_explained
|
|
58
|
-
[[listing(after), *notes(before, after)].join("\n"), true, nil]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The level, where it came from, and what the session sets itself.
|
|
62
|
-
def listing(explained)
|
|
63
|
-
return "thinking: can't be resolved for this model" unless explained
|
|
64
|
-
|
|
65
|
-
lines = ["thinking: #{explained.label}"]
|
|
66
|
-
unless explained.own
|
|
67
|
-
lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
|
|
68
|
-
"its host, then thinking.level)"
|
|
69
|
-
end
|
|
70
|
-
lines.join("\n")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
# The reply that asks before a costly change, nil when it needn't.
|
|
76
|
-
def ask(before, own, word)
|
|
77
|
-
would = @engine.thinking_explained(own: own)
|
|
78
|
-
return nil unless before && would
|
|
79
|
-
|
|
80
|
-
cost = @engine.thinking_cache_cost
|
|
81
|
-
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
82
|
-
|
|
83
|
-
step = CacheConfirm.confirm(NAME, word)
|
|
84
|
-
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def notes(before, after)
|
|
88
|
-
return [] unless before && after
|
|
89
|
-
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
90
|
-
|
|
91
|
-
["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
|
|
92
|
-
end
|
|
24
|
+
SPEC = Spec.new(
|
|
25
|
+
name: NAME, label: "thinking", usage: USAGE, cost_notes: COST_NOTES,
|
|
26
|
+
parse: lambda { |word|
|
|
27
|
+
level = word.to_sym
|
|
28
|
+
if Thinking::LEVELS.include?(level)
|
|
29
|
+
[true, SETTABLE.include?(level) ? level : nil]
|
|
30
|
+
else
|
|
31
|
+
[false, nil]
|
|
32
|
+
end
|
|
33
|
+
},
|
|
34
|
+
own: lambda(&:thinking_override),
|
|
35
|
+
explained: ->(engine, own) { engine.thinking_explained(own: own) },
|
|
36
|
+
set: ->(engine, own) { engine.thinking_override = own },
|
|
37
|
+
cache_cost: ->(engine, _from, _to) { engine.thinking_cache_cost },
|
|
38
|
+
follows: "chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, its host, then thinking.level",
|
|
39
|
+
extra_line: nil
|
|
40
|
+
)
|
|
93
41
|
end
|
|
94
42
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_model"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "thinking"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# What the thinking level a turn runs at leads to, said to the user: a
|
|
9
|
+
# level its host can't do (an effort on a native host, or on a llama.cpp
|
|
10
|
+
# chat template that takes none), off that the model didn't honour, and a
|
|
11
|
+
# host refusing the thinking fields or the reasoning sent back. Each
|
|
12
|
+
# notice once per session and host; the log lines each time.
|
|
13
|
+
#
|
|
14
|
+
# Turn thread only (the turn's settings and its stream handler): no lock.
|
|
15
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
16
|
+
class ThinkingNotices
|
|
17
|
+
# @param session [#call] → Session, nil (its id: once per session)
|
|
18
|
+
# @param profile [#call] → ModelProfile (a native host's)
|
|
19
|
+
# @param notify [#call] (text, level, source) → a notice
|
|
20
|
+
def initialize(session:, profile:, notify:)
|
|
21
|
+
@session = session
|
|
22
|
+
@profile = profile
|
|
23
|
+
@notify = notify
|
|
24
|
+
@said = Set.new
|
|
25
|
+
@turn = nil
|
|
26
|
+
@generation_thought = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# [level, HostRegistry::ModelTarget] the turn runs at, nil before one.
|
|
30
|
+
attr_reader :turn
|
|
31
|
+
|
|
32
|
+
# The turn's thinking level on +target+ (its EffectiveModel's), what
|
|
33
|
+
# the notices after it read; an effort a native host can't do is said
|
|
34
|
+
# now.
|
|
35
|
+
def turn_started(level, target)
|
|
36
|
+
@turn = [level, target]
|
|
37
|
+
announce_level(level, target)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A generation starts (or a dropped one streams again from the start).
|
|
41
|
+
def generation_started = @generation_thought = nil
|
|
42
|
+
|
|
43
|
+
# Whether this generation's thinking lane said anything: nil while it
|
|
44
|
+
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
45
|
+
# with thinking off), true once a real thought came.
|
|
46
|
+
def note_thought(thinking)
|
|
47
|
+
return if thinking.empty?
|
|
48
|
+
|
|
49
|
+
@generation_thought ||= thinking.match?(/\S/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A generation completed: off that the model didn't honour, an effort
|
|
53
|
+
# its chat template ignores.
|
|
54
|
+
def generation_completed(event)
|
|
55
|
+
check_honoured(event)
|
|
56
|
+
announce_effort_ignored
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The host refused the level's request fields (gpt-oss can't turn
|
|
60
|
+
# thinking off) and the chat loop sent the request without them: said
|
|
61
|
+
# once per session and host, standing in for the not-honoured notice.
|
|
62
|
+
def thinking_refused(event)
|
|
63
|
+
_level, target = @turn
|
|
64
|
+
return unless target
|
|
65
|
+
|
|
66
|
+
Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
67
|
+
detail: event[:detail])
|
|
68
|
+
@said << [session_id, target.entry.name, :not_honoured]
|
|
69
|
+
notice_once(:refused, target, :warn,
|
|
70
|
+
"#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
|
|
71
|
+
"sent without it, so thinking stays as the model has it")
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
failed(:thinking_refused, e)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The host refused a sampling field (the empty-answer retry's
|
|
77
|
+
# temperature on a model that takes none) and the chat loop asked again
|
|
78
|
+
# without them, as it will for the model from now on: a log line.
|
|
79
|
+
def sampling_refused(event)
|
|
80
|
+
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
failed(:sampling_refused, e)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The host refused the model's reasoning sent back (reasoning_history)
|
|
86
|
+
# and the chat loop asked the step again without it, as it will from
|
|
87
|
+
# now on: logged, and said once per session and host.
|
|
88
|
+
def reasoning_history_refused(event)
|
|
89
|
+
_level, target = @turn
|
|
90
|
+
return unless target
|
|
91
|
+
|
|
92
|
+
Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
93
|
+
detail: event[:detail])
|
|
94
|
+
return unless @said.add?([session_id, target.entry.name, :reasoning_history_refused])
|
|
95
|
+
|
|
96
|
+
@notify.call("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
|
|
97
|
+
"#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
failed(:reasoning_history_refused, e)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# An effort on a native host, which has no knob for it: said once per
|
|
105
|
+
# session and host.
|
|
106
|
+
def announce_level(level, target)
|
|
107
|
+
return if target.entry.chat?
|
|
108
|
+
|
|
109
|
+
profile = @profile.call
|
|
110
|
+
return if Thinking.native(level, profile).honoured
|
|
111
|
+
|
|
112
|
+
notice_once(:unsupported, target, :info,
|
|
113
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
114
|
+
"thinking stays as the model has it")
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
failed(:announce_level, e)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# An effort on a llama.cpp chat host whose chat template takes none
|
|
120
|
+
# (its /props says so): said once per session and host, as for a native
|
|
121
|
+
# host. Read from the /props answer the turn's window probe left in the
|
|
122
|
+
# cache, so it asks the server nothing.
|
|
123
|
+
def announce_effort_ignored
|
|
124
|
+
level, target = @turn
|
|
125
|
+
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
126
|
+
|
|
127
|
+
props = target.client.cached_server_props(model: target.bare_model)
|
|
128
|
+
return unless Thinking.effort_ignored?(level, props)
|
|
129
|
+
|
|
130
|
+
notice_once(:unsupported, target, :info,
|
|
131
|
+
"#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
|
|
132
|
+
"(/props: supports_reasoning_effort false); thinking stays as the model has it")
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
failed(:effort_ignored, e)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Thinking off, and the model thought anyway: logged each time, said
|
|
138
|
+
# once per session and host. A thinking lane of whitespace alone is no
|
|
139
|
+
# thought.
|
|
140
|
+
def check_honoured(event)
|
|
141
|
+
level, target = @turn
|
|
142
|
+
chars = event[:thinking_chars].to_i
|
|
143
|
+
return unless level == :off && chars.positive? && target
|
|
144
|
+
return if @generation_thought == false
|
|
145
|
+
|
|
146
|
+
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
147
|
+
notice_once(:not_honoured, target, :warn,
|
|
148
|
+
"off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
|
|
149
|
+
"(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
|
|
150
|
+
"(see Thinking in docs/configuration.md)")
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
failed(:check_honoured, e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def notice_once(kind, target, level, text)
|
|
156
|
+
return unless @said.add?([session_id, target.entry.name, kind])
|
|
157
|
+
|
|
158
|
+
@notify.call(text, level, "thinking")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def session_id = @session.call&.id
|
|
162
|
+
|
|
163
|
+
# A notice that failed: none is said, the turn goes on; a log line
|
|
164
|
+
# (once per process for the notice, model and error class).
|
|
165
|
+
def failed(notice, error)
|
|
166
|
+
EffectiveModel::Unresolved.fallback(:thinking_notice_failed, @turn&.last&.model || notice.to_s, error, nil)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -56,6 +56,9 @@ module Samagotchi
|
|
|
56
56
|
# The name the model used when an alias ran a built-in (called_as: "bash"
|
|
57
57
|
# for an execute), for a UI that shows it.
|
|
58
58
|
event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
|
|
59
|
+
# The tool it was reached through (via: "call_tool", a built-in the
|
|
60
|
+
# toolkit doesn't declare): not an alias, the model named it.
|
|
61
|
+
event[:via] = call[:via] if call.is_a?(Hash) && call[:via]
|
|
59
62
|
# The model's own words for a command, which the TUI's tool line
|
|
60
63
|
# shows in place of the cut params.
|
|
61
64
|
description = command_description(tool_name, call)
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
# params and label stay where they are: their sources differ per path
|
|
24
24
|
# (the registry's preview, a plugin's label, the saved shown params).
|
|
25
25
|
module ToolRowFields
|
|
26
|
-
ACTIVITY_KEYS = %i[title called_as].freeze
|
|
26
|
+
ACTIVITY_KEYS = %i[title called_as via].freeze
|
|
27
27
|
EVENT_KEYS = %i[view ref refs].freeze
|
|
28
28
|
KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
|
|
29
29
|
|
|
@@ -33,13 +33,15 @@ module Samagotchi
|
|
|
33
33
|
# relative to it when inside it)
|
|
34
34
|
# @return [Hash] title (a few words: a project-relative path, a command
|
|
35
35
|
# without its "cd … &&"), called_as (the name the model called it by
|
|
36
|
-
# when that was an alias's: bash),
|
|
36
|
+
# when that was an alias's: bash), via (the tool it was reached
|
|
37
|
+
# through: call_tool), view (ToolView: the full command)
|
|
37
38
|
# and ref (FileRef: the file a file tool touched), each only when
|
|
38
39
|
# there is one
|
|
39
40
|
def for(tool_name, call, cwd:)
|
|
40
41
|
{
|
|
41
42
|
title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
|
|
42
43
|
called_as: call[:called_as],
|
|
44
|
+
via: call[:via],
|
|
43
45
|
view: ToolView.for(tool_name, call)&.to_h,
|
|
44
46
|
ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
|
|
45
47
|
}.compact
|
|
@@ -47,6 +47,10 @@ module Samagotchi
|
|
|
47
47
|
# file (EditPreview.change; also on tool_call_completed, never in the
|
|
48
48
|
# model's output)
|
|
49
49
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
50
|
+
# A call_tool of one of chi's own tools runs as that tool, from here
|
|
51
|
+
# on (the gate, the row, the result): rules, approvals and hooks see
|
|
52
|
+
# the real call, with via: (Tools::ToolIndex#unwrap).
|
|
53
|
+
call = unwrapped(call)
|
|
50
54
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
51
55
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
52
56
|
# A tool the session's toolkit leaves out skips it: no before_tool_call
|
|
@@ -60,7 +64,7 @@ module Samagotchi
|
|
|
60
64
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
61
65
|
tool: call[:name], call: call.dup, params: params }
|
|
62
66
|
started[:label] = label if label
|
|
63
|
-
# Its row fields (ToolRowFields: title, called_as, view, ref, and a
|
|
67
|
+
# Its row fields (ToolRowFields: title, called_as, via, view, ref, and a
|
|
64
68
|
# plugin tool's refs); the worker runs in its session's working
|
|
65
69
|
# directory.
|
|
66
70
|
fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
|
|
@@ -268,12 +272,29 @@ module Samagotchi
|
|
|
268
272
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
269
273
|
def tools = @kernel.tools
|
|
270
274
|
|
|
271
|
-
|
|
275
|
+
def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
276
|
+
|
|
277
|
+
# The call its tool's entry unwraps it to (call_tool on a built-in),
|
|
278
|
+
# else the call as it is. One that fails runs as it is: call_tool then
|
|
279
|
+
# refuses a built-in (fail closed).
|
|
280
|
+
def unwrapped(call)
|
|
281
|
+
registry = tools
|
|
282
|
+
return call unless registry.respond_to?(:offered) && !call[:name].nil?
|
|
283
|
+
|
|
284
|
+
registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
|
|
287
|
+
call
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# A call that can't run in this turn as it is, by its name: a real tool
|
|
291
|
+
# the session's toolkit leaves out, or one reached through call_tool
|
|
292
|
+
# (via:) that the toolkit doesn't reach that way (a hook's rewrite).
|
|
272
293
|
def left_out?(call)
|
|
273
294
|
registry = tools
|
|
274
295
|
return false unless registry.respond_to?(:left_out) && !call[:name].nil?
|
|
296
|
+
return registry.indexed(call[:name], layers: layers).nil? if call[:via]
|
|
275
297
|
|
|
276
|
-
layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
277
298
|
!registry.left_out(call[:name], layers: layers).nil?
|
|
278
299
|
end
|
|
279
300
|
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -25,17 +25,19 @@ module Samagotchi
|
|
|
25
25
|
# The plain form the snapshot and the web carry.
|
|
26
26
|
def to_h = { name: name, add: add, remove: remove }
|
|
27
27
|
|
|
28
|
-
# This toolkit with +tool+ declared (/toolkit add):
|
|
29
|
-
#
|
|
28
|
+
# This toolkit with +tool+ declared (/toolkit add): out of +remove+
|
|
29
|
+
# and into +add+, whatever its kit says (an add after a remove
|
|
30
|
+
# declares it).
|
|
30
31
|
def adding(tool)
|
|
31
32
|
tool = tool.to_s
|
|
32
|
-
|
|
33
|
+
with(remove: remove - [tool], add: (add + [tool]).uniq)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
# This toolkit with +tool+
|
|
36
|
+
# This toolkit with +tool+ removed (/toolkit remove), the other way: a
|
|
37
|
+
# remove is hard (call_tool can't reach it either), also after an add.
|
|
36
38
|
def removing(tool)
|
|
37
39
|
tool = tool.to_s
|
|
38
|
-
|
|
40
|
+
with(add: add - [tool], remove: (remove + [tool]).uniq)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# How the UIs name it: "lean", "lean +web_fetch -memory_write".
|
|
@@ -66,21 +68,31 @@ module Samagotchi
|
|
|
66
68
|
|
|
67
69
|
# A toolkit as a session's next turn would declare it (Engine
|
|
68
70
|
# #toolkit_explained): +toolkit+ as saved, +resolved+, the Registry
|
|
69
|
-
# +declared+ entries and the names it leaves out (+left_out+)
|
|
70
|
-
#
|
|
71
|
+
# +declared+ entries and the names it leaves out (+left_out+): the ones
|
|
72
|
+
# call_tool reaches (+reachable+) and the ones it removes (+removed+);
|
|
73
|
+
# and whether that isn't what the Registry declares now (+pending+: it
|
|
71
74
|
# applies at the next turn's start).
|
|
72
|
-
Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :pending) do
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :reachable, :removed, :pending) do
|
|
76
|
+
def initialize(toolkit:, resolved:, declared:, left_out:, pending:, reachable: [], removed: [])
|
|
77
|
+
super
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# What the prompt carries of it: the declared names, the reachable
|
|
81
|
+
# and removed ones (the not-loaded line, find_tools' chi line) and,
|
|
82
|
+
# when either list has any, the kit name the line gives. Two equal
|
|
75
83
|
# keys build the same prompt (/toolkit's "no change", its cost).
|
|
76
|
-
def prompt_key
|
|
84
|
+
def prompt_key
|
|
85
|
+
[declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
|
|
86
|
+
end
|
|
77
87
|
|
|
78
88
|
# What the UIs show (the snapshot, command_ran): nil when it declares
|
|
79
89
|
# every tool on full (full, full +delegate), which has no chip.
|
|
90
|
+
# +left_out+ is +reachable+ and +removed+ together.
|
|
80
91
|
def summary
|
|
81
92
|
return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
|
|
82
93
|
|
|
83
|
-
{ name: toolkit.name, label: toolkit.label, left_out: left_out,
|
|
94
|
+
{ name: toolkit.name, label: toolkit.label, left_out: left_out, reachable: reachable, removed: removed,
|
|
95
|
+
fallback: resolved.fallback }
|
|
84
96
|
end
|
|
85
97
|
end
|
|
86
98
|
|
|
@@ -111,8 +123,9 @@ module Samagotchi
|
|
|
111
123
|
Kit.new(name: FULL, tools: :all),
|
|
112
124
|
Kit.new(name: LEAN, tools: LEAN_TOOLS),
|
|
113
125
|
# A delegate child: it reports to its parent (send_note; the
|
|
114
|
-
# delegated-by line names it) and leaves memory writes to it
|
|
115
|
-
|
|
126
|
+
# delegated-by line names it) and leaves memory writes to it, and
|
|
127
|
+
# starts no children of its own (removed: not even through call_tool).
|
|
128
|
+
Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write delegate delegate_result])
|
|
116
129
|
].to_h { |kit| [kit.name, kit] }.freeze
|
|
117
130
|
|
|
118
131
|
# What a toolkit declares, the Registry's filter: every built-in
|
|
@@ -17,14 +17,17 @@ module Samagotchi
|
|
|
17
17
|
# @param can_stage [#call] → whether a plugin may change the toolkit now
|
|
18
18
|
# (no turn runs, no model reply yet)
|
|
19
19
|
# @param reset_prompt [#call] → build the system prompts again
|
|
20
|
+
# @param refresh_index [#call] → the tool index's search tool described
|
|
21
|
+
# for the Registry's toolkit (Tools::ToolIndex#refresh!)
|
|
20
22
|
# @param emit [#call] (sink, event) → emits to a turn's sink + observers
|
|
21
23
|
# @param notify [#call] (text, level, source, extra:) → a notice
|
|
22
|
-
def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
|
|
24
|
+
def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:, refresh_index: -> {})
|
|
23
25
|
@tools = tools
|
|
24
26
|
@session = session
|
|
25
27
|
@state_dir = state_dir
|
|
26
28
|
@can_stage = can_stage
|
|
27
29
|
@reset_prompt = reset_prompt
|
|
30
|
+
@refresh_index = refresh_index
|
|
28
31
|
@emit = emit
|
|
29
32
|
@notify = notify
|
|
30
33
|
# The toolkits plugins declare (chi.toolkit), beside the built-in and
|
|
@@ -63,7 +66,9 @@ module Samagotchi
|
|
|
63
66
|
filter = resolved.everything? ? nil : resolved
|
|
64
67
|
tools = @tools.call
|
|
65
68
|
Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
|
|
66
|
-
left_out: tools.left_out_names(toolkit: filter),
|
|
69
|
+
left_out: tools.left_out_names(toolkit: filter),
|
|
70
|
+
reachable: tools.reachable_names(toolkit: filter),
|
|
71
|
+
removed: tools.removed_names(toolkit: filter), pending: filter != tools.toolkit)
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
# The session's toolkit for the UIs (the snapshot, command_ran): nil on
|
|
@@ -114,6 +119,7 @@ module Samagotchi
|
|
|
114
119
|
return false if resolved == tools.toolkit
|
|
115
120
|
|
|
116
121
|
tools.toolkit = resolved
|
|
122
|
+
@refresh_index.call
|
|
117
123
|
@reset_prompt.call
|
|
118
124
|
true
|
|
119
125
|
end
|