samagotchi 0.65.0 → 0.67.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 +107 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +50 -28
- data/docs/cli.md +14 -2
- data/docs/configuration.md +101 -16
- data/docs/context.md +39 -2
- 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 +75 -30
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +36 -13
- data/docs/memory.md +1 -1
- data/docs/plugins.md +289 -25
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +16 -6
- data/docs/sub-agent.md +6 -1
- data/docs/testing.md +17 -3
- 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 +40 -302
- 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 +22 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- 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/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +23 -8
- 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/engine.rb +120 -107
- 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/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- 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/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +138 -156
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +5 -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/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +41 -15
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +43 -6
- 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 +28 -9
- 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 +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +52 -43
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +6 -4
- 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 +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- 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 +1 -1
- data/lib/samagotchi/web/public/session_state.js +17 -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_model.js +2 -2
- data/lib/samagotchi/web/public/stream_handlers.js +13 -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/toolkit_chip.js +29 -0
- 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/server.rb +0 -4
- data/lib/samagotchi/worker.rb +13 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +23 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Anthropic Messages (`api: anthropic`)
|
|
2
|
+
|
|
3
|
+
A host with `api: anthropic` (Anthropic itself, a gateway passing Claude through, a local server such as Splash,
|
|
4
|
+
Fireworks' Anthropic-compatible API) runs the same chat loop as `api: openai`: `LLM::Adapters::BY_API` maps both apis
|
|
5
|
+
to an adapter, `HostRegistry::HostEntry#chat?` is true for either, and `Engine#backend_for` picks `LLM::ChatLoop`
|
|
6
|
+
(engine.md, "The model↔tool loop"). Only the adapter differs: `LLM::AnthropicMessages` instead of `LLM::OpenAIChat`.
|
|
7
|
+
The user-facing settings (`dialect:`, `auth:`, `max_tokens`, the known limits) are in configuration.md.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
ChatLoop::Run#generate
|
|
11
|
+
→ ChatLoop#wire_messages # the OpenAI-shaped wire every chat host gets,
|
|
12
|
+
# plus anthropic_content on replayed model turns
|
|
13
|
+
→ AnthropicMessages#chat
|
|
14
|
+
→ #request_body # options as Messages takes them, breakpoints on or off
|
|
15
|
+
→ AnthropicWire.translate # system blocks + messages, cache_control placed
|
|
16
|
+
→ AnthropicWire.tools # function schemas → {name, description, input_schema}
|
|
17
|
+
→ POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
|
|
18
|
+
→ AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
|
|
19
|
+
# finish_reason, provider_content)
|
|
20
|
+
→ a 400 it can fix: #refusal_fix, then the same request again (once per kind)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Files
|
|
24
|
+
|
|
25
|
+
Paths are under `lib/samagotchi/llm/`.
|
|
26
|
+
|
|
27
|
+
| File | Role |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `adapters.rb` | `Adapters::BY_API` (`openai`, `anthropic`), `.for(entry)`, `.build(api:, …)` for side requests (`IdleClient`) |
|
|
30
|
+
| `anthropic_messages.rb` | `AnthropicMessages`: the request body, `#chat` (streamed or not), the 400 fixes, `#list_models`, `#thinking_fields`, `#replays_content?` |
|
|
31
|
+
| `anthropic_wire.rb` | `AnthropicWire`: ChatLoop's wire messages as Messages' `system` and `messages`, the two breakpoints, tool ids and names |
|
|
32
|
+
| `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
|
|
33
|
+
| `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
|
|
34
|
+
| `prompt_cache.rb` | `PromptCache.control`: the `cache_control` a breakpoint carries (`cache.ttl`) |
|
|
35
|
+
| `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`) |
|
|
36
|
+
|
|
37
|
+
## The adapter
|
|
38
|
+
|
|
39
|
+
`AnthropicMessages.for(entry)` takes the host's API root, key variable, `auth:`, `dialect:` and remoteness;
|
|
40
|
+
`HostRegistry#adapter_for` keeps one per host for the worker's life, so what it learns from a 400 (below) lasts that
|
|
41
|
+
long. Each request posts `{base}/v1/messages` with `anthropic-version: 2023-06-01`, the key in the header the host's
|
|
42
|
+
`auth:` names (else the dialect's: `x-api-key` on the standard one), and `Session-Id` (plus the dialect's
|
|
43
|
+
`session_header`) when the loop has a session id.
|
|
44
|
+
|
|
45
|
+
`#request_body` builds, in order: `model`, `max_tokens`, `system` (when there is one), `messages`, `stream`, `tools`
|
|
46
|
+
with `tool_choice: {type: "auto"}` (when any), then the loop's options. The options lose chi's OpenAI-only fields
|
|
47
|
+
(`DROPPED_OPTIONS`: `reasoning_effort`, `chat_template_kwargs`, `stream_options`, `prompt_cache_key`, `logprobs`,
|
|
48
|
+
`top_logprobs`, `max_completion_tokens`); configured sampling passes as written. `max_tokens` is the options'
|
|
49
|
+
(`default.max_tokens`), else the model list's `max_tokens` capped at `DEFAULT_MAX_TOKENS` (64000), and never over an
|
|
50
|
+
output cap a 400 named. `#with_budget_room` keeps `max_tokens` above a budget-form `budget_tokens` plus `MIN_BUDGET`.
|
|
51
|
+
|
|
52
|
+
`#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}`; `low`, `medium`
|
|
53
|
+
and `high` are `thinking: {type: "adaptive", display: <dialect's>}` with `output_config: {effort: <level>}`; `default`
|
|
54
|
+
sends nothing. `#thinking_off_refused_fields` (low effort) is what `IdleClient` sends a side ask whose model refused
|
|
55
|
+
thinking off.
|
|
56
|
+
|
|
57
|
+
`#list_models` reads `GET {base}/v1/models` page by page (`limit=1000`, `after_id`, `has_more`/`last_id`, at most
|
|
58
|
+
`AdapterCommon::MAX_MODEL_PAGES`, as OpenAIChat does); `#window_of` takes `max_input_tokens`, else the OpenAI-shaped fields (`ModelListing`).
|
|
59
|
+
|
|
60
|
+
## Wire translation (`AnthropicWire.translate`)
|
|
61
|
+
|
|
62
|
+
ChatLoop builds the same OpenAI-shaped wire messages for every chat host (`#wire_messages`); the adapter translates
|
|
63
|
+
them:
|
|
64
|
+
|
|
65
|
+
- The head system message becomes the top-level `system`, as text blocks: with its `cache_split:` (from
|
|
66
|
+
`SystemPrompt#stable_length`) two blocks, the stable part first. A later system message (a reminder, a turn note,
|
|
67
|
+
the CONTEXT line) becomes user text: a mid-conversation system role is model-gated.
|
|
68
|
+
- An assistant message with saved blocks (`anthropic_content`) sends them verbatim, a `tool_use`'s name and id made
|
|
69
|
+
valid (`#valid_tool_use`); without them, a text block and its `tool_use` blocks (arguments parsed into the `input`
|
|
70
|
+
object, `{}` with a warning when they aren't a JSON object). An assistant message with neither is dropped.
|
|
71
|
+
- Tool messages become `tool_result` blocks in a user message. Ids are made what Messages takes
|
|
72
|
+
(`AnthropicWire.tool_id`: invalid characters replaced and a short hash appended, the same on both sides), and tool
|
|
73
|
+
names too (`.tool_name`), so a malformed one never fails every later request.
|
|
74
|
+
- User parts become text and `image` blocks (a data URL as base64, an http(s) URL as a url source).
|
|
75
|
+
- Consecutive messages of one role merge into one, its `tool_result` blocks first. Blank text blocks are never sent;
|
|
76
|
+
a user message left empty says `(empty)`. `reasoning_content` and `cache_split` never go out.
|
|
77
|
+
|
|
78
|
+
## The response (`AnthropicAssembly`)
|
|
79
|
+
|
|
80
|
+
A stream is read from its `data:` lines alone (a gateway that drops the `event:` lines works), by content-block
|
|
81
|
+
index: `message_start` (served model, usage), `content_block_start` (text, thinking, redacted_thinking, tool_use),
|
|
82
|
+
`content_block_delta` (`text_delta`, `thinking_delta`, `signature_delta`, `input_json_delta`), `message_delta`
|
|
83
|
+
(stop reason, `stop_details`, usage). `AnthropicMessages#chat_streamed` passes every event but `ping` to `on_delta`,
|
|
84
|
+
`message_start` and `message_delta` with the usage so far attached (`#with_usage`); a block's start or delta counts as shown for the first-token limit
|
|
85
|
+
(`.shows?`). A 200 whose body is a JSON error instead of a stream raises that error (`#raise_plain_error`).
|
|
86
|
+
|
|
87
|
+
`#response` is a `ChatResponse`:
|
|
88
|
+
|
|
89
|
+
- `provider_content`: the blocks as received (string keys). A blank text block is dropped (Messages refuses one sent
|
|
90
|
+
back); a `tool_use` input is an object; a thinking block always has a `signature` (`""` when none came).
|
|
91
|
+
- `finish_reason` from `FINISH_REASONS`: `end_turn`/`stop_sequence` → `stop`, `max_tokens` and
|
|
92
|
+
`model_context_window_exceeded` → `length`, `tool_use` → `tool_calls`, `refusal` → `refusal`; anything else as it is.
|
|
93
|
+
- Usage in the OpenAI shape the rest of chi reads (`TokenUsage`, `SessionMetrics`, `ContextStatus`, the web's
|
|
94
|
+
`ctx.js`): Messages counts `input_tokens` without the cache, so `prompt_tokens` is input + cache reads + cache
|
|
95
|
+
writes, with `cached_tokens` and `cache_write_tokens` under `prompt_tokens_details`. Every field is optional and the
|
|
96
|
+
later value wins; all zeros is no usage (`#usage_payload`: Fireworks' `message_start` carries only zeros).
|
|
97
|
+
|
|
98
|
+
## Replaying thinking blocks
|
|
99
|
+
|
|
100
|
+
Messages wants a model turn's blocks back as they came: a thinking block with its signature, in its place among the
|
|
101
|
+
text and `tool_use` blocks (a tool loop with thinking needs the last assistant turn's). So:
|
|
102
|
+
|
|
103
|
+
- `ChatLoop::Run#with_thinking` saves the response's `provider_content` on the model entry as `anthropic_content`,
|
|
104
|
+
beside `thinking:` (string keys, so a resumed session sends the same bytes).
|
|
105
|
+
- `AnthropicMessages#replays_content?` is true, and `ChatLoop#replay_blocks` puts those blocks on the wire message,
|
|
106
|
+
which `AnthropicWire` sends verbatim. Not for an interrupted entry (its partial blocks have no signature) nor for
|
|
107
|
+
one with an unpaired call (it goes as flattened text). Before the reasoning boundary (`#reasoning_boundary`) the
|
|
108
|
+
thinking blocks are left out, so only the front of the history loses them; the conversation's last model entry,
|
|
109
|
+
when it has tool calls, keeps them even there (a run stopped between steps left its `tool_use` last).
|
|
110
|
+
- `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
|
|
111
|
+
run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
|
|
112
|
+
(`Run#generate`: it would resend the same body).
|
|
113
|
+
- A 400 about a thinking block's signature (`BadRequest#thinking_signature?`, a Claude 5.x block bound to a
|
|
114
|
+
different conversation) fails the turn for now; configuration.md has the known limit.
|
|
115
|
+
|
|
116
|
+
## Cache breakpoints
|
|
117
|
+
|
|
118
|
+
`#request_body` asks `AnthropicWire.translate` for breakpoints (`cache: PromptCache.control`, carrying `ttl: "1h"`
|
|
119
|
+
under `cache.ttl: 1h`) only for a chat request (`purpose: "chat"`; a recap, `ctx.ask_model` or a triage sets none),
|
|
120
|
+
on a dialect with `cache_breakpoints`, and until the host refused `cache_control`. Unlike `PromptCache.mark` on the
|
|
121
|
+
OpenAI path, it doesn't look at the model id or remoteness. The two marks:
|
|
122
|
+
|
|
123
|
+
1. the first `system` block (with `cache_split:`, the stable part; the per-session tail goes after it unmarked), which
|
|
124
|
+
caches the tools and the system prompt for every session on that prefix;
|
|
125
|
+
2. the last user block that can carry one (`#mark_last`): never a thinking block, nor one that came from a later
|
|
126
|
+
system message, which moves every request.
|
|
127
|
+
|
|
128
|
+
The log line's `cache=` says `on`, `1h` or nothing (`#cache_summary`). prompt-caching.md has the cache rules.
|
|
129
|
+
|
|
130
|
+
## Dialects
|
|
131
|
+
|
|
132
|
+
`LLM::Dialect` is a host's `dialect:` (`Dialect.parse` warns once on an unknown name or on a host whose api isn't
|
|
133
|
+
`anthropic`): `auth` (the default key header), `thinking_display` (the `display` sent with adaptive or budget
|
|
134
|
+
thinking, nil for none), `cache_breakpoints` and `session_header`.
|
|
135
|
+
|
|
136
|
+
| Dialect | auth | thinking display | breakpoints | session header |
|
|
137
|
+
|---|---|---|---|---|
|
|
138
|
+
| `STANDARD` (no `dialect:`) | `x-api-key` | `summarized` | yes | none |
|
|
139
|
+
| `FIREWORKS` | Bearer | none | no (an automatic prefix cache) | `x-session-affinity` |
|
|
140
|
+
|
|
141
|
+
## 400 fallbacks
|
|
142
|
+
|
|
143
|
+
`AnthropicMessages#chat` handles the 400s it can fix itself, once each per request, and remembers the fix for the
|
|
144
|
+
adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logged under `http`; kept in an
|
|
145
|
+
`LLM::RefusalMemo`, locked, since the turn, side-ask and recap threads share the host's adapter):
|
|
146
|
+
|
|
147
|
+
| 400 about | Fix | Remembered |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `thinking.display` (`DISPLAY_RE`) | no `display` from now on (`#without_display`), the thinking kept | per host (`thinking_display_refused`) |
|
|
150
|
+
| adaptive thinking or its effort (`ADAPTIVE_REFUSED_RE`) | the budget form: `thinking: {type: "enabled", budget_tokens: BUDGETS[level]}`, no effort (`#budget_form`) | per model (`thinking_form_switched`) |
|
|
151
|
+
| `cache_control` | no breakpoints | per host (`cache_control_refused`) |
|
|
152
|
+
| `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
|
|
153
|
+
|
|
154
|
+
The display check comes before the adaptive one: its path (`thinking.adaptive.display`) names adaptive too. A context
|
|
155
|
+
overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these: it goes on as it is,
|
|
156
|
+
to the loop's own handling (ChatLoop's sampling and thinking-fields fallbacks) or to the turn's failure.
|
|
@@ -18,7 +18,7 @@ Recommended workflow:
|
|
|
18
18
|
Behavior:
|
|
19
19
|
|
|
20
20
|
- `execute`, `task_create` and the `!cmd` shell bang run the command in a non-login shell: `/bin/sh -c` on Linux; on macOS, when `/bin/zsh` is there, `/bin/zsh --emulate sh +o ignore_braces +o sh_glob -o bash_rematch +o bsd_echo -c` (macOS's `/bin/sh` is bash 3.2, which can't parse an apostrophe in a heredoc inside `"$( )"`, the usual commit message shape). That mode reads no rc files and keeps POSIX word splitting, unmatched globs left as is, `{1..3}`, `[[ … =~ (…) ]]` with `BASH_REMATCH`, arrays, `$'…'`, `&>` and echo expanding `\t`. Bash-only builtins and variables (`shopt`, `BASH_VERSION`, `${!prefix*}`, `read -p`) are not there.
|
|
21
|
-
- Task metadata and output are persisted under `tmp/tasks
|
|
21
|
+
- Task metadata and output are persisted under `tmp/tasks/`: each task's `tmp/tasks/<id>/task.json` is a `TaskRecord` (`lib/samagotchi/tools/task_record.rb`, a value object: `.from_file` reads it, `#to_file` is the JSON object written, an update is a `#with`). Its keys keep their order; `stderr_path` and `handed_over_by` (a handed-over execute) and `stop_requested_by` (a stop under way or done) are left out until set. `bundles/slow-commands` reads these files on its own, so the shape is a contract.
|
|
22
22
|
- A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's idle exit) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
|
|
23
23
|
- A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
|
|
24
24
|
- Task listing is workspace-scoped (current project only).
|
data/docs/internals/bundles.md
CHANGED
|
@@ -35,6 +35,8 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
|
|
|
35
35
|
| `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
|
|
36
36
|
| `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
|
|
37
37
|
| `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
|
|
38
|
+
| `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
|
|
39
|
+
| `Placeholder` | `{{placeholder}}` patterns left in an installed memory, one line per file from `Installer#detect_placeholders`; a hint, never a gate |
|
|
38
40
|
|
|
39
41
|
Readers of the installed state: `Hooks::BundleLoader` (Engine), `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
|
|
40
42
|
`ContextProviders` (scripts), `BundleNeeds` (index markers). Each goes through `Provenance.each_installed` / `#record`
|
data/docs/internals/engine.md
CHANGED
|
@@ -35,7 +35,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
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
37
|
│ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
|
|
38
|
-
│ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools
|
|
38
|
+
│ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
|
|
39
|
+
│ # then the session's toolkit (apply_toolkit!: Registry#toolkit, a prompt rebuild when it changed)
|
|
39
40
|
│ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
|
|
40
41
|
│ # (a frozen copy of the history, the prompt, and resumed)
|
|
41
42
|
│ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
|
|
@@ -71,7 +72,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
71
72
|
│
|
|
72
73
|
└── ensure: release_turn(probe_cancel_before)
|
|
73
74
|
# TurnState#finish! (turn flag off, controller, sink and steers cleared)
|
|
74
|
-
#
|
|
75
|
+
# SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
|
|
75
76
|
# probe cancel swapped back
|
|
76
77
|
# the context window cache dropped, so the next turn asks the server again
|
|
77
78
|
# steers left logged as dropped, activity recorded, turn-scoped hooks cleared
|
|
@@ -82,7 +83,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
|
|
|
82
83
|
```
|
|
83
84
|
StandardError in run_turn:
|
|
84
85
|
→ if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
|
|
85
|
-
→ else: end_turn(turn, "failed", save: true)
|
|
86
|
+
→ else: end_turn(turn, "failed", save: true) (a save that fails there is logged, turn_end_save_failed)
|
|
86
87
|
# Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
|
|
87
88
|
# failed_messages keeps the loop's partial conversation (tool iterations included),
|
|
88
89
|
# else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
|
|
@@ -134,7 +135,8 @@ Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
|
|
|
134
135
|
```
|
|
135
136
|
|
|
136
137
|
Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
|
|
137
|
-
cancel_controller:, model_name:, max_tool_output_chars:, pending_input:)
|
|
138
|
+
cancel_controller:, model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)`; `reasoning_since:` is
|
|
139
|
+
where the run a step-limit Continue resumes started, the Engine's `resume_from`). `NativeBackend#complete` only calls
|
|
138
140
|
`KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
|
|
139
141
|
whose tools, hooks, gate and dispatch it uses.
|
|
140
142
|
|
|
@@ -171,17 +173,21 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
|
|
|
171
173
|
`turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
|
|
172
174
|
(`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; a Ctrl-C does not (see Error paths).
|
|
173
175
|
|
|
174
|
-
### ChatLoop (the
|
|
176
|
+
### ChatLoop (the chat APIs)
|
|
175
177
|
|
|
176
|
-
Runs hosts
|
|
177
|
-
|
|
178
|
+
Runs hosts whose api has a chat adapter (`LLM::Adapters::BY_API`): `api: openai` on `LLM::OpenAIChat` (remote
|
|
179
|
+
providers such as OpenRouter, Claude among others, and any OpenAI-compatible chat server) and `api: anthropic` on
|
|
180
|
+
`LLM::AnthropicMessages` (Anthropic's Messages API; [anthropic.md](anthropic.md)). The Engine sets the adapter per
|
|
181
|
+
turn for the effective model's host (`HostRegistry#adapter_for`). The model gets the tools as JSON schemas and returns
|
|
182
|
+
structured tool calls.
|
|
178
183
|
|
|
179
184
|
```
|
|
180
185
|
ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
|
|
181
|
-
model_name:, max_tool_output_chars:, pending_input:)
|
|
186
|
+
model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
|
|
182
187
|
│
|
|
183
188
|
│ conversation = Array(messages).map(&:dup) # a shallow copy of each message
|
|
184
|
-
│ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input
|
|
189
|
+
│ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input,
|
|
190
|
+
│ start: reasoning_since)
|
|
185
191
|
│ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
186
192
|
│
|
|
187
193
|
└──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
|
|
@@ -190,10 +196,13 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
|
|
|
190
196
|
The differences from the native loop:
|
|
191
197
|
|
|
192
198
|
- The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
|
|
193
|
-
- The conversation is converted to the OpenAI wire format (`wire_messages`)
|
|
199
|
+
- The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
|
|
200
|
+
`AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
|
|
194
201
|
- The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
|
|
195
|
-
as `stable_length:`), where the prompt's stable part ends.
|
|
196
|
-
|
|
202
|
+
as `stable_length:`), where the prompt's stable part ends. The adapter puts the first cache breakpoint there:
|
|
203
|
+
`OpenAIChat` for a Claude model on a remote host (`LLM::PromptCache.mark`), `AnthropicMessages` on every chat
|
|
204
|
+
request unless its dialect has no breakpoints or the host refused `cache_control` (`AnthropicWire.translate`,
|
|
205
|
+
`Dialect#cache_breakpoints`); see prompt-caching.md and anthropic.md.
|
|
197
206
|
- Each call's result is its own tool message, paired by `tool_call_id`.
|
|
198
207
|
- A model turn's saved reasoning (`thinking:`) goes back as `reasoning_content` per the turn's
|
|
199
208
|
`TurnSettings#reasoning_history` (`ReasoningHistory.resolve`, the session's own level first:
|
|
@@ -207,8 +216,15 @@ The differences from the native loop:
|
|
|
207
216
|
in `#resumed_run_start`), past a user entry added after that run's last reply (a `!cmd`'s output). Without
|
|
208
217
|
`since:` (`#reasoning_chars` from outside a Run) the boundary is the last query (a `role: "user"` or `turn_start`
|
|
209
218
|
entry). `Run#prompt_chars` counts it (`#reasoning_chars`).
|
|
219
|
+
- On an adapter that sends its blocks back (`#replays_content?`: `AnthropicMessages`), a model turn's saved blocks
|
|
220
|
+
(`anthropic_content`, the response's `provider_content`, saved by `Run#with_thinking`) go back verbatim, thinking
|
|
221
|
+
blocks with their signatures (`#replay_blocks`): not for an interrupted entry or one with an unpaired call, and
|
|
222
|
+
without the thinking blocks before the reasoning boundary (the last model entry with tool calls keeps them).
|
|
223
|
+
`#reasoning_history` floors `drop` at `turn` there, since Messages needs the running run's thinking blocks.
|
|
210
224
|
- A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
|
|
211
|
-
without it
|
|
225
|
+
without it (not on a replaying adapter, where the body would be the same); when that goes through,
|
|
226
|
+
`#reasoning_history_refused!` marks the model (worker lifetime; the loop's `LLM::RefusalMemo`, as for the thinking
|
|
227
|
+
and sampling refusals) and
|
|
212
228
|
`:reasoning_history_refused` goes out. When it is refused too, the thinking-fields fallback (`#thinking_refused!`,
|
|
213
229
|
`:thinking_refused`) runs on it, still without reasoning. Up to three requests per step. A 400 about a thinking
|
|
214
230
|
block's signature (`BadRequest#thinking_signature?`) or a refused binding control
|
|
@@ -227,6 +243,8 @@ One iteration with tool calls:
|
|
|
227
243
|
→ :tool_dispatch_started {iteration, call_count}
|
|
228
244
|
→ for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
|
|
229
245
|
on_stream_event:, max_tool_output_chars:)
|
|
246
|
+
→ a tool the session's toolkit leaves out (Registry#left_out) skips the gate: no hooks, no card;
|
|
247
|
+
KernelLoop#dispatch answers it with the not-loaded error
|
|
230
248
|
→ Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
|
|
231
249
|
→ :tool_call_started
|
|
232
250
|
→ an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
|
|
@@ -253,7 +271,7 @@ One iteration with tool calls:
|
|
|
253
271
|
`EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
|
|
254
272
|
read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
|
|
255
273
|
that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
|
|
256
|
-
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `
|
|
274
|
+
`refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
|
|
257
275
|
once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
|
|
258
276
|
`Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
|
|
259
277
|
(`KernelLoop#session_cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
|
|
@@ -314,7 +332,8 @@ The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
|
|
|
314
332
|
|
|
315
333
|
```ruby
|
|
316
334
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
317
|
-
:settings, :id, :limit) do
|
|
335
|
+
:settings, :id, :limit, :resumed, :resume_from) do
|
|
336
|
+
def resumed? = resumed ? true : false # a step-limit Continue
|
|
318
337
|
def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
|
|
319
338
|
def elapsed = ... # seconds since started_at
|
|
320
339
|
end
|
|
@@ -339,10 +358,14 @@ end
|
|
|
339
358
|
running turn's record so far as a killed turn's (canceled, `worker_stopped`), which the next worker loads; the
|
|
340
359
|
turn's end writes over it.
|
|
341
360
|
|
|
342
|
-
### GenerationPhase
|
|
361
|
+
### SteerCut, GenerationPhase and WaitingSteer
|
|
343
362
|
|
|
344
|
-
|
|
345
|
-
|
|
363
|
+
The Engine holds one `SteerCut` (`@steer_cut`), which owns a `GenerationPhase` and a `WaitingSteer`: the Bridge and
|
|
364
|
+
the TUI ask it (`Engine#cut_for_steer`, `#input_epoch`, `#generation_snapshot`), the Engine's stream handler feeds it
|
|
365
|
+
(`SteerCut#observe`), a drain that took input calls `#delivered!`, and `release_turn` calls `#finished!`.
|
|
366
|
+
|
|
367
|
+
`GenerationPhase` is what the running generation streams, fed through `SteerCut#observe`: whether a steer sent as a
|
|
368
|
+
cut (delivery `cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
|
|
346
369
|
Bridge acks it as `cut`.
|
|
347
370
|
A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
|
|
348
371
|
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!`.
|
|
@@ -354,8 +377,8 @@ line and tell whether "cut in now" can land.
|
|
|
354
377
|
### WaitingSteer
|
|
355
378
|
|
|
356
379
|
A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
|
|
357
|
-
waits until the thinking passes the model's cut wait (
|
|
358
|
-
the message to the model at a boundary, or until the turn ends (`
|
|
380
|
+
waits until the thinking passes the model's cut wait (`SteerCut#observe` rechecks it on each thinking chunk), until a
|
|
381
|
+
drain hands the message to the model at a boundary, or until the turn ends (`SteerCut#finished!` clears it).
|
|
359
382
|
|
|
360
383
|
## Events
|
|
361
384
|
|
|
@@ -411,15 +434,20 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
|
|
|
411
434
|
|
|
412
435
|
```
|
|
413
436
|
Engine initialization (in this order, among other things):
|
|
414
|
-
@turn_state, @
|
|
437
|
+
@turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
|
|
415
438
|
@host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
|
|
416
439
|
@given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
|
|
440
|
+
@hook_routes (HookRoutes: notices, hook questions, stop_turn/stop_generation, steer attribution)
|
|
417
441
|
@guardrail_wiring, @question_desk
|
|
442
|
+
@toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
|
|
418
443
|
@extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
|
|
419
|
-
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins)
|
|
444
|
+
@tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins),
|
|
445
|
+
then @tool_index.declare(@tools): find_tools + call_tool (source "index", not core) when a plugin
|
|
446
|
+
registered a chi.tool_source (Tools::ToolIndex: the index's groups, search, call checks; the source runs
|
|
447
|
+
the call with its own plugin Context)
|
|
420
448
|
wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
|
|
421
449
|
@kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
|
|
422
|
-
@hooks.runtime =
|
|
450
|
+
@hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
|
|
423
451
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
424
452
|
@prompt_builder = SystemPrompt.new(...)
|
|
425
453
|
@session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
|
|
@@ -441,7 +469,7 @@ Model probes (per turn):
|
|
|
441
469
|
|
|
442
470
|
Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
|
|
443
471
|
puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
|
|
444
|
-
`Hooks::Runtime
|
|
472
|
+
`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`, ...).
|
|
445
473
|
|
|
446
474
|
| Helper | What it does | Used by |
|
|
447
475
|
|---|---|---|
|
|
@@ -548,8 +576,10 @@ Both loops:
|
|
|
548
576
|
- **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
|
|
549
577
|
and tool call hooks.
|
|
550
578
|
|
|
551
|
-
Native loop only: the Qwen open-block recovery, and the thinking prefill (
|
|
552
|
-
thought after the cue,
|
|
579
|
+
Native loop only: the Qwen open-block recovery, and the thinking prefill (`Thinking.native`: with thinking off, Qwen
|
|
580
|
+
gets an empty thought after the cue, `QWEN_EMPTY_THOUGHT`, and Gemma 4 its empty thought channel,
|
|
581
|
+
`GEMMA_EMPTY_THOUGHT`, with no `<|think|>` token before the system prompt; kept in the model message so the next
|
|
582
|
+
prompt starts with what the server has cached).
|
|
553
583
|
|
|
554
584
|
## Key design principles
|
|
555
585
|
|
|
@@ -563,8 +593,9 @@ thought after the cue, kept in the model message so the next prompt starts with
|
|
|
563
593
|
persistent.
|
|
564
594
|
5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
|
|
565
595
|
`[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
|
|
566
|
-
only on a model switch, a tools change
|
|
567
|
-
|
|
596
|
+
only on a model switch, a tools change, a profile change or a toolkit change (`Engine#apply_toolkit!`). The
|
|
597
|
+
stable part (base prompt, the toolkit's not-loaded line, rg guidance, identity, model notes, preloaded memories,
|
|
598
|
+
AGENTS.md, appended files, memory indexes) comes before the volatile tail (model, working directory, session).
|
|
568
599
|
prompt-caching.md covers this in detail.
|
|
569
600
|
|
|
570
601
|
## Key files
|
|
@@ -573,12 +604,22 @@ Paths are under `lib/samagotchi/`.
|
|
|
573
604
|
|
|
574
605
|
| File | Role |
|
|
575
606
|
|---|---|
|
|
576
|
-
| `engine.rb` | Engine: turn lifecycle, event emission,
|
|
607
|
+
| `engine.rb` | Engine: turn lifecycle, event emission, plugins, model switching |
|
|
577
608
|
| `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
|
|
609
|
+
| `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
|
|
578
610
|
| `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
|
|
611
|
+
| `steer_cut_after.rb`, `cut_policy.rb` | The model's cut wait (`SteerCutAfter.resolve`); the loops' cut path (`CutPolicy`) |
|
|
579
612
|
| `steer.rb` | Queued input and steers at iteration boundaries |
|
|
580
613
|
| `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
|
|
581
|
-
| `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion) |
|
|
614
|
+
| `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion, block replay) |
|
|
615
|
+
| `llm/adapters.rb` | `LLM::Adapters::BY_API`: which chat adapter a host's api gets |
|
|
616
|
+
| `llm/openai_chat.rb` | `LLM::OpenAIChat`, the `/v1/chat/completions` adapter |
|
|
617
|
+
| `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) |
|
|
618
|
+
| `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)) |
|
|
619
|
+
| `llm/prompt_cache.rb` | Cache breakpoints on the OpenAI path, and the `cache_control` both adapters send |
|
|
620
|
+
| `llm/turn_settings.rb` | `LLM::TurnSettings`, what a turn runs under (vision, sampling, thinking, reasoning history, …) |
|
|
621
|
+
| `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) |
|
|
622
|
+
| `reasoning_history.rb` | `ReasoningHistory`: the levels, `.resolve`, `.run_start` |
|
|
582
623
|
| `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
|
|
583
624
|
| `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
|
|
584
625
|
| `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
|
|
@@ -588,7 +629,11 @@ Paths are under `lib/samagotchi/`.
|
|
|
588
629
|
| `prompt_warmup.rb` | The turn-end warm-up |
|
|
589
630
|
| `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
|
|
590
631
|
| `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
|
|
632
|
+
| `prompt_files.rb`, `model_notes.rb` | The AGENTS.md chain and appended files; the model notes the prompt loads |
|
|
633
|
+
| `toolkit.rb`, `toolkit_command.rb` | A session's toolkit (`Toolkit#resolve`, what `Registry` declares); `/toolkit` |
|
|
634
|
+
| `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
|
|
591
635
|
| `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
|
|
636
|
+
| `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 |
|
|
592
637
|
| `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
|
|
593
638
|
| `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
|
|
594
639
|
| `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
|
|
|
@@ -22,11 +23,15 @@ right-hand column happens.
|
|
|
22
23
|
```
|
|
23
24
|
shared by every session ────────────────────────────────────────────────── breaks it
|
|
24
25
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
25
|
-
│ tools built-ins, then plugin tools sorted by (bundle, name)
|
|
26
|
-
│
|
|
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) │
|
|
29
|
+
│ those the session's toolkit declares (per toolkit) │ /model, a profile change,
|
|
30
|
+
│ (chat: the `tools` field; native: the model's template) │ a toolkit change
|
|
27
31
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
28
32
|
│ SYSTEM, stable part SystemPrompt#system_prompt_with_index
|
|
29
33
|
│ [native thinking token] base prompt │
|
|
34
|
+
│ not-loaded line (only when the toolkit leaves tools out) │
|
|
30
35
|
│ rg guidance │ /thinking level,
|
|
31
36
|
│ system identity (+ model overlay) │ /model, editing a
|
|
32
37
|
│ model notes (model_notes_* matching the model) │ memory / AGENTS.md
|
|
@@ -34,7 +39,7 @@ right-hand column happens.
|
|
|
34
39
|
│ AGENTS.md (root → cwd) │ (picked up by a new
|
|
35
40
|
│ appended files (append_system_prompt:, --append-system-prompt) │ process or a rebuild)
|
|
36
41
|
│ project + system memory indexes │
|
|
37
|
-
│
|
|
42
|
+
│ ◄── remote Claude / Messages: cache_control breakpoint #1 │
|
|
38
43
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
39
44
|
│ SYSTEM, volatile tail (per session, least → most changing) │
|
|
40
45
|
│ Model: this session runs on … │ another session
|
|
@@ -46,22 +51,37 @@ right-hand column happens.
|
|
|
46
51
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
47
52
|
│ history user / assistant / tool messages, APPEND-ONLY │ !rollback, a failed or
|
|
48
53
|
│ (images: up to the newest N, cut in batches; ImagePlan) │ cancelled turn's tail,
|
|
49
|
-
│
|
|
50
|
-
│
|
|
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
|
|
51
56
|
├──────────────────────────────────────────────────────────────────────────┤ sanitizing (thinking
|
|
52
57
|
│ tail notes reminders, turn notes, steers (system/user messages) │ stripped per spec)
|
|
53
58
|
│ new prompt this turn's user message │
|
|
54
59
|
└──────────────────────────────────────────────────────────────────────────┘
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
- The system prompt is built **once per
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
67
|
+
- The tools layer is shared **per toolkit** (configuration.md, "Toolkits"): sessions on the same toolkit share it,
|
|
68
|
+
and a chain's links keep theirs. A session's toolkit is saved when it is created and never resolved again from
|
|
69
|
+
config. A change (`/toolkit`, `ctx.use_toolkit`) is applied only at a turn's start (`Engine#apply_toolkit!`,
|
|
70
|
+
beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request, so a mid-turn change
|
|
71
|
+
would differ from the turn's prompt. Under `full` nothing is filtered and no line is added: the bytes are what
|
|
72
|
+
they were before toolkits.
|
|
60
73
|
- On remote Claude, `LLM::PromptCache.mark` sends the system message as two text parts. The stable part carries
|
|
61
74
|
breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
|
|
62
75
|
#2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
|
|
63
76
|
a breakpoint. The split point comes from `SystemPrompt#stable_length` via the message's `cache_split:`, which
|
|
64
|
-
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.
|
|
65
85
|
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
|
|
66
86
|
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
|
67
87
|
|
|
@@ -107,14 +127,17 @@ is prefilled once. The native and the chat loop use the same plan.
|
|
|
107
127
|
one provider. `cache.key: session` also sends the session id as `prompt_cache_key` in the body, the field
|
|
108
128
|
OpenAI's API routes and caches by (OpenRouter honours it too); the log line shows `cache_by=session`. Only
|
|
109
129
|
`api.openai.com` and OpenRouter get it, and only the session's own chat requests (not recap or `/btw`): a local
|
|
110
|
-
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).
|
|
111
134
|
|
|
112
135
|
## Reading the numbers
|
|
113
136
|
|
|
114
137
|
- The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
|
|
115
138
|
server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
|
|
116
139
|
request's prompt on the same model, what was prefilled again). The `http` line of a
|
|
117
|
-
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.
|
|
118
141
|
- The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
|
|
119
142
|
- `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
|
|
120
143
|
- A healthy session: from the second request on `cached=` is close to `prompt=`; a second fresh session in the
|
data/docs/memory.md
CHANGED
|
@@ -270,7 +270,7 @@ profiles, meta bundles that install a set of them:
|
|
|
270
270
|
| profile | bundles |
|
|
271
271
|
|---|---|
|
|
272
272
|
| `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
|
|
273
|
-
| `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer |
|
|
273
|
+
| `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer, codex-peer |
|
|
274
274
|
|
|
275
275
|
`chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
|
|
276
276
|
install gets neither by itself: `chi bundle install core` (or `dev`).
|