samagotchi 0.98.0 → 0.99.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/configuration.md +8 -0
  5. data/docs/guardrails.md +8 -0
  6. data/docs/hooks.md +5 -0
  7. data/docs/internals/anthropic.md +79 -8
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/internals/llm-context-forget.md +2 -1
  10. data/docs/internals/prompt-caching.md +9 -0
  11. data/docs/plugins.md +16 -1
  12. data/docs/sessions.md +10 -0
  13. data/docs/testing.md +27 -0
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +65 -8
  16. data/lib/samagotchi/kernel_loop.rb +15 -2
  17. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  18. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  19. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  20. data/lib/samagotchi/llm/dialect.rb +8 -5
  21. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  22. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  23. data/lib/samagotchi/llm_context_stale.rb +2 -2
  24. data/lib/samagotchi/message_parts.rb +2 -1
  25. data/lib/samagotchi/plugin/api.rb +1 -1
  26. data/lib/samagotchi/plugin/context.rb +21 -2
  27. data/lib/samagotchi/prompt.rb +3 -0
  28. data/lib/samagotchi/testing/bundle.rb +2 -0
  29. data/lib/samagotchi/testing/host.rb +1 -0
  30. data/lib/samagotchi/testing/recorder.rb +13 -2
  31. data/lib/samagotchi/tool_loads.rb +62 -0
  32. data/lib/samagotchi/tool_response.rb +5 -1
  33. data/lib/samagotchi/tool_runner.rb +29 -7
  34. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  35. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  36. data/lib/samagotchi/tools/registry.rb +7 -2
  37. data/lib/samagotchi/tools/tool_index.rb +88 -6
  38. data/lib/samagotchi/version.rb +1 -1
  39. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d683d11a3567996b7eb36801581dfa374309207bda22d8bc7d7e4e7f94d1cf2f
4
- data.tar.gz: 850af6daff806f5c56e4c5b9cd2294452360c891fca2690367111ff960c721fd
3
+ metadata.gz: '0185e889922200db9f6882d899b3ff17c74d296aa8c7823a7040e45e308edc47'
4
+ data.tar.gz: addddd537eac6fc703d0e217c7a968446ca44bb53f8ee501e61de86b1163085c
5
5
  SHA512:
6
- metadata.gz: c2272a6357f9fe281f36078facc23678c044b1942213bd4e881881c67fbd1ad2332aaf7f55d82cbe354ab558661b6e2fa4883044d7e07897508d6518f51b3a36
7
- data.tar.gz: d4e99b1672e12be69e3c36b1b5e29b9a6b3ed1e68d17a7f308e8a33d6e7a41d5790c5b2ea552c796d335e0fba94ff540d3788389b91b252d81b141ba5e38b869
6
+ metadata.gz: 62a2aaff51d6bbc45cfcc59e3d199444448cd838c229fbe4bda717658b6ba83fc7f273326ea0d8e8cb3e34839bd3c78ba472a5792589dbfe87a5dd1a8df2c5a7
7
+ data.tar.gz: 8b68d54a05c89461dba2428496111897cf85ca1cc70ac70f2b1621edcce7d70908243e181644b7086557d347fc80c51305a7b3df446c9299a09c2ec8e1cd113b
data/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.99.0] - 2026-10-22
12
+
13
+ ### Added
14
+
15
+ - Sessions on Anthropic's Messages API (`api: anthropic`) can call a tool their toolkit leaves out by its own name:
16
+ the reachable built-ins (`list_sessions` on `lean`) and the MCP servers' tools (`mcp_fs_read`) go in each request
17
+ deferred (`defer_loading`), and a direct call runs under the same guardrails, approvals and rows as through
18
+ `call_tool`. A `find_tools` hit loads its matches by reference, so the model calls them by name and the request
19
+ after a load reads its whole prompt from the cache. Other hosts are unchanged. See
20
+ [docs/configuration.md](docs/configuration.md#toolkits).
21
+ - Plugins can load tools for the model with `ctx.load_tools(names)` (built-ins, MCP tools): on an Anthropic host the
22
+ model then calls them by name; elsewhere nothing is sent. A hint, not access: the guardrails stay. See
23
+ [docs/plugins.md](docs/plugins.md).
24
+
25
+ ### Changed
26
+
27
+ - An Anthropic session whose toolkit leaves tools out, or that has MCP tools, may read its prompt again once on its
28
+ first turn after the upgrade (its request now carries the deferred tools).
29
+
11
30
  ## [0.98.0] - 2026-10-21
12
31
 
13
32
  ### Added
@@ -3755,7 +3774,8 @@ and long-lived sessions.
3755
3774
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3756
3775
  hotkey panel that send selected text or the clipboard to your sessions.
3757
3776
 
3758
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.98.0...HEAD
3777
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.99.0...HEAD
3778
+ [0.99.0]: https://github.com/dm1try/samagotchi/compare/v0.98.0...v0.99.0
3759
3779
  [0.98.0]: https://github.com/dm1try/samagotchi/compare/v0.97.0...v0.98.0
3760
3780
  [0.97.0]: https://github.com/dm1try/samagotchi/compare/v0.96.0...v0.97.0
3761
3781
  [0.96.0]: https://github.com/dm1try/samagotchi/compare/v0.95.0...v0.96.0
data/docs/architecture.md CHANGED
@@ -612,7 +612,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
612
612
  | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows); `session_metrics/cost.rb` (`SessionMetrics::Cost`): the spend `ctx.cost` reads. `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
613
613
  | Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
614
614
  | Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line, a kit's prompt text after the appended files; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
615
- | Tool index | `tools/tool_index.rb` (`Tools::ToolIndex`) | `find_tools` and `call_tool`: the tools a session knows but doesn't declare one by one (an mcp bundle's servers through `chi.tool_source`, the built-ins its toolkit leaves out), searched and called by name ([plugins.md](plugins.md)). |
615
+ | Tool index | `tools/tool_index.rb` (`Tools::ToolIndex`), `tools/deferred_tools.rb` | `find_tools` and `call_tool`: the tools a session knows but doesn't declare one by one (an mcp bundle's servers through `chi.tool_source`, the built-ins its toolkit leaves out), searched and called by name ([plugins.md](plugins.md)). On `api: anthropic` they also go out deferred, a hit loads its matches and the model calls them by name ([internals/anthropic.md](internals/anthropic.md)). |
616
616
  | Tool output cut | `tool_output_cut.rb` (`ToolOutputCut`), `tool_output_files.rb` (`ToolOutputFiles`) | A tool's output over `max_tool_output_chars`, cut by the tool's policy; an execute's whole output saved in the session's `tool_output/` folder ([internals/tool-guardrails.md](internals/tool-guardrails.md)). |
617
617
  | Steer delivery | `steer.rb` (`Steer`), `delivery.rb` (`Delivery`), `steer_cut.rb` (`SteerCut`), `generation_phase.rb`, `waiting_steer.rb`, `steer_cut_after.rb` (`SteerCutAfter`), `cut_policy.rb` (`CutPolicy`) | A message for a running turn: at the next boundary, cutting a thinking-only generation, or queued for the turn's end ([internals/engine.md](internals/engine.md)). |
618
618
  | Prompt files | `prompt_files.rb` (`PromptFiles`) | The AGENTS.md chain (root → cwd) and the appended files the system prompt carries. |
@@ -1408,6 +1408,14 @@ toolkit:
1408
1408
  starts no children). To keep a built-in away from the model, name it under `remove:`. `find_tools` and `call_tool`
1409
1409
  are declared when a toolkit has a reachable built-in (or an MCP server is configured); `full` with no MCP server
1410
1410
  declares neither, and removing them there (`--no-tool call_tool`) leaves nothing out: no prompt line, no chip.
1411
+ - **On `api: anthropic`, reachable tools are callable by name.** Every tool `call_tool` reaches (the reachable
1412
+ built-ins, the MCP servers' tools as `mcp_<server>_<tool>`) also goes in the request's tools deferred
1413
+ (`defer_loading`: Messages keeps them out of the model's view until something loads them, at a flat ~100 tokens).
1414
+ A direct call of one (`list_sessions`, `mcp_fs_read`) runs, under the same guardrails, approvals and rows as
1415
+ through `call_tool` (an MCP tool's call is `call_tool`'s). A `find_tools` hit **loads** its matches (up to 5):
1416
+ the model then sees their definitions and calls them by name; only the new result is read uncached. Not on
1417
+ Fireworks' dialect, nor on `api: openai` or a native host, where such a call gets the `call_tool` route and a hit
1418
+ reads as text.
1411
1419
  - **The prompt line.** One line after the base prompt names them: `Not declared in this session (toolkit lean): ….
1412
1420
  Search them with find_tools; call one with call_tool.`, then `Removed (toolkit child): memory_write, …. Only the
1413
1421
  user can load one (/toolkit add <name>).` With `find_tools` removed, the first sentence ends `Call one with
data/docs/guardrails.md CHANGED
@@ -429,6 +429,14 @@ direct call, and an approval is keyed `delegate:…` either way. `tool:
429
429
  call_tool` rules no longer see such calls, only MCP calls and misses. A
430
430
  `chi/` name the toolkit removes is refused before the gate, with no card.
431
431
 
432
+ On a Messages host (`api: anthropic`) those tools are also callable by their
433
+ own names (they go out deferred): a direct `delegate` call is gated as any
434
+ declared tool's, keyed `delegate:…` (the same approval as through
435
+ `call_tool`); a direct call of an MCP tool's `mcp_<server>_<tool>` name is
436
+ gated as the `call_tool` call it stands for: `mcp_*` rules match it, and its
437
+ approval is `call_tool>mcp_x:…`, the same as the `call_tool` route's. A tool
438
+ the toolkit removes stays refused before the gate.
439
+
432
440
  ```yaml
433
441
  - id: mcp-ask
434
442
  # every MCP call (call_tool, acting as mcp_<server>_<tool>); not a find_tools search.
data/docs/hooks.md CHANGED
@@ -610,6 +610,11 @@ A built-in reached through `call_tool` (`chi/delegate`, a tool the session's too
610
610
  declare) fires as that built-in, its call built as a direct call's, with `call[:via] == "call_tool"`.
611
611
  A hook that drops `via:` or rewrites the call to a tool the toolkit removes gets the call refused
612
612
  by its name.
613
+ On a Messages host (`api: anthropic`), the model may call those tools by their own names (they
614
+ go out deferred, [configuration.md](configuration.md#toolkits)): a direct `delegate` fires as a
615
+ declared tool's call, with no `via:`; a direct `mcp_fs_read` fires as the `call_tool` call it
616
+ stands for (`tool: "call_tool"`, `args: {"tool" => "fs/read", "args" => {…}}`), as if the model
617
+ had called `call_tool`.
613
618
 
614
619
  ## Bundle Hooks (unified workflow bundle)
615
620
 
@@ -13,7 +13,8 @@ ChatLoop::Run#generate
13
13
  → AnthropicMessages#chat
14
14
  → #request_body # options as Messages takes them, breakpoints on or off
15
15
  → AnthropicWire.translate # system blocks + messages, cache_control placed
16
- → AnthropicWire.tools # function schemas → {name, description, input_schema}
16
+ → AnthropicWire.tools # function schemas → {name, description, input_schema},
17
+ # a deferred one with defer_loading: true
17
18
  → POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
18
19
  → AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
19
20
  # finish_reason, provider_content)
@@ -35,6 +36,7 @@ Paths are under `lib/samagotchi/llm/`.
35
36
  | `anthropic_wire.rb` | `AnthropicWire`: ChatLoop's wire messages as Messages' `system` and `messages`, the two breakpoints, tool ids and names |
36
37
  | `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
37
38
  | `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
39
+ | `../tools/deferred_tools.rb` | `Tools::DeferredTools.list`: the tools a request declares deferred (Deferred tools) |
38
40
  | `prompt_cache.rb` | `PromptCache.control`: the `cache_control` a breakpoint carries (`cache.ttl`) |
39
41
  | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`), `#settle_dropped_thinking!` |
40
42
 
@@ -202,12 +204,76 @@ The log line's `cache=` says `on`, `1h` or nothing (`#cache_summary`). prompt-ca
202
204
 
203
205
  `LLM::Dialect` is a host's `dialect:` (`Dialect.parse` warns once on an unknown name or on a host whose api isn't
204
206
  `anthropic`): `auth` (the default key header), `thinking_display` (the `display` sent with adaptive or budget
205
- thinking, nil for none), `cache_breakpoints` and `session_header`.
206
-
207
- | Dialect | auth | thinking display | breakpoints | session header |
208
- |---|---|---|---|---|
209
- | `STANDARD` (no `dialect:`) | `x-api-key` | `summarized` | yes | none |
210
- | `FIREWORKS` | Bearer | none | no (an automatic prefix cache) | `x-session-affinity` |
207
+ thinking, nil for none), `cache_breakpoints`, `session_header` and `tool_loading` (deferred tools, below).
208
+
209
+ | Dialect | auth | thinking display | breakpoints | session header | tool loading |
210
+ |---|---|---|---|---|---|
211
+ | `STANDARD` (no `dialect:`) | `x-api-key` | `summarized` | yes | none | yes |
212
+ | `FIREWORKS` | Bearer | none | no (an automatic prefix cache) | `x-session-affinity` | no |
213
+
214
+ ## Deferred tools
215
+
216
+ A session declares its toolkit's tools; the ones it reaches but doesn't declare (the built-ins its toolkit leaves out
217
+ without removing them, an MCP server's tools in the tool index) are found with `find_tools` and called with
218
+ `call_tool` on every host. On a Messages host they also go in `tools` with `defer_loading: true`, after the declared
219
+ ones: the model sees their names only when something loads them, and the API accepts a call of one by its own name.
220
+ `AnthropicMessages#loads_tools?(model)` says whether a request does this: the dialect's `tool_loading`, unless the
221
+ host refused it for that model.
222
+
223
+ `ChatLoop#tool_definitions(conversation, model:)` appends them (`Tools::DeferredTools.list`) only on such an
224
+ adapter, and only with at least one tool declared (Messages wants one). The list, per request:
225
+
226
+ 1. the built-ins the toolkit reaches (`Registry#reachable_names`), in registry order;
227
+ 2. the tool index's source tools while `call_tool` is declared (`ToolIndex#deferrable`, via
228
+ `Registry::Index#deferrable`): callable, the only tool with its acts_as, a name Messages takes, no registered
229
+ tool's name; by acts_as (`mcp_<server>_<tool>`), sorted by name;
230
+ 3. every other name the conversation loaded (a find result's `loaded:`, a tool change's `add:`) that isn't declared,
231
+ as a stub ("Not available right now; find_tools finds it when it is.", an empty object schema): its server is
232
+ gone or still starting, and the reference in the history must name a tool in `tools`.
233
+
234
+ A deferred tool never carries `cache_control`, and the list sits after the declared tools, so the cached prefix holds
235
+ (prompt-caching.md). A session with nothing to defer (full, no MCP server) sends the bytes it always did.
236
+
237
+ ### Loading by reference
238
+
239
+ A `find_tools` keyword hit loads its callable matches (`ToolIndex::FoundText#loaded`, at most `FIND_RESULTS`: a
240
+ built-in by its name, a source tool by its acts_as when it is deferrable; an empty query, a miss or the names listing
241
+ load nothing). `KernelLoop#dispatch` passes them on (`dispatched[:loaded]`), `ToolRun#loaded` carries them and
242
+ `ToolResponse.single` saves them on the result as `loaded:` (every chat host; `ToolResponse.joined` doesn't).
243
+ `ChatLoop#wire_messages` copies them onto the tool message only for an adapter that loads tools.
244
+ `AnthropicWire.translate(wire, cache:, deferred:)` takes the names this request declares deferred and renders a
245
+ result's `loaded:` names among them as a tool_result holding only
246
+ `{"type": "tool_reference", "tool_name": …}` blocks (Messages takes no other content beside a reference), followed by
247
+ a sibling text block: `Loaded: a, b. Call them directly by these names.`, then the result's text without its
248
+ `Call one with call_tool(…)` line (`ToolIndex::CALL_HINT`; the saved result keeps it). With that line and a trailing
249
+ "(call_tool works too)", Sonnet and Haiku 5.5 went through `call_tool` 2/2 live; without, 5/5 called the tool by
250
+ name. A result whose names aren't deferred here (declared since, or no tool loading) goes as plain text. The sibling
251
+ text is never marked; when breakpoint #2 falls on such a tool_result, it goes on its last reference.
252
+
253
+ ### Tool changes (`ctx.load_tools`)
254
+
255
+ A plugin's `ctx.load_tools(names)` stages wire names (`Engine#load_tools`: `ToolIndex#deferred_name`, idempotent
256
+ against the staged names and the history's earlier changes, `ToolLoads`). `Engine#turn_messages` appends them after
257
+ the turn's user message (on a turn with none, after its trailing results or notes; a conversation ending with a reply
258
+ keeps them staged) as one `ToolLoads.entry` per bundle (`kind: "tool_change"`), only when the turn's host
259
+ `tool_changes?` (`AnthropicMessages#tool_changes?`: `loads_tools?`, unless the host refused changes for the model);
260
+ otherwise the staged names are dropped and logged (`tool_loads_not_sent`). `ChatLoop#wire_messages` sends the entry
261
+ only to such an adapter, `Prompt.format*` skips it. `AnthropicWire.translate` turns it into a `role: "system"` message
262
+ of its own, `{"type": "tool_addition", "tool": {"type": "tool_reference", "name": …}}` per name deferred in this
263
+ request (none: no message; note `name` here, `tool_name` in a result's reference), held until the run of user-role
264
+ messages it sits in ends: before the next assistant message, or last. `AnthropicMessages` adds
265
+ `INLINE_TOOLS_BETA` (`inline-tools-2026-09-15`) to `anthropic-beta` when the body has such a message, comma-joined with
266
+ the binding beta on a drop_block step. A 400 naming `tool_addition`, the beta or a system role (`TOOL_CHANGES_RE`)
267
+ sets `:tool_changes` for the model: the retry and every later request drop the system messages and the beta.
268
+
269
+ A direct call of a deferred tool runs whether something loaded it or not (the API doesn't check): the Engine sets
270
+ `TurnSettings#loads_tools` from the turn's final model (after the before_turn hooks), and then `ToolRunner#run` and
271
+ `KernelLoop#dispatch` take a reachable built-in by its own name (`Registry#indexed`) through the normal gate, and wrap
272
+ an MCP tool's acts_as into the `call_tool` call it stands for (`ToolIndex#wrap`): `call_tool`'s row, rules and
273
+ approvals. A reload rebuilds that wrapped call from the saved one (`BuiltinCalls.wrap_saved`). Each such call is
274
+ logged (`tool_direct_deferred`, with `loaded:` whether the conversation had loaded it). On `api: openai` and the
275
+ native loop nothing is deferred, and a direct call of a reachable built-in is refused with the `call_tool` route as
276
+ before.
211
277
 
212
278
  ## 400 fallbacks
213
279
 
@@ -225,9 +291,14 @@ client's life only):
225
291
  | `cache_control` | no breakpoints | per host (`cache_control_refused`) |
226
292
  | `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
227
293
  | `block_binding` or the binding beta, on a drop_block retry (`thinking_binding_unsupported?`) | the strip instead (Preserved thinking) | per host (`binding_controls_refused`) |
294
+ | `tools.N.input_schema` (`TOOL_SCHEMA_RE`) where tool N is deferred | that tool leaves the deferred list (`call_tool` still reaches it); one fix per tool (the fix value is `[:tool_schema, name]`), at most `MAX_SCHEMA_FIXES` (5) per request; the names kept by `RefusalMemo#add` | per model (`tool_schema_refused`) |
295
+ | `tool_addition`, the inline-tools beta or a system role (`TOOL_CHANGES_RE`) on a request with a tool change | no system messages and no beta from then on (`#tool_changes?` false); the tools stay deferred and callable | per model (`tool_changes_refused`) |
296
+ | `defer_loading` or `tool_reference` (`TOOL_LOADING_RE`) on a request that carries either | no deferred tools from then on (`#loads_tools?` false, loads go as text); a direct call of a reachable tool is refused again from the next turn (`TurnSettings#loads_tools`) | per model (`tool_loading_refused`) |
228
297
 
229
298
  The display check comes before the adaptive one: its path (`thinking.adaptive.display`) names adaptive too. A context
230
299
  overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these (`#refusal_fix` keeps
231
300
  excluding them, so `ADAPTIVE_REFUSED_RE` never reads a binding 400's "adaptive" as a form refusal): a signature 400
232
301
  goes to the binding recovery above, the rest on as they are, to the loop's own handling (ChatLoop's sampling and
233
- thinking-fields fallbacks) or to the turn's failure.
302
+ thinking-fields fallbacks) or to the turn's failure. A tool-loading fix's retry that breaks a replayed thinking
303
+ block's binding gets a signature 400 like any other request, and the binding recovery takes it. These refusals come on
304
+ the first request that carries the feature, normally a turn's first.
@@ -34,7 +34,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
34
34
  │ # resumed: true (only on a step-limit Continue: resumed: given),
35
35
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
36
36
  │ # refresh_profile! (may ask the server's /props once)
37
- │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate),
37
+ │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name, loads_tools … are set in
38
+ │ # generate, after the hooks, from the final model),
38
39
  │ # from one EffectiveModel; ThinkingNotices#turn_started keeps the level and says one the host can't do
39
40
  │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
40
41
  │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, ToolIndex#refresh! for find_tools'
@@ -42,6 +43,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
42
43
  │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
43
44
  │ # (a frozen copy of the history, the prompt, and resumed)
44
45
  │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
46
+ │ # + the plugins' staged tool loads (ctx.load_tools: a kind "tool_change" system entry per bundle,
47
+ │ # only when the turn's host takes tool changes; #append_tool_loads)
45
48
  │ # Returns false when a Stop came during the probes or the init wait, else true
46
49
  │
47
50
  ├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
@@ -218,7 +221,10 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
218
221
 
219
222
  The differences from the native loop:
220
223
 
221
- - The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
224
+ - The tools go in each request's `tools` field (`tool_definitions(conversation, model:)`), not in the system prompt.
225
+ On an adapter that loads tools (`#loads_tools?`: `AnthropicMessages`), the tools the session reaches but doesn't
226
+ declare follow, `defer_loading: true` (`Tools::DeferredTools`; anthropic.md, "Deferred tools"); the Run keeps the
227
+ last request's deferred names and logs a call of one (`tool_direct_deferred`).
222
228
  - The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
223
229
  `AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
224
230
  - The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
@@ -266,11 +272,15 @@ One iteration with tool calls:
266
272
  → :tool_dispatch_started {iteration, call_count}
267
273
  → for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
268
274
  on_stream_event:, max_tool_output_chars:)
275
+ → under TurnSettings#loads_tools, a direct call of a source tool's acts_as (mcp_fs_read) is wrapped
276
+ into the call_tool call it stands for (Registry::Index#wrap, ToolIndex#wrap)
269
277
  → first, the call's entry unwraps it (Registry::Entry#unwrap: a call_tool of chi/<built-in> becomes
270
278
  that built-in's call with via: "call_tool", ToolIndex#unwrap); the rest sees the real call
271
279
  → a tool the session's toolkit leaves out (Registry#left_out; with via:, one Registry#indexed doesn't
272
280
  reach) skips the gate: no hooks, no card; KernelLoop#dispatch answers it (the call_tool route for a
273
- reachable tool, the not-loaded error for a removed one)
281
+ reachable tool, the not-loaded error for a removed one). Under TurnSettings#loads_tools a reachable
282
+ built-in called by its own name is no such call: it runs through the gate, and KernelLoop#dispatch
283
+ takes it (#loaded_entry)
274
284
  → Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
275
285
  → :tool_call_started
276
286
  → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
@@ -38,7 +38,8 @@ lets the model forget its own tool outputs. The pieces:
38
38
  - **Stubs** (`LLMContextView`): `(forgotten) <note>` on the first output of a call's forgets in a row, `(forgotten
39
39
  with tN: see its note)` on the rest (a run of the same note, author and stamp, no other output between them),
40
40
  ` [restore: tN]` on a non-read with a stored id, then each kept range under `lines A-B kept:`. The entry keeps its
41
- role, tool_call_id and images bookkeeping as for stale.
41
+ role, tool_call_id and images bookkeeping as for stale, and a find_tools result's `loaded:`, so a Messages host
42
+ still gets the load's references (anthropic.md, "Deferred tools"); only the sibling text is the stub.
42
43
  - **Offers**: `ContextStatus` (internals/context-telemetry.md).
43
44
  - **Persistence**: the edits ride on the entries (plan D3), so `--resume`, rollbacks and forks keep the stubs and the
44
45
  restores; `/stats` counts each applied batch's re-prefill.
@@ -99,6 +99,15 @@ right-hand column happens.
99
99
  id: every chat request carries them, unless the host's dialect has none (`Dialect#cache_breakpoints`: `fireworks`,
100
100
  an automatic prefix cache) or the host refused `cache_control` with a 400 (no breakpoints for that worker's life).
101
101
  [anthropic.md](anthropic.md) has the details.
102
+ - On a Messages host the tools a session reaches but doesn't declare go in `tools` deferred (`defer_loading`), after
103
+ the declared ones, never marked. Their presence is a flat ~93 tokens of the cached prefix, whatever their number
104
+ or names: a request with none and one with some don't share a prefix (measured 2026-10-22: a full + MCP session
105
+ read nothing of the prefix main's chi had cached, then read its own), but the list may grow mid-session (an MCP
106
+ server's `list_changed`, a tool sorted in mid-list) without a re-read. A find_tools hit's load goes in its own
107
+ tool_result (`tool_reference` blocks) with the text beside it, rendered the same in every later request (a
108
+ loaded name stays deferred, a stub once its source is gone), so the request after a load reads the whole prefix;
109
+ the loaded definitions and the new result are the fresh part. A plugin's
110
+ `ctx.load_tools` only appends (a system `tool_addition` after the user message, never a change to `tools`).
102
111
  - Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
103
112
  llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
104
113
 
data/docs/plugins.md CHANGED
@@ -631,6 +631,17 @@ and after a reload. `tool: call_tool` rules see MCP calls and misses only. A
631
631
  `call_tool` itself is removed, is refused by its name before the guardrails
632
632
  (no hook, no card).
633
633
 
634
+ **On a Messages host the model calls them by name too.** With `api:
635
+ anthropic` (not Fireworks' dialect), every tool `call_tool` reaches goes in
636
+ the request's tools deferred (`defer_loading`: its name only, until
637
+ something loads it): a built-in by its name, a source's tool by its
638
+ `acts_as` (`mcp_fs_read`; one with a refusal, an acts_as two tools share, or
639
+ a name another tool has stays out). A direct call of one runs whether
640
+ anything loaded it or not: a built-in as itself (as if declared), a
641
+ source's tool as the `call_tool` call it stands for, so its rules,
642
+ approvals, hooks and row are `call_tool`'s. Elsewhere such a call is
643
+ refused with the `call_tool` route, as before.
644
+
634
645
  ```ruby
635
646
  @source = chi.tool_source(label: "mcp", prefix: "mcp_",
636
647
  call: ->(tool, args, ctx) { run(tool, args, ctx) }, # the result, as a tool block's
@@ -759,6 +770,7 @@ full label, `plugin.rb (bundle my-bundle)`.
759
770
  | `ctx.toolkit` | the name of the session's toolkit (`full` for a session from before toolkits); nil with no session |
760
771
  | `ctx.declared_tools` | the tool names the session's next turn declares (its toolkit resolved, with its own adds and removes): ask this, not the name, whether a tool is there |
761
772
  | `ctx.use_toolkit(name)` | put the session on toolkit `name` (yours from `chi.toolkit`, or chi's): saved, applied at the next turn's start and announced (`toolkit> toolkit: NAME from the next turn's start`, the web's tools chip follows). Only before the session's first model reply and outside a turn (the declared tools start every request, and a plugin doesn't ask first); otherwise it returns false and changes nothing: tell the user to run `/toolkit NAME`. An unknown name raises ArgumentError. Needs chi 0.66.0 |
773
+ | `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins (`"delegate"`, `"chi/delegate"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
762
774
  | `ctx.context` | attach outside text to this session and list it: see [Attached context](#attached-context-ctxcontext) |
763
775
 
764
776
  The Engine itself is never handed to a plugin.
@@ -1176,6 +1188,7 @@ bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe"
1176
1188
  bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
1177
1189
  bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
1178
1190
  bundle.set(session_settings: { "cap" => 1 }) # the session's --set values (Bundle.load takes session_settings: too)
1191
+ bundle.tool_loads.last.names # ctx.load_tools calls; set(load_tools_staged: [...]) limits what they stage
1179
1192
  bundle.shutdown
1180
1193
  ```
1181
1194
 
@@ -1276,7 +1289,9 @@ by one: the bundle publishes them into chi's tool index
1276
1289
  ([chi.tool_source](#chitool_sourcecall-acts_as-label-wait-prefix), a group
1277
1290
  per server), and the model has its two tools, `find_tools` and `call_tool` (~250 tokens in
1278
1291
  every request plus a line per server, whatever the servers have; `/mcp`
1279
- says how many). Stdio servers only, for now.
1292
+ says how many). On `api: anthropic` each tool also goes out deferred as
1293
+ `mcp_<server>_<tool>`, so a `find_tools` hit loads it and the model calls it
1294
+ by that name, as `call_tool` would run it. Stdio servers only, for now.
1280
1295
 
1281
1296
  ```yaml
1282
1297
  # config.yml
data/docs/sessions.md CHANGED
@@ -253,6 +253,16 @@ declares every tool (full). The file stores only the name: a kit's tools and pro
253
253
  `/toolkit` changes it, saved at once, from the next turn's start. See
254
254
  [Toolkits](configuration.md#toolkits).
255
255
 
256
+ A `find_tools` result on a chat host saves the tools its hit loads as `loaded: ["list_sessions", "mcp_fs_read"]` on
257
+ its `tool_response` entry (wire names: a built-in's, an MCP tool's `mcp_<server>_<tool>`), whatever the host: a
258
+ later turn on `api: anthropic` (after a `/model` switch, a resume) sends the load as references, any other host
259
+ ignores it. The names stay deferred for the session's life, across respawns, `--resume` and forks.
260
+
261
+ A plugin's `ctx.load_tools` leaves a system entry `{"role": "system", "kind": "tool_change", "content": "[tools loaded:
262
+ delegate (bundle)]", "tool_change": {"add": ["delegate"], "by": "bundle"}}` after the turn's user message, only on a
263
+ host that takes tool changes (`api: anthropic`). It is never the system head, counts as conversation, is hidden in
264
+ the web and TUI histories, and goes to no other wire; its names stay deferred for the session's life like a load's.
265
+
256
266
  ## Parent answers
257
267
 
258
268
  A session file's `parent_answers` (`hard`, `standard`, `soft`, or `null` for none) is the session's own
data/docs/testing.md CHANGED
@@ -219,3 +219,30 @@ each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json`
219
219
  no session text (ids, model names, note names and digests, dates, numbers), and the suite runs it only on small
220
220
  synthetic sessions. It is how a note is judged on and off (`chi --mute model_notes_<name>`) for the same kind of
221
221
  task: see [memory.md](memory.md) ("Model notes").
222
+
223
+ ## Dependency check
224
+
225
+ `bundle exec rake deps:check` says what our dependencies did since the last check; `rake "deps:check[json]"` gives
226
+ the same as JSON. It reads only and always exits 0: it informs (a tech-debt audit round runs it first, and its NEW
227
+ lines go into that round's follow-ups), it doesn't gate, and CI doesn't run it. Three sections:
228
+
229
+ - **Engines:** each `hosts:` entry of your config.yml on a loopback or private address (an IP, `localhost`, a
230
+ single-label name or `.local`/`.lan`/`.internal`/`.home.arpa`; a public API is never probed) gets `/props`
231
+ (`build_info`: llama.cpp) and `/status` (`identity` and `chat_template`: Splash), 2 s each. A host that doesn't
232
+ answer prints "down, skipped"; one that is neither is left out. Each line carries the newest upstream release
233
+ (GitHub, no auth) with its URL, for a model to read the notes: llama.cpp's newest `b<N>` tag and how many builds
234
+ behind the host is, Splash's newest release.
235
+ - **Ruby gems:** `bundle outdated --only-explicit` (direct dependencies, with the Gemfile's requirement).
236
+ - **npm:** `npm outdated` (direct) and `npm audit`'s advisories by severity.
237
+
238
+ A section that can't be read (offline, a GitHub rate limit) says why and the others go on. What a run saw goes to
239
+ `$XDG_STATE_HOME/samagotchi/deps-check.json` (`~/.local/state/...`; `DEPS_CHECK_STATE` overrides it), outside the
240
+ repo since the engines are this machine's; the next run prints `NEW` on each line that changed since (the first run:
241
+ all), NEW lines first in each section.
242
+
243
+ **The Splash version** isn't in Splash's `/status`, so you record it: read `splash --version` on the engine host,
244
+ then `rake "deps:splash_version[1.3.2]"` (every Splash host) or `rake "deps:splash_version[host:port,1.3.2]"`. It is
245
+ kept with the build id `/status` shows (`identity.cache.build_id`); when that changes, the check asks you to confirm
246
+ the version and record it again. This step goes once a Splash release carries its version in `/status`.
247
+
248
+ The code is `script/deps/deps_check.rb`; its spec stubs every request and command (`spec/deps_check_spec.rb`).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.98.0
3
+ version: 0.99.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -39,6 +39,7 @@ require_relative "llm/openai_chat"
39
39
  require_relative "llm/turn_settings"
40
40
  require_relative "session"
41
41
  require_relative "toolkit_binding"
42
+ require_relative "tool_loads"
42
43
  require_relative "answer_tail"
43
44
  require_relative "archive_store"
44
45
  require_relative "session_observer"
@@ -277,6 +278,9 @@ module Samagotchi
277
278
  # The tools plugins publish without declaring (chi.tool_source), and
278
279
  # the search and call tools that reach them (Tools::ToolIndex).
279
280
  @tool_index = Tools::ToolIndex.new
281
+ # The tools plugins load (ctx.load_tools), staged until a turn's
282
+ # messages are built (#append_tool_loads).
283
+ @tool_loads = ToolLoads.new
280
284
  # The context sources the peer bundles declare as a peer agent's
281
285
  # answers (chi.peer_source); the worker's ContextAbsorber asks it.
282
286
  @peer_sources = PeerSources.new
@@ -2115,12 +2119,13 @@ module Samagotchi
2115
2119
  })
2116
2120
  end
2117
2121
 
2118
- return if turn.continue
2119
-
2120
- user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
2121
- user_message[:images] = image_refs unless image_refs.empty?
2122
- turn.messages << user_message
2123
- session.last_prompt = turn.prompt
2122
+ unless turn.continue
2123
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
2124
+ user_message[:images] = image_refs unless image_refs.empty?
2125
+ turn.messages << user_message
2126
+ session.last_prompt = turn.prompt
2127
+ end
2128
+ append_tool_loads(turn)
2124
2129
  end
2125
2130
  private :turn_messages
2126
2131
 
@@ -2137,8 +2142,13 @@ module Samagotchi
2137
2142
  # through the kernel without its #run: tag those dumps with this
2138
2143
  # turn's model, not the last native one.
2139
2144
  explained = model.llm_context_explained(@model_settings.llm_context_session_args)
2145
+ model_backend = backend(model)
2146
+ # A host that loads tools runs a direct call of one the session
2147
+ # reaches but doesn't declare (ToolRunner, KernelLoop#dispatch).
2148
+ loads_tools = model_backend.respond_to?(:loads_tools?) && model_backend.loads_tools?(bare_for_backend)
2140
2149
  @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
2141
- llm_context: explained&.resolved, price: model.price)
2150
+ llm_context: explained&.resolved, price: model.price,
2151
+ loads_tools: loads_tools)
2142
2152
  log_llm_context(explained)
2143
2153
 
2144
2154
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -2146,7 +2156,7 @@ module Samagotchi
2146
2156
  # end keeps it, so a Continue resumes this run.
2147
2157
  run_start = turn.messages.length
2148
2158
  reasoning_since = resumed_run_start(turn, run_start)
2149
- result = backend(model).complete(
2159
+ result = model_backend.complete(
2150
2160
  messages: turn.messages,
2151
2161
  max_iterations: turn.limit,
2152
2162
  on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
@@ -2555,6 +2565,52 @@ module Samagotchi
2555
2565
 
2556
2566
  public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
2557
2567
 
2568
+ # A plugin's ctx.load_tools: stage the reachable tools +names+ names
2569
+ # (Tools::ToolIndex#deferred_name) that the session neither declares
2570
+ # nor loaded with an earlier tool change, for bundle +bundle+. A name
2571
+ # that names no tool at all is logged; a declared or removed one is
2572
+ # skipped quietly.
2573
+ # @return [Array<String>] the wire names staged
2574
+ def load_tools(names, bundle)
2575
+ added = ToolLoads.added(@session&.messages)
2576
+ wanted = names.filter_map do |name|
2577
+ wire = @tool_index.deferred_name(name)
2578
+ Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
2579
+ wire
2580
+ end
2581
+ @tool_loads.stage(wanted - added, by: bundle)
2582
+ end
2583
+ public :load_tools
2584
+
2585
+ # The staged tool loads, appended to +turn+'s messages (after its user
2586
+ # message, or the trailing results and notes of a turn with none) as
2587
+ # one ToolLoads entry per bundle, when the turn's host takes tool
2588
+ # changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
2589
+ # A conversation that ends with a reply keeps them staged (a system
2590
+ # change must follow a user turn).
2591
+ def append_tool_loads(turn)
2592
+ return unless @tool_loads.any?
2593
+ return if turn.messages.last&.dig(:role).to_s == "model"
2594
+
2595
+ model = effective_model
2596
+ host = backend(model)
2597
+ staged = @tool_loads.take
2598
+ added = ToolLoads.added(turn.messages)
2599
+ unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
2600
+ Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
2601
+ return
2602
+ end
2603
+
2604
+ staged.each do |bundle, names|
2605
+ names -= added
2606
+ next if names.empty?
2607
+
2608
+ turn.messages << ToolLoads.entry(names, by: bundle)
2609
+ hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
2610
+ end
2611
+ end
2612
+ private :append_tool_loads
2613
+
2558
2614
  # A plugin's ctx.use_toolkit (ToolkitBinding#use).
2559
2615
  # @return [Boolean] whether it was staged
2560
2616
  # @raise [ArgumentError] an unknown toolkit
@@ -2608,6 +2664,7 @@ module Samagotchi
2608
2664
  toolkit: -> { @toolkit_binding.name },
2609
2665
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2610
2666
  use_toolkit: ->(name) { use_toolkit(name) },
2667
+ load_tools: ->(names, bundle) { load_tools(names, bundle) },
2611
2668
  cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
2612
2669
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
2613
2670
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
@@ -1139,6 +1139,14 @@ module Samagotchi
1139
1139
  # through call_tool (+via+) of a tool the toolkit declares is told to
1140
1140
  # call it directly: the model's call_tool(tool: "chi/read") with read
1141
1141
  # declared, or a hook's rewrite (a direct call can't carry via:).
1142
+ # The built-in +name+ names when the turn's host loads tools and the
1143
+ # toolkit reaches it (Registry#indexed): a direct call runs it.
1144
+ def loaded_entry(name)
1145
+ return nil unless @turn_settings&.loads_tools && @tools.respond_to?(:indexed)
1146
+
1147
+ @tools.indexed(name, layers: llm_context_layers)
1148
+ end
1149
+
1142
1150
  def unknown_tool_error(name, layers, via: nil)
1143
1151
  names = @tools.names(layers: layers)
1144
1152
  toolkit = @tools.toolkit&.name
@@ -1160,11 +1168,13 @@ module Samagotchi
1160
1168
 
1161
1169
  def dispatch(call)
1162
1170
  # A built-in reached through call_tool runs only while the toolkit
1163
- # reaches it that way (Registry#indexed); any other call by its name.
1171
+ # reaches it that way (Registry#indexed); any other call by its name,
1172
+ # on a host that loads tools (TurnSettings#loads_tools) a reachable
1173
+ # one too.
1164
1174
  entry = if call[:via]
1165
1175
  @tools.respond_to?(:indexed) ? @tools.indexed(call[:name], layers: llm_context_layers) : nil
1166
1176
  else
1167
- @tools.offered(call[:name], layers: llm_context_layers)
1177
+ @tools.offered(call[:name], layers: llm_context_layers) || loaded_entry(call[:name])
1168
1178
  end
1169
1179
  unless entry
1170
1180
  result = unknown_tool_error(call[:name], llm_context_layers, via: call[:via])
@@ -1198,6 +1208,9 @@ module Samagotchi
1198
1208
  dispatched[:ref_path] = result.ref_path
1199
1209
  dispatched[:ref_stderr_path] = result.stderr_path if result.stderr_path
1200
1210
  end
1211
+ # The tools a find_tools hit loads (Tools::ToolIndex::FoundText),
1212
+ # saved with a chat result (ToolResponse.single).
1213
+ dispatched[:loaded] = result.loaded if result.respond_to?(:loaded) && !Array(result.loaded).empty?
1201
1214
  if result.respond_to?(:images) && !Array(result.images).empty?
1202
1215
  dispatched[:images] = Array(result.images)
1203
1216
  dispatched[:image_only] = true if result.respond_to?(:image_only?) && result.image_only?