samagotchi 0.64.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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -39,7 +39,7 @@ module Samagotchi
39
39
 
40
40
  USAGE = <<~TEXT
41
41
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
42
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
43
43
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
44
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
45
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
@@ -63,8 +63,8 @@ module Samagotchi
63
63
  the current one
64
64
  --continues ID
65
65
  (--new) start the next link of that session's chain:
66
- in its folder, on its model, LLM context and
67
- thinking level, with a note carrying its recap; it
66
+ in its folder, on its model, LLM context,
67
+ thinking level and toolkit, with a note carrying its recap; it
68
68
  is archived. last:ID names the chain's latest link.
69
69
  Without -m the new session waits idle. Refused for a
70
70
  session continued already (naming the next link) or
@@ -77,6 +77,11 @@ module Samagotchi
77
77
  (--new) its own thinking level (off, low, medium,
78
78
  high; default: none of its own), above a process
79
79
  default such as SAMAGOTCHI_THINKING_LEVEL
80
+ --toolkit NAME, --tool NAME, --no-tool NAME
81
+ (--new) its toolkit, the tools its prompts declare
82
+ (full, lean, child, a config or bundle one; default
83
+ config's toolkit.default), and tools declared on top
84
+ of it or left out (repeatable, a comma list too)
80
85
  --wait wait for the answer and print it (one session); the
81
86
  other lines go to stderr. Exit 3: it waits for an
82
87
  answer from you: the question, its options and the
@@ -126,6 +131,9 @@ module Samagotchi
126
131
  f.value "--llm-context-apply", key: :llm_apply
127
132
  f.value "--llm-context-budget", key: :llm_budget
128
133
  f.value "--thinking"
134
+ f.value "--toolkit"
135
+ f.value "--tool", key: :tools, repeat: true
136
+ f.value "--no-tool", key: :no_tools, repeat: true
129
137
  f.value "--image", key: :images, repeat: true
130
138
  f.switch "--wait"
131
139
  f.switch "--cut"
@@ -262,7 +270,8 @@ module Samagotchi
262
270
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
263
271
  return new_options(options) if options[:new]
264
272
 
265
- %i[dir model continues thinking].each { |key| return usage_error("--#{key} needs --new") if options[key] }
273
+ %i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
274
+ TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
266
275
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
267
276
  return usage_error("give session ids") if options[:ids].empty?
268
277
 
@@ -273,15 +282,19 @@ module Samagotchi
273
282
  LLM_CONTEXT_FLAGS = { llm_strategy: "--llm-context", llm_apply: "--llm-context-apply",
274
283
  llm_budget: "--llm-context-budget" }.freeze
275
284
  LLM_CONTEXT_FIELDS = { llm_strategy: :strategy, llm_apply: :apply, llm_budget: :budget_tokens }.freeze
285
+ # --tool / --no-tool's option keys.
286
+ TOOLKIT_FLAGS = { tools: "--tool", no_tools: "--no-tool" }.freeze
276
287
 
277
288
  def new_options(options)
278
289
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
279
290
 
280
291
  if options[:continues]
281
292
  taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
282
- LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])]
293
+ LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
294
+ [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
283
295
  unless taken.compact.empty?
284
- return usage_error("--continues takes the previous session's folder, model, LLM context and thinking level; " \
296
+ return usage_error("--continues takes the previous session's folder, model, LLM context, thinking level and " \
297
+ "toolkit; " \
285
298
  "leave out #{taken.compact.join(", ")}")
286
299
  end
287
300
  end
@@ -301,6 +314,8 @@ module Samagotchi
301
314
 
302
315
  options[:thinking] = Thinking.session_level(word)
303
316
  end
317
+ toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
318
+ options[:toolkit_value] = toolkit if toolkit
304
319
 
305
320
  if options[:dir]
306
321
  options[:dir] = File.expand_path(options[:dir])
@@ -348,7 +363,8 @@ module Samagotchi
348
363
  SessionManager.spawn_session(**start, working_directory: options[:dir],
349
364
  model_name: options[:model],
350
365
  setup: SessionSetup.new(llm_context: options[:llm_context],
351
- thinking: options[:thinking]),
366
+ thinking: options[:thinking],
367
+ toolkit: options[:toolkit_value]),
352
368
  state_dir: @state_dir)
353
369
  end
354
370
  rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -184,6 +184,19 @@ module Samagotchi
184
184
  # couldn't read; nil when there is neither.
185
185
  def reasoning_history_file = @reasoning_history&.to_s || @reasoning_history_unread
186
186
 
187
+ # The session's toolkit (Toolkit: the tools its prompts declare), as
188
+ # given when it was created; nil for a session from before toolkits,
189
+ # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
190
+ # again from config, so a respawned worker declares the same tools; a
191
+ # continue and a fork copy it (SessionSetup.of), a delegate child gets
192
+ # toolkit.child (SessionManager.spawn_session).
193
+ attr_reader :toolkit
194
+
195
+ # @param value [Toolkit, Hash, String, nil] the session file's form too
196
+ def toolkit=(value)
197
+ @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
198
+ end
199
+
187
200
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
188
201
  # left), e.g. "loop-guard"; nil when it ended otherwise.
189
202
  def stopped_by
@@ -213,7 +226,7 @@ module Samagotchi
213
226
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
214
227
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
215
228
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
216
- interrupted_turn: nil)
229
+ toolkit: nil, interrupted_turn: nil)
217
230
  @id = id
218
231
  @metadata_version = metadata_version
219
232
  @mode = mode
@@ -242,6 +255,7 @@ module Samagotchi
242
255
  self.thinking = thinking
243
256
  self.reasoning_history = reasoning_history
244
257
  self.appended_prompt_files = appended_prompt_files
258
+ self.toolkit = toolkit
245
259
  self.interrupted_turn = interrupted_turn
246
260
  @turn_checkpoint = nil
247
261
  @archived = false
@@ -336,6 +350,7 @@ module Samagotchi
336
350
  thinking: setup.thinking,
337
351
  reasoning_history: setup.reasoning_history,
338
352
  appended_prompt_files: setup.appended_prompt_files,
353
+ toolkit: setup.toolkit,
339
354
  continues: continues
340
355
  )
341
356
  end
@@ -400,6 +415,7 @@ module Samagotchi
400
415
  "thinking" => nil,
401
416
  "reasoning_history" => nil,
402
417
  "appended_prompt_files" => [],
418
+ "toolkit" => nil,
403
419
  "interrupted_turn" => nil
404
420
  }.freeze
405
421
 
@@ -527,6 +543,7 @@ module Samagotchi
527
543
  when "llm_context" then llm_context_file
528
544
  when "thinking" then thinking_file
529
545
  when "reasoning_history" then reasoning_history_file
546
+ when "toolkit" then @toolkit&.to_file
530
547
  when "prompt_notes" then @prompt_notes.map(&:to_file)
531
548
  when "last_turn" then @last_turn&.to_file
532
549
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
@@ -722,14 +739,15 @@ module Samagotchi
722
739
  symbolize_empty_answer(message)
723
740
  end
724
741
 
725
- # An empty-answer note's marker (TurnNote.empty) and its steps, as
726
- # they were saved.
742
+ # An empty-answer note's marker (TurnNote.empty), its steps and its
743
+ # refusal, as they were saved.
727
744
  def symbolize_empty_answer(message)
728
745
  marker = message[:empty_answer]
729
746
  return message unless marker.is_a?(Hash)
730
747
 
731
748
  marker = marker.transform_keys(&:to_sym)
732
749
  marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
750
+ marker[:refusal] = marker[:refusal].transform_keys(&:to_sym) if marker[:refusal].is_a?(Hash)
733
751
  message.merge(empty_answer: marker)
734
752
  end
735
753
 
@@ -12,6 +12,7 @@ require_relative "tools/peers"
12
12
  require_relative "commands/registry"
13
13
  require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
+ require_relative "toolkit_command"
15
16
  require_relative "reasoning_history_command"
16
17
 
17
18
  module Samagotchi
@@ -32,6 +33,7 @@ module Samagotchi
32
33
  HELP_COMMAND = "/help"
33
34
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
34
35
  THINKING_COMMAND = ThinkingCommand::NAME
36
+ TOOLKIT_COMMAND = ToolkitCommand::NAME
35
37
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
36
38
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
37
39
  # shows this many per host; /models <text> lists every match.
@@ -48,7 +50,7 @@ module Samagotchi
48
50
 
49
51
  # The commands that ask before a change that restarts the prompt cache
50
52
  # (CacheConfirm): #run passes them its confirm flag.
51
- CONFIRMING = %i[thinking reasoning_history].freeze
53
+ CONFIRMING = %i[thinking reasoning_history toolkit].freeze
52
54
 
53
55
  # @!attribute status [Symbol] :ok, or :error when the line was refused
54
56
  # @!attribute output [String, nil] what to tell the user
@@ -103,6 +105,9 @@ module Samagotchi
103
105
  registry.register(REASONING_HISTORY_COMMAND, "show or set which of its reasoning this session's model gets back (drop|turn|all|default)",
104
106
  id: :reasoning_history, mid_turn: show_mid_turn(REASONING_HISTORY_COMMAND),
105
107
  match: ->(text) { text.match?(%r{\A/reasoning-history(?:\s+.*)?\z}) }) { |text| reasoning_history(text) }
108
+ registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
109
+ id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
110
+ match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
106
111
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
107
112
  registry.register("/stats", "show the session's stats", local: true)
108
113
  registry.register("/recap", "show the session's recap", local: true)
@@ -467,6 +472,16 @@ module Samagotchi
467
472
  reply(e.message, status: :error)
468
473
  end
469
474
 
475
+ # /toolkit (ToolkitCommand); :toolkit in changed when it set the
476
+ # session's toolkit.
477
+ def toolkit(text, confirm: true)
478
+ output, changed, ask = ToolkitCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
479
+ .run(text.delete_prefix(TOOLKIT_COMMAND), confirm: confirm)
480
+ reply(output, changed: changed ? [:toolkit] : [], confirm: ask)
481
+ rescue ArgumentError => e
482
+ reply(e.message, status: :error)
483
+ end
484
+
470
485
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
471
486
  # saved with its model.
472
487
  def save_setup(session)
@@ -140,7 +140,9 @@ module Samagotchi
140
140
  # @param title [String, nil] what the lists show before the first turn
141
141
  # (the prompt's preview by default, else the seed's first user message)
142
142
  # @param setup [SessionSetup] the session's own settings (chi
143
- # --llm-context; a fork and a continue copy their session's)
143
+ # --llm-context; a fork and a continue copy their session's); without
144
+ # a toolkit the session is stored on config's toolkit.default, a
145
+ # delegate child always on toolkit.child (Toolkit.configured)
144
146
  # @param continues [String, nil] the session this one continues (a
145
147
  # session field; SessionContinue)
146
148
  # @param note [String, nil] a context note the session starts with,
@@ -177,6 +179,7 @@ module Samagotchi
177
179
  setup: setup,
178
180
  continues: continues
179
181
  )
182
+ session.toolkit = Toolkit.configured(delegate: delegate) if delegate || session.toolkit.nil?
180
183
  session.model_warning = model_warning
181
184
  # With no prompt there is no first turn to run (an attaching UI sends
182
185
  # the prompts), so the session starts idle.
@@ -883,7 +883,7 @@ module Samagotchi
883
883
  payload = event[:payload]
884
884
  payload = {} unless payload.is_a?(Hash)
885
885
 
886
- @mutex.synchronize { note_first_chunk(payload, event) }
886
+ @mutex.synchronize { note_first_chunk(event) }
887
887
  usage = TokenUsage.from_payload(payload)
888
888
  if usage
889
889
  @mutex.synchronize { note_server_usage(usage) if @turn }
@@ -915,21 +915,16 @@ module Samagotchi
915
915
  turn.gen_had_server = true
916
916
  end
917
917
 
918
- # The first chunk with content, reasoning or a tool call starts the
919
- # generation's decode clock (an estimated speed runs from it). Caller
920
- # holds the mutex.
921
- def note_first_chunk(payload, event)
918
+ # The first chunk with content, reasoning or a tool call (the loop marks
919
+ # its event tool_call: true, whatever the wire) starts the generation's
920
+ # decode clock (an estimated speed runs from it). Caller holds the mutex.
921
+ def note_first_chunk(event)
922
922
  return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
923
- return unless !event[:content].to_s.empty? || tool_call_delta?(payload)
923
+ return unless !event[:content].to_s.empty? || event[:tool_call]
924
924
 
925
925
  @turn.gen_first_chunk_at = monotonic_time
926
926
  end
927
927
 
928
- def tool_call_delta?(payload)
929
- choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
930
- choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
931
- end
932
-
933
928
  def event_content_chars(payload, event)
934
929
  len = payload_content(payload, event).length
935
930
  len.positive? ? len : nil
@@ -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? = llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.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
@@ -82,6 +82,10 @@ module Samagotchi
82
82
  @waiting.epoch
83
83
  end
84
84
 
85
+ # The running generation's phase (GenerationPhase::Snapshot), nil with
86
+ # none running.
87
+ def generation_snapshot = @phase.snapshot
88
+
85
89
  # The turn's stream: the generation's phase follows it, and a thinking
86
90
  # chunk rechecks a waiting message. +progress+: a chunk's lanes
87
91
  # (.lanes), read from it when not given.
@@ -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: [], state_dir: nil)
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: [], err: $stderr)
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 given with --attach or
163
- # --resume are ignored, with a line saying so (printed before the live
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"
@@ -260,13 +260,17 @@ module Samagotchi
260
260
  take_session_state(event[:session_state_snapshot] || {})
261
261
  return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
262
262
  when :turn_enqueued then show_enqueued(event)
263
- when :turn_started then start_turn(event)
263
+ when :turn_started
264
+ @status.take_event(event)
265
+ start_turn(event)
264
266
  when :turn_completed
267
+ @status.take_event(event)
265
268
  complete_turn(event)
266
269
  refresh_children
267
270
  own_turn_ended(event)
268
271
  # The renderer says how it ended (the REPL's words too).
269
272
  when :turn_canceled, :turn_failed
273
+ @status.take_event(event)
270
274
  @renderer.call(event)
271
275
  end_turn
272
276
  refresh_children
@@ -1239,7 +1243,7 @@ module Samagotchi
1239
1243
  # A turn that ended with no answer: its notice, as live.
1240
1244
  empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
1241
1245
  if empty
1242
- @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
1246
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
1243
1247
  elsif failed
1244
1248
  # A failed turn whose steps stayed: the line the live view left,
1245
1249
  # and no step text as if it were the answer (the web keeps a
@@ -1379,6 +1383,8 @@ module Samagotchi
1379
1383
  def render_current_turn(turn)
1380
1384
  @running = !turn.nil?
1381
1385
  @joined_mid_turn = @running
1386
+ # A turn running at the join: the status row's steer hint.
1387
+ @status.update(running: @running)
1382
1388
  return unless turn
1383
1389
 
1384
1390
  continues = turn[:prompt].nil?
@@ -179,7 +179,8 @@ module Samagotchi
179
179
  end
180
180
  # No answer: chi's muted notice, never an empty answer line.
181
181
  if summary[:empty_answer]
182
- @view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
182
+ empty = summary[:empty_answer]
183
+ @view.print_line(@view.format_empty_answer_line(empty[:retries], empty[:refusal]))
183
184
  else
184
185
  @view.print_line(summary[:output])
185
186
  end
@@ -135,9 +135,10 @@ module Samagotchi
135
135
  end
136
136
 
137
137
  # "no answer: the model returned nothing (after 1 retry)", dim like the
138
- # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
139
- def format_empty_answer_line(retries)
140
- paint(TurnNote.empty_answer_line(retries), 90)
138
+ # retry row: a turn that ended with no answer, or "no answer: refused
139
+ # by the provider (cyber)" for a +refusal+ (TurnNote.empty_answer_line).
140
+ def format_empty_answer_line(retries, refusal = nil)
141
+ paint(TurnNote.empty_answer_line(retries, refusal: refusal), 90)
141
142
  end
142
143
 
143
144
  # "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
@@ -28,7 +28,15 @@ module Samagotchi
28
28
  # preloaded its --memory list (shown until a turn
29
29
  # records them as used)
30
30
  # muted its --mute list
31
- FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted].freeze
31
+ # running a turn runs: the row trails STEER_HINT
32
+ FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted running].freeze
33
+
34
+ # The tail while a turn runs: where a typed line goes (the next step)
35
+ # and the two other choices. Dropped whole first when the row is cut.
36
+ STEER_HINT = " · typing goes in at the next step (/cut, /queue)"
37
+
38
+ # The turn events that end a turn (the hint goes).
39
+ TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
32
40
 
33
41
  # @param surface [Surface] with #columns
34
42
  def initialize(surface)
@@ -77,13 +85,16 @@ module Samagotchi
77
85
 
78
86
  # Take what a turn event says about the row (the Engine's events in
79
87
  # the REPL, the Bridge's in attached mode): ctx, the memories used,
80
- # the model the server served. Other events change nothing.
88
+ # the model the server served, whether a turn runs (the steer
89
+ # hint). Other events change nothing.
81
90
  def take_event(event)
82
91
  case event[:type]
83
92
  when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
84
93
  when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
85
94
  when :generation_completed
86
95
  update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
96
+ when :turn_started then update(running: true)
97
+ when *TURN_END_EVENTS then update(running: false)
87
98
  end
88
99
  end
89
100
 
@@ -119,11 +130,21 @@ module Samagotchi
119
130
  status_context_text(estimate: @values[:context]),
120
131
  status_memory_text(Array(@values[:used_memories]) | Array(@values[:preloaded]), MEMORY_LIMIT),
121
132
  status_memory_text(@values[:muted], MEMORY_LIMIT, label: "muted")].reject(&:empty?)
122
- status_rows(segments, width)
133
+ status_rows(with_steer_hint(segments, width), width)
123
134
  end
124
135
 
125
136
  private
126
137
 
138
+ # +segments+ with STEER_HINT after the last while a turn runs, when
139
+ # the whole row fits +width+ with it: on a narrow terminal the hint
140
+ # goes first, whole, before anything else is cut.
141
+ def with_steer_hint(segments, width)
142
+ return segments unless @values[:running] && segments.any?
143
+ return segments if "status> #{segments.join(" | ")}#{STEER_HINT}".length > width
144
+
145
+ segments[0..-2] + ["#{segments.last}#{STEER_HINT}"]
146
+ end
147
+
127
148
  # `⑂ 2 running · 1 waiting`, the counts there are; "" with neither (no
128
149
  # children, or all ended). Not "not reported": a REPL parent is never
129
150
  # given its children's replies (no wakes there), and a worker moves
@@ -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