samagotchi 0.66.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +27 -1
data/docs/internals/engine.md
CHANGED
|
@@ -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 is set in generate),
|
|
38
|
+
│ # from one EffectiveModel; ThinkingNotices#turn_started keeps the level and says one the host can't do
|
|
38
39
|
│ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
|
|
39
40
|
│ # then the session's toolkit (apply_toolkit!: Registry#toolkit, a prompt rebuild when it changed)
|
|
40
41
|
│ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
|
|
@@ -43,11 +44,12 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
43
44
|
│ # Returns false when a Stop came during the probes or the init wait, else true
|
|
44
45
|
│
|
|
45
46
|
├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
46
|
-
│ #
|
|
47
|
-
│ #
|
|
48
|
-
│ #
|
|
47
|
+
│ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
|
|
48
|
+
│ # @kernel.turn_settings = turn.settings with model_name, window_setting, llm_context (the
|
|
49
|
+
│ # session's, the model's/host's, else llm_context.strategy) and price
|
|
50
|
+
│ # (hosts.<name>.models.<id>.price), all read per turn
|
|
49
51
|
│ # turn.limit = max_iterations or IterationLimit.for
|
|
50
|
-
│ # backend.complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
|
|
52
|
+
│ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
|
|
51
53
|
│ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
|
|
52
54
|
│ # context_status, empty_steps, empty_retries)
|
|
53
55
|
│
|
|
@@ -72,7 +74,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
72
74
|
│
|
|
73
75
|
└── ensure: release_turn(probe_cancel_before)
|
|
74
76
|
# TurnState#finish! (turn flag off, controller, sink and steers cleared)
|
|
75
|
-
#
|
|
77
|
+
# SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
|
|
76
78
|
# probe cancel swapped back
|
|
77
79
|
# the context window cache dropped, so the next turn asks the server again
|
|
78
80
|
# steers left logged as dropped, activity recorded, turn-scoped hooks cleared
|
|
@@ -83,7 +85,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
83
85
|
```
|
|
84
86
|
StandardError in run_turn:
|
|
85
87
|
→ if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
|
|
86
|
-
→ else: end_turn(turn, "failed", save: true)
|
|
88
|
+
→ else: end_turn(turn, "failed", save: true) (a save that fails there is logged, turn_end_save_failed)
|
|
87
89
|
# Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
|
|
88
90
|
# failed_messages keeps the loop's partial conversation (tool iterations included),
|
|
89
91
|
# else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
|
|
@@ -118,6 +120,16 @@ A worker's TERM, HUP or INT (WorkerSignals, trapped by a spawned worker only):
|
|
|
118
120
|
loop left brings only that hard exit, and run_session_loop restores the default handlers
|
|
119
121
|
→ run_session_loop: exit 0, no discard; input queued behind the stopped turn wakes a new worker
|
|
120
122
|
|
|
123
|
+
The effective model doesn't resolve (a broken host config, HostRegistry#resolve raising):
|
|
124
|
+
→ the rescued readers give their fallback (Engine#model_setting) and log effective_model_unresolved;
|
|
125
|
+
a setting that fails on a resolved model gives its fallback and logs <setting>_unresolved
|
|
126
|
+
(sampling, thinking, reasoning_history, steer_cut_after, llm_context, window, price, served,
|
|
127
|
+
vision_adapter); served_probe_failed when asking the server for its served model fails;
|
|
128
|
+
alias_note_failed, thinking_notice_failed likewise
|
|
129
|
+
→ WARN once per process for an (event, model, error class), DEBUG after (EffectiveModel::Unresolved)
|
|
130
|
+
→ session_state_snapshot never raises (its model fields are nil); a turn, stats_snapshot,
|
|
131
|
+
chat_model? and system_prompt raise, as before
|
|
132
|
+
|
|
121
133
|
Stopped before the model was asked (prepare_turn returned false):
|
|
122
134
|
→ stopped_before_model(turn) → end_turn(turn, "canceled")
|
|
123
135
|
→ nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
|
|
@@ -129,13 +141,14 @@ Stopped before the model was asked (prepare_turn returned false):
|
|
|
129
141
|
The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
|
|
130
142
|
|
|
131
143
|
```
|
|
132
|
-
Engine#backend → backend_for(
|
|
144
|
+
Engine#backend → backend_for(effective_model) # EffectiveModel.of(@host_registry, @model_choice)
|
|
133
145
|
→ host entry chat? (an api in LLM::Adapters::BY_API: openai, anthropic): LLM::ChatLoop (one per Engine, built on first use)
|
|
134
146
|
→ else: LLM::NativeBackend wrapping the Engine's KernelLoop
|
|
135
147
|
```
|
|
136
148
|
|
|
137
149
|
Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
|
|
138
|
-
cancel_controller:, model_name:, max_tool_output_chars:, pending_input:)
|
|
150
|
+
cancel_controller:, model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)`; `reasoning_since:` is
|
|
151
|
+
where the run a step-limit Continue resumes started, the Engine's `resume_from`). `NativeBackend#complete` only calls
|
|
139
152
|
`KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
|
|
140
153
|
whose tools, hooks, gate and dispatch it uses.
|
|
141
154
|
|
|
@@ -172,17 +185,21 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
|
|
|
172
185
|
`turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
|
|
173
186
|
(`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; a Ctrl-C does not (see Error paths).
|
|
174
187
|
|
|
175
|
-
### ChatLoop (the
|
|
188
|
+
### ChatLoop (the chat APIs)
|
|
176
189
|
|
|
177
|
-
Runs hosts
|
|
178
|
-
|
|
190
|
+
Runs hosts whose api has a chat adapter (`LLM::Adapters::BY_API`): `api: openai` on `LLM::OpenAIChat` (remote
|
|
191
|
+
providers such as OpenRouter, Claude among others, and any OpenAI-compatible chat server) and `api: anthropic` on
|
|
192
|
+
`LLM::AnthropicMessages` (Anthropic's Messages API; [anthropic.md](anthropic.md)). The Engine sets the adapter per
|
|
193
|
+
turn for the effective model's host (`HostRegistry#adapter_for`). The model gets the tools as JSON schemas and returns
|
|
194
|
+
structured tool calls.
|
|
179
195
|
|
|
180
196
|
```
|
|
181
197
|
ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
|
|
182
|
-
model_name:, max_tool_output_chars:, pending_input:)
|
|
198
|
+
model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
|
|
183
199
|
│
|
|
184
200
|
│ conversation = Array(messages).map(&:dup) # a shallow copy of each message
|
|
185
|
-
│ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input
|
|
201
|
+
│ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input,
|
|
202
|
+
│ start: reasoning_since)
|
|
186
203
|
│ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
187
204
|
│
|
|
188
205
|
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
@@ -191,10 +208,13 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
|
|
|
191
208
|
The differences from the native loop:
|
|
192
209
|
|
|
193
210
|
- The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
|
|
194
|
-
- The conversation is converted to the OpenAI wire format (`wire_messages`)
|
|
211
|
+
- The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
|
|
212
|
+
`AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
|
|
195
213
|
- The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
|
|
196
|
-
as `stable_length:`), where the prompt's stable part ends.
|
|
197
|
-
|
|
214
|
+
as `stable_length:`), where the prompt's stable part ends. The adapter puts the first cache breakpoint there:
|
|
215
|
+
`OpenAIChat` for a Claude model on a remote host (`LLM::PromptCache.mark`), `AnthropicMessages` on every chat
|
|
216
|
+
request unless its dialect has no breakpoints or the host refused `cache_control` (`AnthropicWire.translate`,
|
|
217
|
+
`Dialect#cache_breakpoints`); see prompt-caching.md and anthropic.md.
|
|
198
218
|
- Each call's result is its own tool message, paired by `tool_call_id`.
|
|
199
219
|
- A model turn's saved reasoning (`thinking:`) goes back as `reasoning_content` per the turn's
|
|
200
220
|
`TurnSettings#reasoning_history` (`ReasoningHistory.resolve`, the session's own level first:
|
|
@@ -208,8 +228,15 @@ The differences from the native loop:
|
|
|
208
228
|
in `#resumed_run_start`), past a user entry added after that run's last reply (a `!cmd`'s output). Without
|
|
209
229
|
`since:` (`#reasoning_chars` from outside a Run) the boundary is the last query (a `role: "user"` or `turn_start`
|
|
210
230
|
entry). `Run#prompt_chars` counts it (`#reasoning_chars`).
|
|
231
|
+
- On an adapter that sends its blocks back (`#replays_content?`: `AnthropicMessages`), a model turn's saved blocks
|
|
232
|
+
(`anthropic_content`, the response's `provider_content`, saved by `Run#with_thinking`) go back verbatim, thinking
|
|
233
|
+
blocks with their signatures (`#replay_blocks`): not for an interrupted entry or one with an unpaired call, and
|
|
234
|
+
without the thinking blocks before the reasoning boundary (the last model entry with tool calls keeps them).
|
|
235
|
+
`#reasoning_history` floors `drop` at `turn` there, since Messages needs the running run's thinking blocks.
|
|
211
236
|
- A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
|
|
212
|
-
without it
|
|
237
|
+
without it (not on a replaying adapter, where the body would be the same); when that goes through,
|
|
238
|
+
`#reasoning_history_refused!` marks the model (worker lifetime; the loop's `LLM::RefusalMemo`, as for the thinking
|
|
239
|
+
and sampling refusals) and
|
|
213
240
|
`:reasoning_history_refused` goes out. When it is refused too, the thinking-fields fallback (`#thinking_refused!`,
|
|
214
241
|
`:thinking_refused`) runs on it, still without reasoning. Up to three requests per step. A 400 about a thinking
|
|
215
242
|
block's signature (`BadRequest#thinking_signature?`) or a refused binding control
|
|
@@ -256,7 +283,7 @@ One iteration with tool calls:
|
|
|
256
283
|
`EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
|
|
257
284
|
read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
|
|
258
285
|
that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
|
|
259
|
-
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `
|
|
286
|
+
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
|
|
260
287
|
once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
|
|
261
288
|
`Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
|
|
262
289
|
(`KernelLoop#session_cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
|
|
@@ -311,13 +338,46 @@ TurnState
|
|
|
311
338
|
|
|
312
339
|
The lock is a leaf: nothing is called out while it is held.
|
|
313
340
|
|
|
341
|
+
### EffectiveModel (a snapshot per read)
|
|
342
|
+
|
|
343
|
+
`EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve`: host, client, the id
|
|
344
|
+
sent) with its lookup names, and reads each setting from them (sampling, thinking, reasoning history, steer cut,
|
|
345
|
+
LLM context, window, price, served model, vision, profile). It is built per read and never kept: config.yml is
|
|
346
|
+
read live, a model listing can move a bare id to another host, and another process's saved model lists change
|
|
347
|
+
what a resolve gives, all without a `/model`. Frozen, so any thread may hold one.
|
|
348
|
+
|
|
349
|
+
- The Engine's readers take one (`Engine#effective_model`); those whose fallback always covered the resolve go
|
|
350
|
+
through `#model_setting`. A turn takes one in `turn_settings` (before the hooks) and one in `generate` (after
|
|
351
|
+
them); `system_prompt`, `steer_cut_after` (at `:turn_started`) and the turn-end warm-up take their own;
|
|
352
|
+
`stats_snapshot` and `session_state_snapshot` take one each (the latter nil-tolerant).
|
|
353
|
+
- Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`,
|
|
354
|
+
`guardrail_model_name`, `sync_model_key!`, `served_model(probe: false)` (`HostRegistry#model_ref`/`#bare_name`).
|
|
355
|
+
- Readers on other threads: the Bridge (`/state`, `/stats`, the SSE reset, a steer's cut wait), the worker's
|
|
356
|
+
command thread, the recap thread and plugin threads (`session_model_recap_target`), the REPL.
|
|
357
|
+
- `ModelChoice` (`@model_choice`) holds the name and the typed name as one value, so a reader on another thread
|
|
358
|
+
never pairs one model's name with another's typed name.
|
|
359
|
+
- `EffectiveModel.of_session` builds a saved session's (no worker): `Web::App`, `LLMContextStrategy.for_session`.
|
|
360
|
+
|
|
361
|
+
`SessionSetting` names the four per-session settings (llm_context, thinking, reasoning_history, toolkit): the
|
|
362
|
+
session's own value, the commands that move each chip, its chip from an EffectiveModel or from the Engine's
|
|
363
|
+
readers, a saved session's chip, and how the web takes a worker snapshot's. The session state, `/stats` (no
|
|
364
|
+
toolkit), a `:command_ran` and the web's session JSON list them from it.
|
|
365
|
+
|
|
366
|
+
### ThinkingNotices
|
|
367
|
+
|
|
368
|
+
What a turn's thinking level leads to, said once per session and host (`thinking_notices.rb`): an effort the
|
|
369
|
+
host can't do, off the model didn't honour, a host refusing the thinking fields or the reasoning sent back. It
|
|
370
|
+
keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
|
|
371
|
+
call it. Turn thread only.
|
|
372
|
+
|
|
314
373
|
### Turn (Engine::Turn)
|
|
315
374
|
|
|
316
375
|
The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
|
|
317
376
|
|
|
318
377
|
```ruby
|
|
319
378
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
320
|
-
:settings, :id, :limit) do
|
|
379
|
+
:settings, :id, :limit, :resumed, :resume_from) do
|
|
380
|
+
def resumed? = resumed ? true : false # a step-limit Continue
|
|
321
381
|
def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
|
|
322
382
|
def elapsed = ... # seconds since started_at
|
|
323
383
|
end
|
|
@@ -342,10 +402,14 @@ end
|
|
|
342
402
|
running turn's record so far as a killed turn's (canceled, `worker_stopped`), which the next worker loads; the
|
|
343
403
|
turn's end writes over it.
|
|
344
404
|
|
|
345
|
-
### GenerationPhase
|
|
405
|
+
### SteerCut, GenerationPhase and WaitingSteer
|
|
406
|
+
|
|
407
|
+
The Engine holds one `SteerCut` (`@steer_cut`), which owns a `GenerationPhase` and a `WaitingSteer`: the Bridge and
|
|
408
|
+
the TUI ask it (`Engine#cut_for_steer`, `#input_epoch`, `#generation_snapshot`), the Engine's stream handler feeds it
|
|
409
|
+
(`SteerCut#observe`), a drain that took input calls `#delivered!`, and `release_turn` calls `#finished!`.
|
|
346
410
|
|
|
347
|
-
|
|
348
|
-
`cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
|
|
411
|
+
`GenerationPhase` is what the running generation streams, fed through `SteerCut#observe`: whether a steer sent as a
|
|
412
|
+
cut (delivery `cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
|
|
349
413
|
Bridge acks it as `cut`.
|
|
350
414
|
A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
|
|
351
415
|
the model's cut wait (`SteerCutAfter.resolve`: `steer_cut_after` per models:/hosts: entry, else `steer.cut_after`) seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
|
|
@@ -357,8 +421,8 @@ line and tell whether "cut in now" can land.
|
|
|
357
421
|
### WaitingSteer
|
|
358
422
|
|
|
359
423
|
A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
|
|
360
|
-
waits until the thinking passes the model's cut wait (
|
|
361
|
-
the message to the model at a boundary, or until the turn ends (`
|
|
424
|
+
waits until the thinking passes the model's cut wait (`SteerCut#observe` rechecks it on each thinking chunk), until a
|
|
425
|
+
drain hands the message to the model at a boundary, or until the turn ends (`SteerCut#finished!` clears it).
|
|
362
426
|
|
|
363
427
|
## Events
|
|
364
428
|
|
|
@@ -414,22 +478,28 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
|
|
|
414
478
|
|
|
415
479
|
```
|
|
416
480
|
Engine initialization (in this order, among other things):
|
|
417
|
-
@turn_state, @
|
|
418
|
-
@host_registry, @
|
|
481
|
+
@turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
|
|
482
|
+
@host_registry, @model_choice (ModelChoice: the name and the name as typed); @client = effective_model.client
|
|
419
483
|
@given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
|
|
484
|
+
@hook_routes (HookRoutes: notices, hook questions, stop_turn/stop_generation, steer attribution)
|
|
420
485
|
@guardrail_wiring, @question_desk
|
|
486
|
+
@toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
|
|
421
487
|
@extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
|
|
422
|
-
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins)
|
|
488
|
+
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins),
|
|
489
|
+
then @tool_index.declare(@tools): find_tools + call_tool (source "index", not core) when a plugin
|
|
490
|
+
registered a chi.tool_source (Tools::ToolIndex: the index's groups, search, call checks; the source runs
|
|
491
|
+
the call with its own plugin Context)
|
|
423
492
|
wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
|
|
424
493
|
@kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
|
|
425
|
-
@hooks.runtime =
|
|
494
|
+
@hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
|
|
426
495
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
427
496
|
@prompt_builder = SystemPrompt.new(...)
|
|
428
497
|
@session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
|
|
429
498
|
|
|
430
499
|
Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
|
|
431
|
-
→ checks the host (ModelProfile.check_host!), sets @
|
|
432
|
-
→ drops the given profile and the profile resolution (resolved again on first need
|
|
500
|
+
→ checks the host (ModelProfile.check_host!), sets @model_choice (name and typed name in one assignment)
|
|
501
|
+
→ drops the given profile and the profile resolution (resolved again on first need; one that another
|
|
502
|
+
thread finishes for the old model is neither kept nor applied: #store_profile)
|
|
433
503
|
→ sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
|
|
434
504
|
→ sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
|
|
435
505
|
→ the kernel is never rebuilt; the next turn's backend follows the new host's api
|
|
@@ -444,7 +514,7 @@ Model probes (per turn):
|
|
|
444
514
|
|
|
445
515
|
Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
|
|
446
516
|
puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
|
|
447
|
-
`Hooks::Runtime
|
|
517
|
+
`Hooks::Runtime` (built by `HookRoutes`, `hook_routes.rb`, which routes each to the UIs). A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
|
|
448
518
|
|
|
449
519
|
| Helper | What it does | Used by |
|
|
450
520
|
|---|---|---|
|
|
@@ -551,8 +621,10 @@ Both loops:
|
|
|
551
621
|
- **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
|
|
552
622
|
and tool call hooks.
|
|
553
623
|
|
|
554
|
-
Native loop only: the Qwen open-block recovery, and the thinking prefill (
|
|
555
|
-
thought after the cue,
|
|
624
|
+
Native loop only: the Qwen open-block recovery, and the thinking prefill (`Thinking.native`: with thinking off, Qwen
|
|
625
|
+
gets an empty thought after the cue, `QWEN_EMPTY_THOUGHT`, and Gemma 4 its empty thought channel,
|
|
626
|
+
`GEMMA_EMPTY_THOUGHT`, with no `<|think|>` token before the system prompt; kept in the model message so the next
|
|
627
|
+
prompt starts with what the server has cached).
|
|
556
628
|
|
|
557
629
|
## Key design principles
|
|
558
630
|
|
|
@@ -566,8 +638,9 @@ thought after the cue, kept in the model message so the next prompt starts with
|
|
|
566
638
|
persistent.
|
|
567
639
|
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
|
|
568
640
|
`[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
|
|
569
|
-
only on a model switch, a tools change
|
|
570
|
-
|
|
641
|
+
only on a model switch, a tools change, a profile change or a toolkit change (`Engine#apply_toolkit!`). The
|
|
642
|
+
stable part (base prompt, the toolkit's not-loaded line, rg guidance, identity, model notes, preloaded memories,
|
|
643
|
+
AGENTS.md, appended files, memory indexes) comes before the volatile tail (model, working directory, session).
|
|
571
644
|
prompt-caching.md covers this in detail.
|
|
572
645
|
|
|
573
646
|
## Key files
|
|
@@ -576,12 +649,26 @@ Paths are under `lib/samagotchi/`.
|
|
|
576
649
|
|
|
577
650
|
| File | Role |
|
|
578
651
|
|---|---|
|
|
579
|
-
| `engine.rb` | Engine: turn lifecycle, event emission,
|
|
652
|
+
| `engine.rb` | Engine: turn lifecycle, event emission, plugins, model switching |
|
|
580
653
|
| `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
|
|
654
|
+
| `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
|
|
581
655
|
| `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
|
|
656
|
+
| `effective_model.rb` | `EffectiveModel` (the effective model's settings, a snapshot per read; `.of_session`), `ModelChoice`, the fallbacks' log lines (`Unresolved`) |
|
|
657
|
+
| `session_setting.rb` | `SessionSetting`: the per-session settings' descriptors (own value, chips, the web's fallback) |
|
|
658
|
+
| `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb` | `/thinking` and `/reasoning-history` (`LevelSettingCommand` and its two Specs) |
|
|
659
|
+
| `thinking_notices.rb` | `ThinkingNotices`: the notices a turn's thinking level leads to |
|
|
660
|
+
| `steer_cut_after.rb`, `cut_policy.rb` | The model's cut wait (`SteerCutAfter.resolve`); the loops' cut path (`CutPolicy`) |
|
|
582
661
|
| `steer.rb` | Queued input and steers at iteration boundaries |
|
|
583
662
|
| `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
|
|
584
|
-
| `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion) |
|
|
663
|
+
| `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion, block replay) |
|
|
664
|
+
| `llm/adapters.rb` | `LLM::Adapters::BY_API`: which chat adapter a host's api gets |
|
|
665
|
+
| `llm/openai_chat.rb` | `LLM::OpenAIChat`, the `/v1/chat/completions` adapter |
|
|
666
|
+
| `llm/adapter_common.rb` | `LLM::AdapterCommon`: what both chat adapters share (the HTTP layer and its timeouts, the paged model list, the SSE line loop, JSON errors) |
|
|
667
|
+
| `llm/anthropic_messages.rb`, `llm/anthropic_wire.rb`, `llm/anthropic_assembly.rb`, `llm/dialect.rb` | The `/v1/messages` adapter, its wire translation, its response, its dialects ([anthropic.md](anthropic.md)) |
|
|
668
|
+
| `llm/prompt_cache.rb` | Cache breakpoints on the OpenAI path, and the `cache_control` both adapters send |
|
|
669
|
+
| `llm/turn_settings.rb` | `LLM::TurnSettings`, what a turn runs under (vision, sampling, thinking, reasoning history, …) |
|
|
670
|
+
| `llm/native_tool_normalizer.rb`, `llm/stream_drops.rb` | A chat adapter's tool calls as chi's call hashes (`Tools::BuiltinCalls`); asking again for a step whose stream dropped (both loops) |
|
|
671
|
+
| `reasoning_history.rb` | `ReasoningHistory`: the levels, `.resolve`, `.run_start` |
|
|
585
672
|
| `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
|
|
586
673
|
| `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
|
|
587
674
|
| `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
|
|
@@ -591,7 +678,11 @@ Paths are under `lib/samagotchi/`.
|
|
|
591
678
|
| `prompt_warmup.rb` | The turn-end warm-up |
|
|
592
679
|
| `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
|
|
593
680
|
| `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
|
|
681
|
+
| `prompt_files.rb`, `model_notes.rb` | The AGENTS.md chain and appended files; the model notes the prompt loads |
|
|
682
|
+
| `toolkit.rb`, `toolkit_command.rb` | A session's toolkit (`Toolkit#resolve`, what `Registry` declares); `/toolkit` |
|
|
683
|
+
| `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
|
|
594
684
|
| `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
|
|
685
|
+
| `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
|
|
595
686
|
| `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
|
|
596
687
|
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
|
|
597
688
|
| `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
|
|
@@ -14,7 +14,7 @@ XML tool tags and declaration-echo parsing are intentionally not supported.
|
|
|
14
14
|
|
|
15
15
|
Thought handling follows the Gemma guidance:
|
|
16
16
|
|
|
17
|
-
- Include `<|think|>` in the system instruction to activate thinking mode.
|
|
17
|
+
- Include `<|think|>` in the system instruction to activate thinking mode. With thinking off (`/thinking off`) chi leaves the token out and prefills Gemma's empty thought channel after the model cue (`Thinking::GEMMA_EMPTY_THOUGHT`, `Thinking.native`, `Prompt.prefill_for`), as Gemma's chat template does with `enable_thinking` false.
|
|
18
18
|
- When thinking mode is active, the model may emit internal reasoning as `<|channel>thought ... <channel|>`.
|
|
19
19
|
- Standard multi-turn: prior model thoughts are stripped from conversation history before the next turn.
|
|
20
20
|
- Function/tool-calling exception: during a single turn that includes tool calls, thoughts are not stripped between those tool-call rounds.
|
|
@@ -75,9 +75,9 @@ The proxies are the context-edit spike's (lexical and lenient: a basename mentio
|
|
|
75
75
|
stale's stubs, every output led by its `[#tN]` id (`LLMContextView`), the `[CONTEXT: …]` line chi's `ContextStatus`
|
|
76
76
|
adds at a turn's start under `--budget N` (default 64000; no line below its guided buckets, so a small case may get
|
|
77
77
|
none), and the chat messages and tools of chi's `ChatLoop`, `forget_outputs` with its real description. The model
|
|
78
|
-
(`--forget-model MODEL`, a chi model ref on
|
|
79
|
-
calls runs through chi's `LLMContextForget` (refusals, `protect_steps`, `keep`),
|
|
80
|
-
plus the forgets. Each answer is saved in `--out DIR` (`<case>.forget_offer.json`) and read back, so a rerun asks
|
|
78
|
+
(`--forget-model MODEL`, a chi model ref on a chat host: `api: openai` or `api: anthropic`) answers once; a
|
|
79
|
+
`forget_outputs` call among its calls runs through chi's `LLMContextForget` (refusals, `protect_steps`, `keep`),
|
|
80
|
+
and the row scores stale's stubs plus the forgets. Each answer is saved in `--out DIR` (`<case>.forget_offer.json`) and read back, so a rerun asks
|
|
81
81
|
only for the rest (an error isn't saved); `--out` alone scores saved answers; `--max-cost USD` stops the asking
|
|
82
82
|
once the costs the server reports reach it (exit 1, the answers so far kept). The row's notes give the call rate
|
|
83
83
|
(`called`, `no_call`, `error`, `unasked`), the ids chi refused and the notes' mean length. Ids in this row cost
|
|
@@ -96,7 +96,8 @@ ruby script/llm_context_bench.rb "$SESSIONS" --picks deepseek=picks/deepseek --p
|
|
|
96
96
|
## Live picks (the D7 tool-name A/B)
|
|
97
97
|
|
|
98
98
|
The one part that calls a model, and only with `--live MODEL`: at each case it asks the model which of its tool
|
|
99
|
-
outputs to forget, through chi's own chat client (the model must be on
|
|
99
|
+
outputs to forget, through chi's own chat client (the model must be on a chat host, `api: openai` or `api: anthropic`:
|
|
100
|
+
`LivePick.chat_adapter` takes any `HostEntry#chat?` host), and saves each
|
|
100
101
|
answer under `--out DIR` for `--picks` to score. A saved pick is never asked for again. The request is the
|
|
101
102
|
session's conversation up to the turn's end (thinking dropped, every tool result led by its id `[#tN]`), chi's
|
|
102
103
|
built-in chat tools plus the forget tool, and the policy's tail line; a model that doesn't call the tool is asked
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
A model server reuses work only for the **exact token prefix** it has already seen. Change one token and
|
|
4
4
|
everything after it is computed again (prefill). The local models we run (hybrid Qwen 3.6 / Ornith on llama.cpp
|
|
5
5
|
and Splash) can only resume from a **restore point near the end** of an earlier prompt, not from any shared
|
|
6
|
-
prefix. Anthropic (Claude via OpenRouter) caches only up to an explicit
|
|
7
|
-
of what chi sends decides what a new turn, or a new session, has to pay
|
|
6
|
+
prefix. Anthropic (Claude via OpenRouter, or over the Messages API, `api: anthropic`) caches only up to an explicit
|
|
7
|
+
`cache_control` breakpoint. So the order of what chi sends decides what a new turn, or a new session, has to pay
|
|
8
|
+
for.
|
|
8
9
|
|
|
9
10
|
Measured with prompt-cache slice 1 (2026-10-04, fresh sessions, ~7.7k-token prompt):
|
|
10
11
|
|
|
@@ -23,6 +24,8 @@ right-hand column happens.
|
|
|
23
24
|
shared by every session ────────────────────────────────────────────────── breaks it
|
|
24
25
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
25
26
|
│ tools built-ins, then plugin tools sorted by (bundle, name), │ a tool added/changed,
|
|
27
|
+
│ the tool index's find_tools/call_tool as bundle "index" │
|
|
28
|
+
│ (declared once at load; publishes change only results) │
|
|
26
29
|
│ those the session's toolkit declares (per toolkit) │ /model, a profile change,
|
|
27
30
|
│ (chat: the `tools` field; native: the model's template) │ a toolkit change
|
|
28
31
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
@@ -36,7 +39,7 @@ right-hand column happens.
|
|
|
36
39
|
│ AGENTS.md (root → cwd) │ (picked up by a new
|
|
37
40
|
│ appended files (append_system_prompt:, --append-system-prompt) │ process or a rebuild)
|
|
38
41
|
│ project + system memory indexes │
|
|
39
|
-
│
|
|
42
|
+
│ ◄── remote Claude / Messages: cache_control breakpoint #1 │
|
|
40
43
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
41
44
|
│ SYSTEM, volatile tail (per session, least → most changing) │
|
|
42
45
|
│ Model: this session runs on … │ another session
|
|
@@ -48,17 +51,19 @@ right-hand column happens.
|
|
|
48
51
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
49
52
|
│ history user / assistant / tool messages, APPEND-ONLY │ !rollback, a failed or
|
|
50
53
|
│ (images: up to the newest N, cut in batches; ImagePlan) │ cancelled turn's tail,
|
|
51
|
-
│
|
|
52
|
-
│
|
|
54
|
+
│ ◄── remote Claude / Messages: breakpoint #2 on the last │ the image cut moving
|
|
55
|
+
│ non-system message (Messages: its last user block) │ a batch, history
|
|
53
56
|
├──────────────────────────────────────────────────────────────────────────┤ sanitizing (thinking
|
|
54
57
|
│ tail notes reminders, turn notes, steers (system/user messages) │ stripped per spec)
|
|
55
58
|
│ new prompt this turn's user message │
|
|
56
59
|
└──────────────────────────────────────────────────────────────────────────┘
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
- The system prompt is built **once per
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
- The system prompt is built **once per key** and kept (`SystemPrompt#build`, keyed by `[chat, thinking, layers]`:
|
|
63
|
+
the loop, the thinking level, the turn's LLM context layers; a `/thinking` change builds under another key). All
|
|
64
|
+
of them are dropped only on `@prompt_builder.reset!`: a model switch (`Engine#switch_model!`), a plugin's
|
|
65
|
+
`tools_changed!`, a profile change, or a toolkit change (`Engine#apply_toolkit!`). A per-turn rebuild would break
|
|
66
|
+
everything below it.
|
|
62
67
|
- The tools layer is shared **per toolkit** (configuration.md, "Toolkits"): sessions on the same toolkit share it,
|
|
63
68
|
and a chain's links keep theirs. A session's toolkit is saved when it is created and never resolved again from
|
|
64
69
|
config. A change (`/toolkit`, `ctx.use_toolkit`) is applied only at a turn's start (`Engine#apply_toolkit!`,
|
|
@@ -69,7 +74,14 @@ right-hand column happens.
|
|
|
69
74
|
breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
|
|
70
75
|
#2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
|
|
71
76
|
a breakpoint. The split point comes from `SystemPrompt#stable_length` via the message's `cache_split:`, which
|
|
72
|
-
never goes on the wire.
|
|
77
|
+
never goes on the wire. `PromptCache.mark` keys on the model id (`claude`) and runs only for a remote host's chat
|
|
78
|
+
requests (`OpenAIChat#cache_breakpoints`).
|
|
79
|
+
- On a Messages host (`api: anthropic`), `AnthropicWire.translate` sets the same two breakpoints on the translated
|
|
80
|
+
request: the first `system` block (the stable part, when `cache_split:` splits it) and the last user block that
|
|
81
|
+
can carry one (never a thinking block, never one from a mid or tail system message). It doesn't look at the model
|
|
82
|
+
id: every chat request carries them, unless the host's dialect has none (`Dialect#cache_breakpoints`: `fireworks`,
|
|
83
|
+
an automatic prefix cache) or the host refused `cache_control` with a 400 (no breakpoints for that worker's life).
|
|
84
|
+
[anthropic.md](anthropic.md) has the details.
|
|
73
85
|
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
|
|
74
86
|
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
|
75
87
|
|
|
@@ -115,14 +127,17 @@ is prefilled once. The native and the chat loop use the same plan.
|
|
|
115
127
|
one provider. `cache.key: session` also sends the session id as `prompt_cache_key` in the body, the field
|
|
116
128
|
OpenAI's API routes and caches by (OpenRouter honours it too); the log line shows `cache_by=session`. Only
|
|
117
129
|
`api.openai.com` and OpenRouter get it, and only the session's own chat requests (not recap or `/btw`): a local
|
|
118
|
-
llama.cpp, Splash or another gateway may reject an unknown field. Off by default.
|
|
130
|
+
llama.cpp, Splash or another gateway may reject an unknown field. Off by default. A Messages host never gets
|
|
131
|
+
`prompt_cache_key` (`AnthropicMessages::DROPPED_OPTIONS`); its dialect may add a header of its own beside
|
|
132
|
+
`Session-Id` (`Dialect#session_header`: `x-session-affinity` under `dialect: fireworks`, which keeps a session on
|
|
133
|
+
the replica that has its cache).
|
|
119
134
|
|
|
120
135
|
## Reading the numbers
|
|
121
136
|
|
|
122
137
|
- The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
|
|
123
138
|
server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
|
|
124
139
|
request's prompt on the same model, what was prefilled again). The `http` line of a
|
|
125
|
-
remote Claude request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
|
|
140
|
+
remote Claude or Messages request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
|
|
126
141
|
- The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
|
|
127
142
|
- `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
|
|
128
143
|
- A healthy session: from the second request on `cached=` is close to `prompt=`; a second fresh session in the
|