samagotchi 0.65.0 → 0.66.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 +48 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/cli.md +11 -1
- data/docs/configuration.md +51 -7
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +4 -1
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +10 -0
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +8 -1
- data/lib/samagotchi/engine.rb +112 -4
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +21 -7
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/worker.rb +2 -0
- metadata +10 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "llm_context_override"
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
require_relative "thinking"
|
|
6
|
+
require_relative "toolkit"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# A session's own settings that come before its model's, carried as one
|
|
@@ -12,27 +13,33 @@ module Samagotchi
|
|
|
12
13
|
# +thinking+ a level Symbol (Thinking.session_level) or nil,
|
|
13
14
|
# +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil,
|
|
14
15
|
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
15
|
-
# named (their text goes into the system prompt; [] with none)
|
|
16
|
+
# named (their text goes into the system prompt; [] with none),
|
|
17
|
+
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
18
|
+
# session config's toolkit.default then).
|
|
16
19
|
#
|
|
17
20
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
18
21
|
# and file key, and resolves it on its own. A delegate child starts
|
|
19
22
|
# without one; a continue and a fork copy their session's (.of).
|
|
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: [])
|
|
23
|
+
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit) do
|
|
24
|
+
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
|
|
22
25
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
23
|
-
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze)
|
|
26
|
+
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
def empty?
|
|
29
|
+
def empty?
|
|
30
|
+
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
|
|
31
|
+
end
|
|
27
32
|
|
|
28
|
-
# A session's own setup, for a session that starts from it.
|
|
33
|
+
# A session's own setup, for a session that starts from it. Its
|
|
34
|
+
# toolkit always: one from before toolkits is full (Toolkit.of), not
|
|
35
|
+
# whatever config's default is now.
|
|
29
36
|
# @param session [Session, nil]
|
|
30
37
|
# @return [SessionSetup]
|
|
31
38
|
def self.of(session)
|
|
32
39
|
return new if session.nil?
|
|
33
40
|
|
|
34
41
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
35
|
-
appended_prompt_files: session.appended_prompt_files)
|
|
42
|
+
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit))
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
45
|
end
|
|
@@ -317,7 +317,7 @@ module Samagotchi
|
|
|
317
317
|
"Project memories:\n#{indexes[:project]}",
|
|
318
318
|
"System memories:\n#{indexes[:system]}"
|
|
319
319
|
].join("\n\n")
|
|
320
|
-
stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
320
|
+
stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
321
321
|
project_specific_description, appended_section, memory_sections]
|
|
322
322
|
stable_text = stable.compact.join("\n")
|
|
323
323
|
volatile = [current_model, project_location, current_session].compact
|
|
@@ -328,6 +328,21 @@ module Samagotchi
|
|
|
328
328
|
with_tail_declarations(prompt, chat: chat, layers: layers)
|
|
329
329
|
end
|
|
330
330
|
|
|
331
|
+
# The tools the session's toolkit leaves out, named once right after the
|
|
332
|
+
# base prompt: sessions on another toolkit already differ at the tools,
|
|
333
|
+
# and nothing later (a memory write) moves it. nil when none are (full):
|
|
334
|
+
# the prompt is what it was before toolkits.
|
|
335
|
+
def not_loaded_line(layers)
|
|
336
|
+
registry = @tools_lookup.call
|
|
337
|
+
return nil unless registry.respond_to?(:left_out_names)
|
|
338
|
+
|
|
339
|
+
names = registry.left_out_names(layers: layers)
|
|
340
|
+
return nil if names.empty?
|
|
341
|
+
|
|
342
|
+
"Not loaded in this session (toolkit #{registry.toolkit&.name}): #{names.join(", ")}. " \
|
|
343
|
+
"Only the user can load one (/toolkit add <name>)."
|
|
344
|
+
end
|
|
345
|
+
|
|
331
346
|
def with_tail_declarations(prompt, chat:, layers:)
|
|
332
347
|
chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
|
|
333
348
|
end
|
|
@@ -46,17 +46,23 @@ module Samagotchi
|
|
|
46
46
|
# @param thinking [String, nil] --thinking as typed: a new session
|
|
47
47
|
# starts with it as its own level; a resumed or attached one's
|
|
48
48
|
# worker gets a /thinking, after those
|
|
49
|
+
# @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
|
|
50
|
+
# session's toolkit (config's toolkit.default without); an existing
|
|
51
|
+
# session keeps its own
|
|
52
|
+
# @param toolkit_flags [Array<String>] which of those were given, for
|
|
53
|
+
# the line an existing session's run prints
|
|
49
54
|
# @param state_dir [String, nil] where the sessions are (the default
|
|
50
55
|
# state dir by default): the worker's, for the attached loop too
|
|
51
56
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
52
57
|
# :failed when the --model switch didn't go through, or (input from a
|
|
53
58
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
54
59
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
55
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [],
|
|
60
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
61
|
+
toolkit_flags: [], state_dir: nil)
|
|
56
62
|
state_dir ||= Session.default_state_dir
|
|
57
63
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
58
64
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
59
|
-
appended_prompt_files: appended_prompt_files)
|
|
65
|
+
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags)
|
|
60
66
|
commands = []
|
|
61
67
|
commands << "/model #{model}" if model && (attach || resume)
|
|
62
68
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -123,10 +129,12 @@ module Samagotchi
|
|
|
123
129
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
124
130
|
# @raise [Error]
|
|
125
131
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
126
|
-
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [],
|
|
132
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
133
|
+
toolkit_flags: [], err: $stderr)
|
|
127
134
|
sd = state_dir || Session.default_state_dir
|
|
128
135
|
if attach || resume
|
|
129
|
-
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files
|
|
136
|
+
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
137
|
+
toolkit_flags: toolkit_flags)
|
|
130
138
|
end
|
|
131
139
|
if attach
|
|
132
140
|
live = connect_existing(attach, sd)
|
|
@@ -143,7 +151,8 @@ module Samagotchi
|
|
|
143
151
|
memories: memories, muted_memories: muted_memories,
|
|
144
152
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
145
153
|
thinking: Thinking.session_level(thinking),
|
|
146
|
-
appended_prompt_files: appended_prompt_files
|
|
154
|
+
appended_prompt_files: appended_prompt_files,
|
|
155
|
+
toolkit: toolkit))
|
|
147
156
|
end
|
|
148
157
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
149
158
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -159,14 +168,15 @@ module Samagotchi
|
|
|
159
168
|
end
|
|
160
169
|
|
|
161
170
|
# An existing session's prompt is built from its own session fields:
|
|
162
|
-
# --memory/--mute/--append-system-prompt
|
|
163
|
-
# --resume are ignored, with a line saying so (printed
|
|
164
|
-
# region opens).
|
|
165
|
-
def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [])
|
|
171
|
+
# --memory/--mute/--append-system-prompt and the toolkit flags given
|
|
172
|
+
# with --attach or --resume are ignored, with a line saying so (printed
|
|
173
|
+
# before the live region opens).
|
|
174
|
+
def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [], toolkit_flags: [])
|
|
166
175
|
flags = []
|
|
167
176
|
flags << "--memory" unless Array(memories).empty?
|
|
168
177
|
flags << "--mute" unless Array(muted_memories).empty?
|
|
169
178
|
flags << "--append-system-prompt" unless Array(appended_prompt_files).empty?
|
|
179
|
+
flags.concat(toolkit_flags)
|
|
170
180
|
return if flags.empty?
|
|
171
181
|
|
|
172
182
|
verb = flags.size == 1 ? "applies" : "apply"
|
|
@@ -97,10 +97,15 @@ module Samagotchi
|
|
|
97
97
|
# @param appended_prompt_files [Array<String>] --append-system-prompt's
|
|
98
98
|
# files (absolute): a new session saves them; a resumed one's own come
|
|
99
99
|
# first, and the merge goes only to the Engine (as --memory's does)
|
|
100
|
+
# @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
|
|
101
|
+
# session's toolkit (config's toolkit.default without); a resumed one
|
|
102
|
+
# keeps its own, with a line naming +toolkit_flags+ (the flags given)
|
|
100
103
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
101
104
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
102
|
-
appended_prompt_files: [])
|
|
105
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
|
|
103
106
|
@scratch = scratch
|
|
107
|
+
@toolkit = toolkit
|
|
108
|
+
@toolkit_flags = toolkit_flags
|
|
104
109
|
@thinking_word = thinking
|
|
105
110
|
@prompt = prompt
|
|
106
111
|
@llm_context_words = llm_context
|
|
@@ -269,7 +274,7 @@ module Samagotchi
|
|
|
269
274
|
preloaded_memory_names: @requested_memories,
|
|
270
275
|
muted_memory_names: @muted_memory_names,
|
|
271
276
|
scratch: @scratch,
|
|
272
|
-
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
|
|
277
|
+
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured)
|
|
273
278
|
))
|
|
274
279
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
275
280
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -279,6 +284,7 @@ module Samagotchi
|
|
|
279
284
|
session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
|
|
280
285
|
session.save if @resume_session
|
|
281
286
|
end
|
|
287
|
+
warn_toolkit_flags_ignored(session) if @resume_session
|
|
282
288
|
claim_session!(session.id) unless @owner_lock
|
|
283
289
|
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
284
290
|
# note, before this REPL's first turn appends after it.
|
|
@@ -714,6 +720,16 @@ module Samagotchi
|
|
|
714
720
|
end
|
|
715
721
|
end
|
|
716
722
|
|
|
723
|
+
# The toolkit flags are a new session's: a resumed one keeps its own
|
|
724
|
+
# toolkit (as attached mode says it, AttachLauncher).
|
|
725
|
+
def warn_toolkit_flags_ignored(session)
|
|
726
|
+
return if @toolkit_flags.empty?
|
|
727
|
+
|
|
728
|
+
names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
|
|
729
|
+
warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}'s prompt is already built)"
|
|
730
|
+
end
|
|
731
|
+
private :warn_toolkit_flags_ignored
|
|
732
|
+
|
|
717
733
|
private
|
|
718
734
|
|
|
719
735
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
@@ -49,8 +49,11 @@ module Samagotchi
|
|
|
49
49
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
50
50
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
51
51
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
52
|
+
# A tool the session's toolkit leaves out skips it: no before_tool_call
|
|
53
|
+
# hook, no approval card for a call that can't run (KernelLoop#dispatch
|
|
54
|
+
# answers it with the not-loaded error).
|
|
52
55
|
original = call.dup
|
|
53
|
-
verdict = evaluate(call, iteration, params)
|
|
56
|
+
verdict = left_out?(call) ? Guardrails::Verdict.new(call: call) : evaluate(call, iteration, params)
|
|
54
57
|
call = verdict.call
|
|
55
58
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
56
59
|
label = plugin_label(call[:name])
|
|
@@ -265,6 +268,15 @@ module Samagotchi
|
|
|
265
268
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
266
269
|
def tools = @kernel.tools
|
|
267
270
|
|
|
271
|
+
# A real tool the session's toolkit leaves out of this turn.
|
|
272
|
+
def left_out?(call)
|
|
273
|
+
registry = tools
|
|
274
|
+
return false unless registry.respond_to?(:left_out) && !call[:name].nil?
|
|
275
|
+
|
|
276
|
+
layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
277
|
+
!registry.left_out(call[:name], layers: layers).nil?
|
|
278
|
+
end
|
|
279
|
+
|
|
268
280
|
# A tool the registry has from a plugin (not core, not unknown).
|
|
269
281
|
def plugin_tool?(name)
|
|
270
282
|
entry = tools && !name.nil? ? tools[name] : nil
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session's toolkit: which of the registered tools its prompts declare
|
|
7
|
+
# (docs/sessions.md, "Toolkits"). Saved in the session file as given
|
|
8
|
+
# ({"name" => "lean", "add" => [], "remove" => []}) when the session is
|
|
9
|
+
# created, kept by continues and forks, and never resolved again from
|
|
10
|
+
# config: a respawned worker declares what the session was created with.
|
|
11
|
+
# A session without one (a file from before toolkits) is on FULL.
|
|
12
|
+
#
|
|
13
|
+
# +name+ names a Kit (the built-ins, config's toolkit.kits, a plugin's
|
|
14
|
+
# chi.toolkit); +add+ and +remove+ are the session's own changes on top
|
|
15
|
+
# (--tool / --no-tool, /toolkit add|remove). #resolve against the kits
|
|
16
|
+
# known gives the Resolved the Registry filters by.
|
|
17
|
+
Toolkit = Data.define(:name, :add, :remove) do
|
|
18
|
+
def initialize(name:, add: [], remove: [])
|
|
19
|
+
super(name: name.to_s.strip, add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Hash] the session file's "toolkit"
|
|
23
|
+
def to_file = { "name" => name, "add" => add, "remove" => remove }
|
|
24
|
+
|
|
25
|
+
# The plain form the snapshot and the web carry.
|
|
26
|
+
def to_h = { name: name, add: add, remove: remove }
|
|
27
|
+
|
|
28
|
+
# This toolkit with +tool+ declared (/toolkit add): a tool it removed
|
|
29
|
+
# comes out of +remove+ (back to its kit's), any other goes into +add+.
|
|
30
|
+
def adding(tool)
|
|
31
|
+
tool = tool.to_s
|
|
32
|
+
remove.include?(tool) ? with(remove: remove - [tool]) : with(add: (add + [tool]).uniq)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# This toolkit with +tool+ left out (/toolkit remove), the other way.
|
|
36
|
+
def removing(tool)
|
|
37
|
+
tool = tool.to_s
|
|
38
|
+
add.include?(tool) ? with(add: add - [tool]) : with(remove: (remove + [tool]).uniq)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# How the UIs name it: "lean", "lean +web_fetch -memory_write".
|
|
42
|
+
def label = [name, *add.map { |tool| "+#{tool}" }, *remove.map { |tool| "-#{tool}" }].join(" ")
|
|
43
|
+
|
|
44
|
+
def full? = name == Toolkit::FULL && add.empty? && remove.empty?
|
|
45
|
+
|
|
46
|
+
# The kit +name+ names, from +kits+ (Toolkit.kits), with the session's
|
|
47
|
+
# own +add+ and +remove+ on top. An unknown name, a base that is, or a
|
|
48
|
+
# cycle falls back to FULL with the reason in Resolved#fallback; the
|
|
49
|
+
# saved toolkit is untouched, so a reinstalled bundle gets it back.
|
|
50
|
+
# @param kits [Hash{String => Kit}]
|
|
51
|
+
# @return [Resolved]
|
|
52
|
+
def resolve(kits)
|
|
53
|
+
Toolkit.resolve_kit(name, kits).changed(add: add, remove: remove)
|
|
54
|
+
rescue Toolkit::Unresolved => e
|
|
55
|
+
Toolkit.resolve_kit(Toolkit::FULL, kits).changed(add: add, remove: remove).with(fallback: e.message)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Toolkit
|
|
60
|
+
FULL = "full"
|
|
61
|
+
LEAN = "lean"
|
|
62
|
+
CHILD = "child"
|
|
63
|
+
|
|
64
|
+
# A name that can't be resolved (unknown, a cycle).
|
|
65
|
+
class Unresolved < StandardError; end
|
|
66
|
+
|
|
67
|
+
# A toolkit as a session's next turn would declare it (Engine
|
|
68
|
+
# #toolkit_explained): +toolkit+ as saved, +resolved+, the Registry
|
|
69
|
+
# +declared+ entries and the names it leaves out (+left_out+), and
|
|
70
|
+
# whether that isn't what the Registry declares now (+pending+: it
|
|
71
|
+
# applies at the next turn's start).
|
|
72
|
+
Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :pending) do
|
|
73
|
+
# What the prompt carries of it: the declared names and, when it
|
|
74
|
+
# leaves tools out, the kit name the not-loaded line gives. Two equal
|
|
75
|
+
# keys build the same prompt (/toolkit's "no change", its cost).
|
|
76
|
+
def prompt_key = [declared.map(&:name), left_out.empty? ? nil : resolved.name]
|
|
77
|
+
|
|
78
|
+
# What the UIs show (the snapshot, command_ran): nil when it declares
|
|
79
|
+
# every tool on full (full, full +delegate), which has no chip.
|
|
80
|
+
def summary
|
|
81
|
+
return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
|
|
82
|
+
|
|
83
|
+
{ name: toolkit.name, label: toolkit.label, left_out: left_out, fallback: resolved.fallback }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Tool names as a list of unique, stripped strings.
|
|
88
|
+
def self.tool_names(names) = Array(names).map { |tool| tool.to_s.strip }.reject(&:empty?).uniq
|
|
89
|
+
|
|
90
|
+
# A named list of declared tools: +tools+ (:all, every built-in; or the
|
|
91
|
+
# built-in names) when it has no +base+, else its +base+'s with +add+
|
|
92
|
+
# and +remove+. +source+: "core", "config", or the bundle that declared
|
|
93
|
+
# it (chi.toolkit).
|
|
94
|
+
Kit = Data.define(:name, :base, :tools, :add, :remove, :source) do
|
|
95
|
+
def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core")
|
|
96
|
+
super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
|
|
97
|
+
add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The built-ins `lean` declares: the file and shell tools, memories,
|
|
102
|
+
# the task tools (execute's hand-over, turn notes and task_wait's tail
|
|
103
|
+
# name the trio), questions, web_fetch, and context_read (core notes
|
|
104
|
+
# tell the model to call it).
|
|
105
|
+
LEAN_TOOLS = %w[
|
|
106
|
+
execute read write edit memory_read memory_write task_create task_wait task_get task_list task_stop
|
|
107
|
+
ask_user_question web_fetch context_read
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
BUILTIN_KITS = [
|
|
111
|
+
Kit.new(name: FULL, tools: :all),
|
|
112
|
+
Kit.new(name: LEAN, tools: LEAN_TOOLS),
|
|
113
|
+
# A delegate child: it reports to its parent (send_note; the
|
|
114
|
+
# delegated-by line names it) and leaves memory writes to it.
|
|
115
|
+
Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write])
|
|
116
|
+
].to_h { |kit| [kit.name, kit] }.freeze
|
|
117
|
+
|
|
118
|
+
# What a toolkit declares, the Registry's filter: every built-in
|
|
119
|
+
# (+all_core+) or the ones in +core+, every plugin tool and every tool
|
|
120
|
+
# an LLM context layer gates (forget_outputs), but for +removed+;
|
|
121
|
+
# +added+ declares any registered tool. +fallback+: why the session's
|
|
122
|
+
# own toolkit couldn't be resolved (this is FULL then), nil when it was.
|
|
123
|
+
Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback) do
|
|
124
|
+
def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil)
|
|
125
|
+
super(name: name.to_s, all_core: all_core, core: core.dup.freeze, added: added.dup.freeze,
|
|
126
|
+
removed: removed.dup.freeze, fallback: fallback)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every built-in and nothing removed: what no toolkit declares.
|
|
130
|
+
def everything? = all_core && removed.empty?
|
|
131
|
+
|
|
132
|
+
# @param entry [Tools::Registry::Entry]
|
|
133
|
+
def declares?(entry)
|
|
134
|
+
return false if removed.include?(entry.name)
|
|
135
|
+
return true if added.include?(entry.name) || !entry.core? || !entry.layer.nil?
|
|
136
|
+
|
|
137
|
+
all_core || core.include?(entry.name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# This with +add+ declared and +remove+ left out, in that order of
|
|
141
|
+
# the kit's own words: a later change wins.
|
|
142
|
+
def changed(add: [], remove: [])
|
|
143
|
+
added = self.added - remove
|
|
144
|
+
removed = self.removed - add
|
|
145
|
+
with(added: (added + add).uniq, removed: (removed + remove).uniq)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The session file's "toolkit": a Toolkit, nil for none (a file from
|
|
150
|
+
# before toolkits, or one that isn't a toolkit), which reads as FULL.
|
|
151
|
+
def self.from_file(value)
|
|
152
|
+
case value
|
|
153
|
+
when Hash
|
|
154
|
+
name = value["name"] || value[:name]
|
|
155
|
+
return nil if name.to_s.strip.empty?
|
|
156
|
+
|
|
157
|
+
new(name: name, add: value["add"] || value[:add], remove: value["remove"] || value[:remove])
|
|
158
|
+
when String, Symbol
|
|
159
|
+
value.to_s.strip.empty? ? nil : new(name: value)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The toolkit a new session is stored on: config's toolkit.default, or
|
|
164
|
+
# toolkit.child for a delegate child; FULL when unset. As named: the
|
|
165
|
+
# worker resolves it (plugins' kits load there).
|
|
166
|
+
def self.configured(delegate: false)
|
|
167
|
+
name = Config.get(delegate ? "toolkit.child" : "toolkit.default").to_s.strip
|
|
168
|
+
new(name: name.empty? ? FULL : name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# config.yml's toolkit.kits (a map of name => {base:, add:, remove:}),
|
|
172
|
+
# nil when it has none.
|
|
173
|
+
def self.config_kits
|
|
174
|
+
section = ConfigFile.section("toolkit")
|
|
175
|
+
section.is_a?(Hash) ? section["kits"] : nil
|
|
176
|
+
rescue StandardError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A new session's toolkit from chi --toolkit NAME / --tool / --no-tool
|
|
181
|
+
# (each repeatable, a comma list too): NAME (config's toolkit.default
|
|
182
|
+
# without one) with those tools added and left out; nil when none was
|
|
183
|
+
# given. A tool in both is left out.
|
|
184
|
+
def self.from_flags(name: nil, add: [], remove: [])
|
|
185
|
+
add = split_names(add)
|
|
186
|
+
remove = split_names(remove)
|
|
187
|
+
return nil if name.to_s.strip.empty? && add.empty? && remove.empty?
|
|
188
|
+
|
|
189
|
+
base = name.to_s.strip.empty? ? configured.name : name
|
|
190
|
+
new(name: base, add: add - remove, remove: remove)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.split_names(values) = tool_names(Array(values).flat_map { |value| value.to_s.split(",") })
|
|
194
|
+
private_class_method :split_names
|
|
195
|
+
|
|
196
|
+
# The toolkit a session is on: its own, or FULL without one.
|
|
197
|
+
def self.of(toolkit) = toolkit || new(name: FULL)
|
|
198
|
+
|
|
199
|
+
# What a toolkit change costs the prompt cache (CacheConfirm): the
|
|
200
|
+
# declared tools are the front of every request, so any change to them
|
|
201
|
+
# (or to the not-loaded line) is :full; nil when they stay the same.
|
|
202
|
+
# @param before [Object] what the prompt carries now (Explained#prompt_key)
|
|
203
|
+
# @param after [Object] what it would carry after the change
|
|
204
|
+
def self.cache_cost(before, after) = before == after ? nil : :full
|
|
205
|
+
|
|
206
|
+
# @return [Resolved] +name+'s kit through its bases
|
|
207
|
+
# @raise [Unresolved] unknown, or a cycle
|
|
208
|
+
def self.resolve_kit(name, kits, seen = [])
|
|
209
|
+
raise Unresolved, "no toolkit named #{name}" unless (kit = kits[name])
|
|
210
|
+
raise Unresolved, "toolkit #{name}'s bases make a cycle (#{(seen + [name]).join(" → ")})" if seen.include?(name)
|
|
211
|
+
|
|
212
|
+
base = if kit.base
|
|
213
|
+
resolve_kit(kit.base, kits, seen + [name])
|
|
214
|
+
else
|
|
215
|
+
Resolved.new(name: name, all_core: kit.tools == :all, core: kit.tools == :all ? [] : kit.tools)
|
|
216
|
+
end
|
|
217
|
+
base.changed(add: kit.add, remove: kit.remove).with(name: name)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The kits known: the built-ins, then config's toolkit.kits, then the
|
|
221
|
+
# plugins' (chi.toolkit). A config kit with a built-in's name is left
|
|
222
|
+
# out (one it can't redefine) with a line in +errors+, and so is one
|
|
223
|
+
# that isn't a {base:, add:, remove:} map.
|
|
224
|
+
# @param config [Hash, nil] toolkit.kits
|
|
225
|
+
# @param plugins [Array<Kit>]
|
|
226
|
+
# @param errors [Array<String>] gets a line per kit left out
|
|
227
|
+
# @return [Hash{String => Kit}]
|
|
228
|
+
def self.kits(config: nil, plugins: [], errors: [])
|
|
229
|
+
kits = BUILTIN_KITS.dup
|
|
230
|
+
(config.is_a?(Hash) ? config : {}).each do |name, spec|
|
|
231
|
+
name = name.to_s
|
|
232
|
+
if kits.key?(name)
|
|
233
|
+
errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
|
|
234
|
+
elsif !spec.is_a?(Hash)
|
|
235
|
+
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
|
|
236
|
+
else
|
|
237
|
+
spec = spec.transform_keys(&:to_s)
|
|
238
|
+
kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
|
|
239
|
+
source: "config")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
plugins.each { |kit| kits[kit.name] ||= kit }
|
|
243
|
+
kits
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "cache_confirm"
|
|
5
|
+
require_relative "toolkit"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# /toolkit, the session's toolkit (Session#toolkit): with no argument,
|
|
9
|
+
# the tools its next turn declares with their estimated tokens, the
|
|
10
|
+
# toolkit and the tools it leaves out; `/toolkit NAME` puts the session
|
|
11
|
+
# on another toolkit (its own adds and removes go), `/toolkit add TOOL`
|
|
12
|
+
# and `/toolkit remove TOOL` change one tool. A change is saved at once
|
|
13
|
+
# and applies at the next turn's start (Engine#apply_toolkit!). The
|
|
14
|
+
# declared tools are the front of every request, so once the session has
|
|
15
|
+
# a model reply a change asks first (CacheConfirm); --yes applies it.
|
|
16
|
+
class ToolkitCommand
|
|
17
|
+
NAME = "/toolkit"
|
|
18
|
+
USAGE = "usage: #{NAME} [NAME | add TOOL | remove TOOL]".freeze
|
|
19
|
+
# Characters per token for the estimates (context.chars_per_token's
|
|
20
|
+
# default; a listing, not a meter).
|
|
21
|
+
CHARS_PER_TOKEN = 4.0
|
|
22
|
+
|
|
23
|
+
# @param engine [Engine] #session, #toolkit_explained, #toolkit_kits,
|
|
24
|
+
# #tools, #history?
|
|
25
|
+
# @param save [#call] saves the session after a change
|
|
26
|
+
def initialize(engine:, save:)
|
|
27
|
+
@engine = engine
|
|
28
|
+
@save = save
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param args [String] what follows /toolkit (--yes anywhere)
|
|
32
|
+
# @param confirm [Boolean] ask before a change once the session has a
|
|
33
|
+
# model reply (CacheConfirm); false applies it at once
|
|
34
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
|
|
35
|
+
# whether the toolkit changed, and the confirm it asks for (nothing
|
|
36
|
+
# changed then)
|
|
37
|
+
# @raise [ArgumentError] an unknown toolkit or tool, or bad words
|
|
38
|
+
def run(args, confirm: true)
|
|
39
|
+
words, yes = CacheConfirm.split(args, downcase: false)
|
|
40
|
+
return [listing(@engine.toolkit_explained), false, nil] if words.empty?
|
|
41
|
+
|
|
42
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
43
|
+
before = @engine.toolkit_explained
|
|
44
|
+
after = @engine.toolkit_explained(changed(before.toolkit, words.split))
|
|
45
|
+
# Nothing the prompt carries changes (full +delegate on full): not saved.
|
|
46
|
+
return [listing(before) + "\n(no change to what the next turn declares)", false, nil] if after.prompt_key == before.prompt_key
|
|
47
|
+
|
|
48
|
+
cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
|
|
49
|
+
if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
|
|
50
|
+
step = CacheConfirm.confirm(NAME, words)
|
|
51
|
+
return [[change_line(before, after), "The tools are the front of every request: the whole prompt is read " \
|
|
52
|
+
"again from them on.", step.text].join("\n"), false, step]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
session.toolkit = after.toolkit
|
|
56
|
+
@save.call(session)
|
|
57
|
+
[[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The toolkit, its declared tools with tokens, and the ones left out.
|
|
61
|
+
def listing(explained)
|
|
62
|
+
toolkit = explained.toolkit
|
|
63
|
+
lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
|
|
64
|
+
if (fallback = explained.resolved.fallback)
|
|
65
|
+
lines << " #{fallback}: every tool is declared (full) until it resolves"
|
|
66
|
+
end
|
|
67
|
+
declared = explained.declared
|
|
68
|
+
lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
|
|
69
|
+
"#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
|
|
70
|
+
left_out = explained.left_out
|
|
71
|
+
lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
|
|
72
|
+
lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
|
|
73
|
+
lines.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# +toolkit+ after the words: NAME, add TOOL…, remove TOOL….
|
|
79
|
+
def changed(toolkit, words)
|
|
80
|
+
verb, *tools = words
|
|
81
|
+
case verb.downcase
|
|
82
|
+
when "add", "remove"
|
|
83
|
+
raise ArgumentError, "#{NAME} #{verb} needs a tool name\n#{USAGE}" if tools.empty?
|
|
84
|
+
|
|
85
|
+
tools.reduce(toolkit) do |kit, tool|
|
|
86
|
+
raise ArgumentError, "no tool named #{tool} in this session" unless @engine.tools.key?(tool)
|
|
87
|
+
|
|
88
|
+
verb.casecmp?("add") ? kit.adding(tool) : kit.removing(tool)
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
raise ArgumentError, "#{NAME} takes one toolkit name\n#{USAGE}" unless tools.empty?
|
|
92
|
+
|
|
93
|
+
kits = @engine.toolkit_kits
|
|
94
|
+
raise ArgumentError, "no toolkit named #{verb} (toolkits: #{kits.keys.join(", ")})" unless kits.key?(verb)
|
|
95
|
+
|
|
96
|
+
Toolkit.new(name: verb)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def change_line(before, after)
|
|
101
|
+
added = names(after) - names(before)
|
|
102
|
+
removed = names(before) - names(after)
|
|
103
|
+
parts = []
|
|
104
|
+
parts << "declares #{added.join(", ")}" unless added.empty?
|
|
105
|
+
parts << "leaves out #{removed.join(", ")}" unless removed.empty?
|
|
106
|
+
parts << "declares the same tools under its own name" if parts.empty?
|
|
107
|
+
"Switching to #{after.toolkit.label} #{parts.join("; ")}."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def names(explained) = explained.declared.map(&:name)
|
|
111
|
+
|
|
112
|
+
# A tool's schema in tokens, estimated from its JSON.
|
|
113
|
+
def tokens(entry) = (JSON.generate(entry.schema).length / CHARS_PER_TOKEN).round
|
|
114
|
+
|
|
115
|
+
def format_tokens(count) = count >= 1000 ? format("%.1fk", count / 1000.0) : count.to_s
|
|
116
|
+
end
|
|
117
|
+
end
|