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.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. 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
- "Not loaded in this session (toolkit #{registry.toolkit&.name}): #{names.join(", ")}. " \
343
- "Only the user can load one (/toolkit add <name>)."
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
- called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
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 "cache_confirm"
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): with no
8
- # argument, the level the next turn runs at and where it came from;
9
- # `/thinking off|low|medium|high` sets the session's own, `/thinking
10
- # default` unsets it (the session follows the process default, the
11
- # model, its host, then thinking.level). A change takes effect at the
12
- # next turn's start (the REPL and the worker run it between turns), and
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
- # @param engine [Engine] #thinking_explained(own:), #thinking_override(=),
28
- # #session, #thinking_cache_cost, #history?
29
- # @param save [#call] saves the session after a change
30
- def initialize(engine:, save:)
31
- @engine = engine
32
- @save = save
33
- end
34
-
35
- # @param args [String] what follows /thinking (--yes anywhere)
36
- # @param confirm [Boolean] ask before a costly change (CacheConfirm);
37
- # false applies it at once (chi send, a delegate, a start flag)
38
- # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
39
- # whether the level changed, and the confirm it asks for (nothing
40
- # changed then)
41
- # @raise [ArgumentError] a word that isn't a level (with USAGE)
42
- def run(args, confirm: true)
43
- word, yes = CacheConfirm.split(args)
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), view (ToolView: the full command)
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
- # A real tool the session's toolkit leaves out of this turn.
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
 
@@ -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): a tool it removed
29
- # comes out of +remove+ (back to its kit's), any other goes into +add+.
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
- remove.include?(tool) ? with(remove: remove - [tool]) : with(add: (add + [tool]).uniq)
33
+ with(remove: remove - [tool], add: (add + [tool]).uniq)
33
34
  end
34
35
 
35
- # This toolkit with +tool+ left out (/toolkit remove), the other way.
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
- add.include?(tool) ? with(add: add - [tool]) : with(remove: (remove + [tool]).uniq)
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+), and
70
- # whether that isn't what the Registry declares now (+pending+: it
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
- # 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
+ 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 = [declared.map(&:name), left_out.empty? ? nil : resolved.name]
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, fallback: resolved.fallback }
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
- Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write])
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), pending: filter != tools.toolkit)
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