samagotchi 0.25.0 → 0.27.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 +47 -1
- data/docs/architecture.md +6 -0
- data/docs/configuration.md +1 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +33 -7
- data/docs/plugins.md +3 -2
- data/lib/samagotchi/bridge.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cancellation_controller.rb +8 -0
- data/lib/samagotchi/client.rb +53 -3
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/engine.rb +50 -7
- data/lib/samagotchi/kernel_loop.rb +39 -3
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/http.rb +5 -4
- data/lib/samagotchi/log.rb +19 -3
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +4 -3
- data/lib/samagotchi/tool_activity.rb +3 -2
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_view.rb +14 -3
- data/lib/samagotchi/tools/task_get.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +49 -12
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +22 -10
- data/lib/samagotchi/turn_note.rb +28 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +25 -0
- data/lib/samagotchi/web/markdown_renderer.rb +21 -6
- data/lib/samagotchi/web/public/app.js +4 -0
- data/lib/samagotchi/web/public/command_view.js +6 -3
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +39 -13
- data/lib/samagotchi/web/public/stage_model.js +8 -2
- data/lib/samagotchi/web/public/stage_view.js +14 -2
- data/lib/samagotchi/web/public/task_stop.js +101 -0
- data/lib/samagotchi/web/public/turn_view.js +24 -1
- data/lib/samagotchi/worker.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b91f830918a3f58dac1d54820450ec90b4caa2d8f970b3fab03c8a0199b3ac2e
|
|
4
|
+
data.tar.gz: 1d1f0f1dc839247801be1462a895e8a57e189a68882fbaeb5a7707fdb4add0ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 398faa2152be479cf78093fb9c752158932a2c186bf1fa5fd1ee9a622895bf742f82f1a25c843febfb877c826cee869623ee9871c50154015fdb54d02a490054
|
|
7
|
+
data.tar.gz: 35a4b45af554dd9cfd79c87d67ed7d819d14759421f8f669ac769ddbb1b37a64f5b0c79101f15a002ebdb4a2e2e9bee1def9eead820d50f61312c9f4a8cc41c8
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,50 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.27.0] - 2026-10-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: a "stop task" button on a running `task_wait` stops the background task; the turn goes on and the model is
|
|
16
|
+
told the user stopped it (Cancel still cancels the turn and leaves the task running).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `/mcp` lists each server's tools alphabetically, not in the order the server sent them (mcp bundle 0.4.3).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- A stopped background task ends as `stopped`, not `failed`, and records who stopped it (the user or the model).
|
|
25
|
+
- Code blocks in the web follow the light theme too: their highlighting comes from the page's palette instead of one
|
|
26
|
+
fixed dark theme.
|
|
27
|
+
|
|
28
|
+
Update with `chi update` (mcp 0.4.3).
|
|
29
|
+
|
|
30
|
+
## [0.26.0] - 2026-10-04
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- The web has a light theme: with the system set to light it follows (`prefers-color-scheme`). There is no
|
|
35
|
+
in-page switch yet. Native radios, inputs and scrollbars now follow the theme, so they are dark in dark mode.
|
|
36
|
+
- On a local llama.cpp host, the next turn starts faster: when a turn ends, chi sends the next turn's prompt (up to
|
|
37
|
+
your next message) in the background, so the server has it ready while you read. `cache.warmup: off` turns it
|
|
38
|
+
off; remote and paid hosts never get it.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `execute`'s `description` parameter now says it is shown to the user as the step's title and asks for it on
|
|
43
|
+
every call (still optional), so models, Sonnet in particular, now label execute steps.
|
|
44
|
+
- loop-guard (bundle 0.3.4) words a loop its short run or its window found by the different sentences it held,
|
|
45
|
+
`12 different sentences in 48`, instead of `12 sentences ×4`, which read as a cycle of 12.
|
|
46
|
+
- `sampling:` can't set llama.cpp's `id_slot` (pinning a session to a slot made the next turn re-read the whole
|
|
47
|
+
prompt).
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- `thinking_tails.jsonl` names the plugin that stopped a turn instead of "hook".
|
|
52
|
+
|
|
53
|
+
Update with `chi update` (loop-guard 0.3.4).
|
|
54
|
+
|
|
11
55
|
## [0.25.0] - 2026-10-04
|
|
12
56
|
|
|
13
57
|
### Added
|
|
@@ -1394,7 +1438,9 @@ and long-lived sessions.
|
|
|
1394
1438
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1395
1439
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1396
1440
|
|
|
1397
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1441
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.27.0...HEAD
|
|
1442
|
+
[0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
|
|
1443
|
+
[0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
|
|
1398
1444
|
[0.25.0]: https://github.com/dm1try/samagotchi/compare/v0.24.0...v0.25.0
|
|
1399
1445
|
[0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
|
|
1400
1446
|
[0.23.0]: https://github.com/dm1try/samagotchi/compare/v0.22.0...v0.23.0
|
data/docs/architecture.md
CHANGED
|
@@ -246,6 +246,12 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
246
246
|
`guardrails.parent_approvals` is `403 parent_approval_refused`, the question still open.
|
|
247
247
|
- `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
|
|
248
248
|
`200`, or `409` when it is no longer pending.
|
|
249
|
+
- `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
|
|
250
|
+
(`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
|
|
251
|
+
web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
|
|
252
|
+
`task_create` started (the saved messages, or the running turn's accumulator); answers once the
|
|
253
|
+
task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
|
|
254
|
+
The worker names it in its sidecar features (`task_stop`).
|
|
249
255
|
- `POST /session/:id/relay` — the approval relay, on a delegated child's worker
|
|
250
256
|
(`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
|
|
251
257
|
pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
|
data/docs/configuration.md
CHANGED
|
@@ -898,6 +898,7 @@ described in their own sections.
|
|
|
898
898
|
| `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
|
|
899
899
|
| `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
|
|
900
900
|
| `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
|
|
901
|
+
| `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
|
|
901
902
|
| `retry.max` | `5` | yes | See "Llama Network Retry Behavior". |
|
|
902
903
|
| `retry.base_delay` | `0.5` | yes | |
|
|
903
904
|
| `retry.max_delay` | `8.0` | yes | |
|
data/docs/hooks.md
CHANGED
|
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
56
56
|
| `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
57
57
|
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
|
|
58
58
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
59
|
-
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
|
|
59
|
+
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
|
|
60
60
|
| `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
61
61
|
|
|
62
62
|
Every event also carries the hook runtime (next section): `hook:` (the label
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# Engine: the core agent loop
|
|
2
|
+
|
|
3
|
+
The Engine (`lib/samagotchi/engine.rb`) runs a session's turns. It owns the turn lifecycle, the hooks and
|
|
4
|
+
plugins, and the model↔tool cycle, and it reports what happens as events, so every UI (the REPL, a worker
|
|
5
|
+
behind the web and the attached TUI, one-shot CLI runs) runs turns the same way without the Engine knowing how
|
|
6
|
+
they render.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoop#complete (chat)
|
|
10
|
+
→ per iteration: one generation, then ToolResponse.run_batch over the calls
|
|
11
|
+
→ ToolRunner#run per call → Guardrails::Gate → KernelLoop#dispatch_tool_call
|
|
12
|
+
→ repeat until the model answers without tool calls, or the iteration limit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick reference: the turn lifecycle
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
|
|
19
|
+
pending_input:, continue:, origin:, images:)
|
|
20
|
+
│
|
|
21
|
+
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
|
|
22
|
+
│ # @session = session, session.status = RUNNING, used memories absorbed,
|
|
23
|
+
│ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
|
|
24
|
+
│ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
|
|
25
|
+
│ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
|
|
26
|
+
│ # origin, started_at, id}
|
|
27
|
+
│
|
|
28
|
+
├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
|
|
29
|
+
│
|
|
30
|
+
├── prepare_turn(turn, images)
|
|
31
|
+
│ # Emits :turn_started {session_id, prompt, turn_id, continue: true (only on a continue),
|
|
32
|
+
│ # images (only when there are any)}
|
|
33
|
+
│ # refresh_profile! (may ask the server's /props once)
|
|
34
|
+
│ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
|
|
35
|
+
│ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools
|
|
36
|
+
│ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
|
|
37
|
+
│ # (a frozen copy of the history, and the prompt)
|
|
38
|
+
│ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
|
|
39
|
+
│ # Returns false when a Stop came during the probes or the init wait, else true
|
|
40
|
+
│
|
|
41
|
+
├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
42
|
+
│ # sync_kernel_client!, then @kernel.turn_settings = turn.settings with model_name and window_setting
|
|
43
|
+
│ # turn.limit = max_iterations or IterationLimit.for
|
|
44
|
+
│ # backend.complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
|
|
45
|
+
│ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
|
|
46
|
+
│ # context_status, empty_steps, empty_retries)
|
|
47
|
+
│
|
|
48
|
+
├── publish_used_memories(session, on_event) # :used_memories_updated when there are any
|
|
49
|
+
│
|
|
50
|
+
├── complete_turn(turn, result)
|
|
51
|
+
│ # end_turn(turn, "completed" | "canceled"): under the event lock the session gets the kept
|
|
52
|
+
│ # messages, goes IDLE, records last_turn, and :turn_completed {result, turn_summary,
|
|
53
|
+
│ # display_pending} or :turn_canceled {cancellation_reason, cancelled_by, duration_ms} is emitted
|
|
54
|
+
│ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter)
|
|
55
|
+
│ # store_answer_display (:answer_display to the observers when a hook changed the answer,
|
|
56
|
+
│ # or display: nil when the web was told to wait for it)
|
|
57
|
+
│ # then the :session_end hook
|
|
58
|
+
│
|
|
59
|
+
├── warm_up_next_turn(turn, result)
|
|
60
|
+
│ # local llama.cpp on the native loop only: prefills the next turn's prompt while the user
|
|
61
|
+
│ # reads (PromptWarmup; see prompt-caching.md, "The turn-end warm-up"). Never fails the turn.
|
|
62
|
+
│
|
|
63
|
+
└── ensure: release_turn(probe_cancel_before)
|
|
64
|
+
# TurnState#finish! (turn flag off, controller, sink and steers cleared)
|
|
65
|
+
# GenerationPhase#finished!, WaitingSteer#clear!
|
|
66
|
+
# probe cancel swapped back
|
|
67
|
+
# the context window cache dropped, so the next turn asks the server again
|
|
68
|
+
# steers left logged as dropped, activity recorded, turn-scoped hooks cleared
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Error paths
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
StandardError in run_turn:
|
|
75
|
+
→ if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
|
|
76
|
+
→ else: end_turn(turn, "failed", save: true)
|
|
77
|
+
# Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
|
|
78
|
+
# failed_messages keeps the loop's partial conversation (tool iterations included),
|
|
79
|
+
# else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
|
|
80
|
+
# :turn_failed {error_class, message, duration_ms}; a provider error adds
|
|
81
|
+
# error_kind, retryable, host and summary.
|
|
82
|
+
→ re-raised
|
|
83
|
+
|
|
84
|
+
Interrupt (Ctrl-C):
|
|
85
|
+
→ if turn.ended: re-raised only
|
|
86
|
+
→ turn.controller.cancel!(:ctrl_c)
|
|
87
|
+
→ end_turn(turn, "canceled"): ctrl_c_messages keeps turn.messages with TurnNote.cancelled(:ctrl_c)
|
|
88
|
+
# Both loops work on their own copy of the conversation, and Interrupt is not a
|
|
89
|
+
# StandardError, so nothing attaches the loop's conversation: only what the Engine
|
|
90
|
+
# built before the loop (system head, history, reminders, the prompt) survives.
|
|
91
|
+
# Tasks the turn started are missed in the cancel note for the same reason.
|
|
92
|
+
→ :turn_canceled {cancellation_reason: :ctrl_c, duration_ms}
|
|
93
|
+
→ re-raised (the caller decides whether to exit)
|
|
94
|
+
|
|
95
|
+
A Stop (Engine#cancel_current_turn!, e.g. the Bridge's POST cancel) or a hook's stop_turn:
|
|
96
|
+
→ not an error: the loop returns LLM::ModelResult(canceled: true, conversation: ...)
|
|
97
|
+
→ complete_turn keeps the loop's conversation (tool iterations included) with a
|
|
98
|
+
TurnNote.cancelled note; a text streamed before the cancel stays, marked [interrupted]
|
|
99
|
+
|
|
100
|
+
Stopped before the model was asked (prepare_turn returned false):
|
|
101
|
+
→ stopped_before_model(turn) → end_turn(turn, "canceled")
|
|
102
|
+
→ nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
|
|
103
|
+
→ LLM::ModelResult(text: "", canceled: true, cancellation_reason: reason)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## The model↔tool loop
|
|
107
|
+
|
|
108
|
+
The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
|
|
112
|
+
→ host entry chat? (api: openai): LLM::ChatLoop (one per Engine, built on first use)
|
|
113
|
+
→ else: LLM::NativeBackend wrapping the Engine's KernelLoop
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
|
|
117
|
+
cancel_controller:, model_name:, max_tool_output_chars:, pending_input:)`). `NativeBackend#complete` only calls
|
|
118
|
+
`KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
|
|
119
|
+
whose tools, hooks, gate and dispatch it uses.
|
|
120
|
+
|
|
121
|
+
### KernelLoop (the native, raw-prompt loop)
|
|
122
|
+
|
|
123
|
+
Runs models whose server takes a raw prompt (llama.cpp `/completion`, or `/v1/completions` on an
|
|
124
|
+
OpenAI-compatible completions server). chi formats the prompt itself, per model profile (Gemma 4, Qwen 3.6), and
|
|
125
|
+
parses tool calls out of the generated text.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
|
|
129
|
+
model_name:, max_tool_output_chars:, pending_input:)
|
|
130
|
+
│
|
|
131
|
+
│ turn = start_turn(...) # turn.conversation = prepare_conversation(messages): a copy,
|
|
132
|
+
│ # old model messages without their thought blocks
|
|
133
|
+
│ each iteration:
|
|
134
|
+
│ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
|
|
135
|
+
│ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
|
|
136
|
+
│ guidance line goes on the tail when the window fills past a threshold
|
|
137
|
+
│ 3. Stream one generation (:generation_started, :generation_chunk, :generation_completed)
|
|
138
|
+
│ 4. Parse tool calls (ToolCallParser for the profile):
|
|
139
|
+
│ → Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
|
|
140
|
+
│ → Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
141
|
+
│ A Qwen block left open is asked to be finished (QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2)
|
|
142
|
+
│ 5. Calls: dispatch_calls → ToolResponse.run_batch; one tool_response entry for the batch
|
|
143
|
+
│ 6. No calls: an answer, an empty answer to retry (EmptyAnswerRetry), or queued input to answer
|
|
144
|
+
│
|
|
145
|
+
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
|
|
149
|
+
(`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; a Ctrl-C does not (see Error paths).
|
|
150
|
+
|
|
151
|
+
### ChatLoop (the OpenAI chat API)
|
|
152
|
+
|
|
153
|
+
Runs hosts with `api: openai`: remote providers such as OpenRouter (Claude and others) and any OpenAI-compatible
|
|
154
|
+
chat server. The model gets the tools as JSON schemas and returns structured tool calls.
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
|
|
158
|
+
model_name:, max_tool_output_chars:, pending_input:)
|
|
159
|
+
│
|
|
160
|
+
│ conversation = Array(messages).map(&:dup) # a shallow copy of each message
|
|
161
|
+
│ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
|
|
162
|
+
│ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
163
|
+
│
|
|
164
|
+
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The differences from the native loop:
|
|
168
|
+
|
|
169
|
+
- The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
|
|
170
|
+
- The conversation is converted to the OpenAI wire format (`wire_messages`).
|
|
171
|
+
- The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
|
|
172
|
+
as `stable_length:`), where the prompt's stable part ends. For remote Claude, `LLM::PromptCache` puts the
|
|
173
|
+
first cache breakpoint there; see prompt-caching.md.
|
|
174
|
+
- Each call's result is its own tool message, paired by `tool_call_id`.
|
|
175
|
+
|
|
176
|
+
### Shared: ToolResponse (per batch) and ToolRunner (per call)
|
|
177
|
+
|
|
178
|
+
Both loops run a batch of calls the same way:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
One iteration with tool calls:
|
|
182
|
+
KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
|
|
183
|
+
→ ToolResponse.run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
|
|
184
|
+
→ :tool_dispatch_started {iteration, call_count}
|
|
185
|
+
→ for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
|
|
186
|
+
on_stream_event:, max_tool_output_chars:)
|
|
187
|
+
→ Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
|
|
188
|
+
→ :tool_call_started
|
|
189
|
+
→ an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
|
|
190
|
+
→ KernelLoop#dispatch_tool_call, or the denial text
|
|
191
|
+
→ :tool_call_completed, then the :after_tool_call hook
|
|
192
|
+
→ returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
|
|
193
|
+
shown_label:, diff:}
|
|
194
|
+
→ :tool_dispatch_completed {iteration, call_count}
|
|
195
|
+
→ native: ToolResponse.joined(runs), one entry with the full outputs
|
|
196
|
+
chat: ToolResponse.single(run, tool_call_id:) per call, with the capped output
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**ToolRunner** (`ToolRunner#run`):
|
|
200
|
+
- Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
|
|
201
|
+
replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
|
|
202
|
+
- Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
|
|
203
|
+
title, view}.
|
|
204
|
+
- Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
|
|
205
|
+
- Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
|
|
206
|
+
`KernelLoop#dispatch` ("Error: unknown tool ...").
|
|
207
|
+
- For edit and write: diffs the file before and after, and refreshes a memory's index line
|
|
208
|
+
(`MemoryBundle::IndexSync`).
|
|
209
|
+
- Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
|
|
210
|
+
- Scrubs bytes that aren't UTF-8, and caps the event's output at `max_tool_output_chars`.
|
|
211
|
+
- Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, activity,
|
|
212
|
+
images, diff, waited_ms, view}, then fires `:after_tool_call` {iteration, tool, output, status}.
|
|
213
|
+
|
|
214
|
+
A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
|
|
215
|
+
`stop_turn`, the gate denies every remaining call of the batch.
|
|
216
|
+
|
|
217
|
+
## State management
|
|
218
|
+
|
|
219
|
+
### TurnState (cross-thread)
|
|
220
|
+
|
|
221
|
+
One object, guarded by a `Monitor`, that the turn thread writes (`begin_turn`, `release_turn`) and other threads
|
|
222
|
+
read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
TurnState
|
|
226
|
+
├── running?
|
|
227
|
+
├── controller (the running turn's CancellationController)
|
|
228
|
+
├── sink (the turn's on_event)
|
|
229
|
+
├── steers (plugin steers for the running turn's next boundary)
|
|
230
|
+
├── carried_steers (for the next turn that begins)
|
|
231
|
+
└── activity clock (last_activity_at, activity_seq)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- `begin!(controller:, sink:)`: running, with controller and sink, in one step; carried steers become steers.
|
|
235
|
+
- `finish!`: not running, controller and sink cleared; returns the steers never taken (logged as dropped).
|
|
236
|
+
- `steer(text, source:)`: queues a steer for the running turn (false with no turn).
|
|
237
|
+
- `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
|
|
238
|
+
|
|
239
|
+
The lock is a leaf: nothing is called out while it is held.
|
|
240
|
+
|
|
241
|
+
### Turn (Engine::Turn)
|
|
242
|
+
|
|
243
|
+
The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
247
|
+
:settings, :id, :limit) do
|
|
248
|
+
def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
|
|
249
|
+
def elapsed = ... # seconds since started_at
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- `messages` is what the Engine sends: system head, history, reminders, the prompt. The loop works on its own
|
|
254
|
+
copy; the Engine keeps `result.conversation` after a normal end or a Stop, and the error's partial
|
|
255
|
+
conversation after a failure.
|
|
256
|
+
- `controller` is the turn's `CancellationController`, an in-process signal. Another process stops a worker's
|
|
257
|
+
turn through the Bridge (`POST cancel` → `Engine#cancel_current_turn!`).
|
|
258
|
+
- `on_event` is the turn's sink.
|
|
259
|
+
- `ended` is set by `end_turn`.
|
|
260
|
+
|
|
261
|
+
### GenerationPhase
|
|
262
|
+
|
|
263
|
+
What the running generation streams, fed by the Engine's stream handler: whether a user's steer may cut it.
|
|
264
|
+
A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
|
|
265
|
+
`steer.cut_after` seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
|
|
266
|
+
|
|
267
|
+
### WaitingSteer
|
|
268
|
+
|
|
269
|
+
A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
|
|
270
|
+
waits until the thinking passes `steer.cut_after` (the Engine checks on each thinking chunk), until a drain hands
|
|
271
|
+
the message to the model at a boundary, or until the turn ends (`clear!` in `release_turn`).
|
|
272
|
+
|
|
273
|
+
## Events
|
|
274
|
+
|
|
275
|
+
### The turn's sink (`on_event`) and the observers
|
|
276
|
+
|
|
277
|
+
`Engine#emit_event` sends each turn event to the turn's sink (`on_event`, the REPL's renderer) and then to the
|
|
278
|
+
persistent observers (`Engine#subscribe`), which get a copy with a monotonic `event_seq`. The observers are the
|
|
279
|
+
metrics, the debug log, the thinking tails, and in a worker the Bridge, which streams to the web and the attached
|
|
280
|
+
TUI. Errors in the sink or an observer are caught; they never break the turn.
|
|
281
|
+
|
|
282
|
+
`Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `card`,
|
|
283
|
+
`hook_notice`, `guardrail_warning`, `plugin_init_started`, and more) to the observers only.
|
|
284
|
+
|
|
285
|
+
Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
286
|
+
|
|
287
|
+
| Event | When | Payload |
|
|
288
|
+
|---|---|---|
|
|
289
|
+
| `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, images?}` |
|
|
290
|
+
| `:reminder_injected` | due reminders went into the turn | `{reminders}` |
|
|
291
|
+
| `:context_status` | the window's fill crossed a bucket | `{iteration, ...}` |
|
|
292
|
+
| `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source}` (native adds `profile`, `profile_source`) |
|
|
293
|
+
| `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
|
|
294
|
+
| `:generation_retrying` | the transport retries the request | `{iteration, ...}` (the retry's fields) |
|
|
295
|
+
| `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens` |
|
|
296
|
+
| `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
|
|
297
|
+
| `:empty_answer_retry` | an empty or cut answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, ...}` |
|
|
298
|
+
| `:steer_cut` | a steer cut the generation | `{iteration, source}` |
|
|
299
|
+
| `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
|
|
300
|
+
| `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
|
|
301
|
+
| `:tool_call_started` | before a call runs | see ToolRunner |
|
|
302
|
+
| `:tool_call_completed` | after a call ran | see ToolRunner |
|
|
303
|
+
| `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
|
|
304
|
+
| `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
|
|
305
|
+
| `:guardrail_warning` | a guardrails line during a turn | `{message}` |
|
|
306
|
+
| `:turn_completed` | normal end | `{result, turn_summary, display_pending}` |
|
|
307
|
+
| `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
|
|
308
|
+
| `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
|
|
309
|
+
| `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display}` (a string, or nil) |
|
|
310
|
+
|
|
311
|
+
The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry `origin`
|
|
312
|
+
when the turn was queued with one.
|
|
313
|
+
|
|
314
|
+
### Hook points (not events)
|
|
315
|
+
|
|
316
|
+
`:session_start`, `:before_turn`, `:after_turn` and `:session_end` are hook points: the Engine fires them on the
|
|
317
|
+
hook registry (`@hooks.fire`), not on the sink or the observers. The other hook points are `:before_generation`,
|
|
318
|
+
`:after_generation`, `:generation_progress` (Hooks::StreamWatch, in batches while the response streams),
|
|
319
|
+
`:before_tool_call` (the guardrail gate) and `:after_tool_call` (ToolRunner). docs/hooks.md lists their payloads.
|
|
320
|
+
|
|
321
|
+
## Session lifecycle
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
Engine initialization (in this order, among other things):
|
|
325
|
+
@turn_state, @generation_phase, @waiting_steer
|
|
326
|
+
@host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
|
|
327
|
+
@given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
|
|
328
|
+
@guardrail_wiring, @question_desk
|
|
329
|
+
@hooks = load_hooks_from_config (config.yml hooks), then load_hooks_from_bundles
|
|
330
|
+
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins)
|
|
331
|
+
@kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
|
|
332
|
+
@kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
|
|
333
|
+
@hooks.runtime = hook_runtime
|
|
334
|
+
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
335
|
+
@prompt_builder = SystemPrompt.new(...)
|
|
336
|
+
@session_observer with the metrics, the log subscriber and the thinking tails subscribed
|
|
337
|
+
|
|
338
|
+
Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
|
|
339
|
+
→ checks the host (ModelProfile.check_host!), sets @effective_model_name
|
|
340
|
+
→ drops the given profile and the profile resolution (resolved again on first need)
|
|
341
|
+
→ sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
|
|
342
|
+
→ sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
|
|
343
|
+
→ the kernel is never rebuilt; the next turn's backend follows the new host's api
|
|
344
|
+
|
|
345
|
+
Model probes (per turn):
|
|
346
|
+
The profile, window, served model and vision are asked from the server's /props as needed.
|
|
347
|
+
The cache is dropped at the turn's end, not its start, so one turn doesn't ask twice.
|
|
348
|
+
A Stop cuts the probes early (Client.swap_probe_cancel).
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Hooks
|
|
352
|
+
|
|
353
|
+
Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
|
|
354
|
+
puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
|
|
355
|
+
`Hooks::Runtime`. A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
|
|
356
|
+
|
|
357
|
+
| Helper | What it does | Used by |
|
|
358
|
+
|---|---|---|
|
|
359
|
+
| `event[:notify].call(text, level:)` | One line to the user (`:hook_notice`) | mcp (a server didn't start), loop-guard |
|
|
360
|
+
| `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question through the question flow; nil when there is no one to ask | known-names (a near-miss name: correct it?) |
|
|
361
|
+
| `event[:stop_turn].call(reason)` | Cancel the running turn (reason `:hook`), with a warn notice | loop-guard (repeated calls), check-in (`/checkin stop`) |
|
|
362
|
+
| `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard (thinking repeats itself) |
|
|
363
|
+
| `event[:steer].call(text)` | Queue text for the running turn's next boundary | check-in (nudge mode), skills |
|
|
364
|
+
|
|
365
|
+
From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `steer` return false.
|
|
366
|
+
|
|
367
|
+
### Hook lifecycle
|
|
368
|
+
|
|
369
|
+
- **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`, from config.yml)
|
|
370
|
+
live for the process: `clear_all` leaves them, so they apply to every turn.
|
|
371
|
+
- **Turn-scoped hooks** (`Engine#register_hook`, `Registry#register`) are cleared by `clear_hooks` in
|
|
372
|
+
`release_turn`.
|
|
373
|
+
- Order of a fire: bundle hooks (by priority, bundle, hook name), then config hooks, then turn-scoped hooks.
|
|
374
|
+
- A hook that raises is logged; it never breaks the turn.
|
|
375
|
+
- A notice from a hook goes to the running turn's sink and the observers. Outside a turn it is announced with
|
|
376
|
+
`between_turns: true`; while the plugins load it is held and announced after.
|
|
377
|
+
- Plugin init tasks (`chi.init`, slow setup such as an MCP server's first start): a turn waits for the ones that
|
|
378
|
+
provide tools before it builds its messages, and a Stop or Ctrl-C ends the wait.
|
|
379
|
+
|
|
380
|
+
## Guardrails
|
|
381
|
+
|
|
382
|
+
`ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
|
|
383
|
+
the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
|
|
384
|
+
known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
|
|
385
|
+
`event[:blocked]` flag. A deny stays a deny for the hooks after it. Then the core checks run on the final call:
|
|
386
|
+
protected paths, then the rules (config and bundles, e.g. the guardrails bundle's `rules.yml`; a rule can match
|
|
387
|
+
the tool, its targets and the model). A gate that raises denies the call.
|
|
388
|
+
|
|
389
|
+
The verdict is one of:
|
|
390
|
+
|
|
391
|
+
- **allow**: the call runs.
|
|
392
|
+
- **deny**: that call's output is replaced with denial text; the rest of the batch still runs.
|
|
393
|
+
- **ask**: `Gate#settle_ask` first looks for a stored approval that covers the call. Otherwise
|
|
394
|
+
`Engine#request_approval` asks the user through the question flow (REPL, attached TUI, web). A
|
|
395
|
+
`--non-interactive` run has no one to ask, so the call is denied. An approval wider than this once is stored.
|
|
396
|
+
|
|
397
|
+
docs/guardrails.md covers the rules and the approval scopes.
|
|
398
|
+
|
|
399
|
+
## Anytime commands
|
|
400
|
+
|
|
401
|
+
A slash command a plugin registers with `anytime: true` (the btw bundle's `/btw`) runs on its own thread, even
|
|
402
|
+
while a turn runs:
|
|
403
|
+
|
|
404
|
+
```
|
|
405
|
+
Engine
|
|
406
|
+
├── spawn_anytime { ... } # a thread #shutdown waits for
|
|
407
|
+
├── running_anytime { ... } # cards and notices on this thread are announced at once,
|
|
408
|
+
│ # marked anytime: true, never as the running turn's events
|
|
409
|
+
├── add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed:)
|
|
410
|
+
└── await_init_tasks(controller, on_event)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`add_init_task` and `await_init_tasks` belong to plugin init tasks (`PluginTasks`), not to anytime commands.
|
|
414
|
+
|
|
415
|
+
## Shared loop behaviors
|
|
416
|
+
|
|
417
|
+
Both loops:
|
|
418
|
+
|
|
419
|
+
- **Empty-answer retry** (`EmptyAnswerRetry`): a generation with no visible text and no tool calls is asked
|
|
420
|
+
again, up to `retry.empty_answer` times (at most 3), with a hidden nudge (`TurnNote.empty_retry`) on the tail.
|
|
421
|
+
The retry request runs at temperature 0.6 when the configured sampling sets no temperature; a configured one
|
|
422
|
+
is kept. A length stop with the context at least 90% full is not retried. When the retries run out, the
|
|
423
|
+
Engine keeps a `TurnNote.empty` note as the turn's end.
|
|
424
|
+
- **A cut generation** (`after_cut`): a generation a plugin cut (`stop_generation`) spends the same retry budget
|
|
425
|
+
with its own nudge; when it is used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the
|
|
426
|
+
steer and spends nothing.
|
|
427
|
+
- **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`).
|
|
428
|
+
- **Context status**: an estimate of the window's fill before each request (`:context_status`), with a guidance
|
|
429
|
+
line on the tail when it rises far enough.
|
|
430
|
+
- **Cancel**: text streamed before a cancel stays, marked `[interrupted]`.
|
|
431
|
+
- **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
|
|
432
|
+
and tool call hooks.
|
|
433
|
+
|
|
434
|
+
Native loop only: the Qwen open-block recovery, and the thinking prefill (Qwen with thinking off gets an empty
|
|
435
|
+
thought after the cue, kept in the model message so the next prompt starts with what the server has cached).
|
|
436
|
+
|
|
437
|
+
## Key design principles
|
|
438
|
+
|
|
439
|
+
1. **Turn state is cross-thread safe.** TurnState uses one Monitor and calls nothing out while it is held.
|
|
440
|
+
2. **Turn-scoped and process-wide hooks.** `register_hook` hooks are cleared at `release_turn`; bundle hooks
|
|
441
|
+
and config.yml hooks survive.
|
|
442
|
+
3. **Each loop works on its own copy of the conversation.** The Engine's `turn.messages` is what it sent. After
|
|
443
|
+
a normal end or a Stop it keeps `result.conversation`; after a failure, the copy attached with
|
|
444
|
+
`FailedTurn.attach`; after a Ctrl-C, only `turn.messages`.
|
|
445
|
+
4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers are
|
|
446
|
+
persistent.
|
|
447
|
+
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine (per chat/native
|
|
448
|
+
and thinking level) and is reset only on a model switch, a tools change or a profile change. The stable part
|
|
449
|
+
(base prompt, identity, memories, AGENT.md, memory indexes) comes before the volatile tail (model, working
|
|
450
|
+
directory, session). prompt-caching.md covers this in detail.
|
|
451
|
+
|
|
452
|
+
## Key files
|
|
453
|
+
|
|
454
|
+
Paths are under `lib/samagotchi/`.
|
|
455
|
+
|
|
456
|
+
| File | Role |
|
|
457
|
+
|---|---|
|
|
458
|
+
| `engine.rb` | Engine: turn lifecycle, event emission, hooks runtime, plugins, model switching |
|
|
459
|
+
| `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
|
|
460
|
+
| `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
|
|
461
|
+
| `steer.rb` | Queued input and steers at iteration boundaries |
|
|
462
|
+
| `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
|
|
463
|
+
| `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion) |
|
|
464
|
+
| `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
|
|
465
|
+
| `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
|
|
466
|
+
| `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
|
|
467
|
+
| `tool_runner.rb` | Per-call path (gate, start/complete events, output cap, images, diffs) |
|
|
468
|
+
| `tool_response.rb` | Per-batch dispatch and tool_response entries (joined for native, single for chat) |
|
|
469
|
+
| `empty_answer_retry.rb` | The empty-answer and cut retry budget |
|
|
470
|
+
| `prompt_warmup.rb` | The turn-end warm-up |
|
|
471
|
+
| `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
|
|
472
|
+
| `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
|
|
473
|
+
| `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
|
|
474
|
+
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
|
|
475
|
+
| `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
|
|
476
|
+
| `cancellation_controller.rb` | A turn's cancel signal, and a generation's child controller |
|
|
477
|
+
| `session.rb` | Session model (status, messages, persistence) |
|
|
478
|
+
| `session_manager.rb` | Background session processes (workers) and their IPC |
|
|
@@ -60,9 +60,30 @@ right-hand column happens.
|
|
|
60
60
|
#2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
|
|
61
61
|
a breakpoint. The split point comes from `SystemPrompt#stable_length` via the message's `cache_split:`, which
|
|
62
62
|
never goes on the wire.
|
|
63
|
-
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
|
|
64
|
+
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
|
65
|
+
|
|
66
|
+
## The turn-end warm-up
|
|
67
|
+
|
|
68
|
+
The next turn's prompt differs from the turn's last request at its tail: the previous answer's thinking is
|
|
69
|
+
stripped (the models' specs require it). A hybrid model on llama.cpp can only resume from a restore point, so the
|
|
70
|
+
next turn prefills from well before the end, more with every turn. `PromptWarmup` moves that work into the time
|
|
71
|
+
the user reads: after a completed turn, `Engine#warm_up_next_turn` formats the next prompt up to where the new
|
|
72
|
+
user message starts (`KernelLoop#warmup_prompt`) and sends it in a thread (`Client#warm_up`: native
|
|
73
|
+
`/completion`, `n_predict: 1`, not streamed, no retries, log purpose `warmup`, then a `model warmup` line with
|
|
74
|
+
`cached=`, `prefilled=` and `prefill_ms=`).
|
|
75
|
+
|
|
76
|
+
- Only on a local llama.cpp host on the native loop, with `cache.warmup: auto` (the default). Never a remote or
|
|
77
|
+
paid host, never the chat loop (Splash keeps its own cache; llama.cpp's chat path is not measured).
|
|
78
|
+
- Not after a failed, cancelled or step-limited turn, nor when a reminder is due, a steer is carried or input
|
|
79
|
+
waits (the next turn starts at once), nor in a `--non-interactive` run.
|
|
80
|
+
- **Slot rule.** The warm-up runs on the slot the turn's last request used (`id_slot` from its stream). The next
|
|
81
|
+
request is pinned to that slot only while the warm-up still runs, so it queues behind it; once the warm-up is
|
|
82
|
+
done nothing is pinned. Pinning across an idle gap is the one thing that hurts: a pinned request to a slot the
|
|
83
|
+
server has since cleared skips its prompt cache and prefills everything (measured 0.5 s → 12 s). For the same
|
|
84
|
+
reason `id_slot` is a reserved sampling key.
|
|
85
|
+
- What breaks it (the warm-up simply misses): a system-prompt rebuild (`/thinking`, `/model`, a tool change),
|
|
86
|
+
the image window sliding, `!rollback`.
|
|
66
87
|
|
|
67
88
|
## Rules for changes
|
|
68
89
|
|
|
@@ -85,10 +106,15 @@ right-hand column happens.
|
|
|
85
106
|
|
|
86
107
|
- **Splash:** `--persistent-cache` (with `--max-cache-disk`) keeps restore points across restarts. Splash learns
|
|
87
108
|
a new branch point on the 2nd session, so the 3rd one gets the full reuse.
|
|
88
|
-
- **llama.cpp:**
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
- **llama.cpp:** `--cache-ram` (the host-memory prompt cache, 8 GiB by default in recent builds) is what keeps
|
|
110
|
+
sessions apart: when a task starts on a slot, the idle slots' state goes to that cache, and an unpinned request
|
|
111
|
+
restores the best match on whatever slot it gets. So a recap or another session between two turns costs
|
|
112
|
+
nothing (measured on Ornith: the next turn still reuses 11.9k of 12.3k tokens). Raise it for many or long
|
|
113
|
+
sessions (roughly 0.25–0.3 GB per 12k-token Ornith session). `--parallel` matters little; pinning a request to
|
|
114
|
+
a slot (`id_slot`) is the hazard. With `--cache-ram 0` or an old single-slot build, a recap does evict the
|
|
115
|
+
session. `--ctx-checkpoints` controls how many restore points a hybrid model keeps. chi sends
|
|
116
|
+
`cache_prompt: true` on every native request.
|
|
91
117
|
- **OpenRouter / Anthropic:** the default cache TTL is 5 minutes. Provider routing (which upstream serves a
|
|
92
118
|
request) also decides whether a cache is there to hit.
|
|
93
119
|
|
|
94
|
-
Still to come:
|
|
120
|
+
Still to come: image-window batches and longer remote TTLs.
|