samagotchi 0.77.0 → 0.79.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 +40 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +14 -8
- data/docs/internals/anthropic.md +80 -7
- data/docs/internals/engine.md +3 -2
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +2 -2
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +2 -2
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +74 -17
- data/lib/samagotchi/llm/anthropic_wire.rb +29 -1
- data/lib/samagotchi/llm/chat_loop.rb +61 -5
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/question_desk.rb +10 -3
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +2 -3
- data/lib/samagotchi/session_manager.rb +0 -1
- data/lib/samagotchi/session_metrics/totals.rb +4 -3
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -53
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/index.html +27 -3
- data/lib/samagotchi/web/public/model_picker.js +4 -5
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/toasts.js +5 -4
- data/lib/samagotchi/worker.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc74f7069433e0f6be4252973309183e17c3ecc7ad06800684bf9be6fe40015f
|
|
4
|
+
data.tar.gz: 12a8d7e7c8002a8762d7752f6cae9bd20fae6565cb2d81636cc1ca9a6ff2469d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 004ef50c618ba8bb488adf9e5623da491691bf37850946870bb94511451d8e56876288232d0bae0212dcfc7d9a1c79544d4297ef0ab8692f5678ff3617fc144c
|
|
7
|
+
data.tar.gz: cb551afd6bdae21bfd61a4ff64ead0306816b41b88f4b5083fbf5e7d2afbb9ef9b9353d5d5750ccbfd1764fe10db8c60f8eb534a2cd4d728b04e3145f84dad4a
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,43 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.79.0] - 2026-10-17
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Web: a repeated toast cannot unstick a sticky error and make it time out.
|
|
16
|
+
- Anthropic: `api: anthropic` hosts on the Claude API now read their model list, including 1M-token windows and image
|
|
17
|
+
input, so `chi models` and model validation work.
|
|
18
|
+
- Anthropic: on Claude 5.x models, which turn thinking off by `between_tools` and refuse `disabled`, recaps, side
|
|
19
|
+
answers and `--thinking off` sessions now send `thinking: {type: "between_tools"}` after that 400 (a recap used to
|
|
20
|
+
fall back to adaptive thinking at low effort, a `--thinking off` session to the provider's default thinking). Each
|
|
21
|
+
side-answer client learns it once for itself.
|
|
22
|
+
- A host whose model list fails is asked again at most once a minute (it was asked on every context-window lookup).
|
|
23
|
+
|
|
24
|
+
## [0.78.0] - 2026-10-17
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Web: dragging the message box's top-edge grip down folds the box away for room to read; the info bar stays at the
|
|
29
|
+
bottom, and the grip stays on its top edge as a handle. Pull the handle up or click it, or just type on the page, to
|
|
30
|
+
bring the box back (a draft marks the handle in the accent, a session waiting on you in the attention colour), and
|
|
31
|
+
Stop turn moves into the info bar while it is folded. The choice is remembered per browser, and an archived session
|
|
32
|
+
opens folded.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Workers no longer load the terminal UI or Reline, saving about 50–100 ms and 2 MB per worker start; a plugin
|
|
37
|
+
that uses Reline, StringIO, Forwardable or Tempfile must now require it itself.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Claude 5.x on `api: anthropic`: a request whose earlier thinking the API refuses after the system prompt or tools
|
|
42
|
+
changed (a resume or respawn after memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`) no longer fails the
|
|
43
|
+
turn. chi asks again with Anthropic's binding controls (or without those thinking blocks), drops that thinking from
|
|
44
|
+
the session, and `/stats` counts it as `thinking dropped`.
|
|
45
|
+
|
|
46
|
+
Update with `chi update` (the system bundle 0.78.0), then restart `chi web`.
|
|
47
|
+
|
|
11
48
|
## [0.77.0] - 2026-10-17
|
|
12
49
|
|
|
13
50
|
### Changed
|
|
@@ -3245,7 +3282,9 @@ and long-lived sessions.
|
|
|
3245
3282
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3246
3283
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3247
3284
|
|
|
3248
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3285
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.79.0...HEAD
|
|
3286
|
+
[0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
|
|
3287
|
+
[0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
|
|
3249
3288
|
[0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
|
|
3250
3289
|
[0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
|
|
3251
3290
|
[0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
|
data/docs/cli.md
CHANGED
|
@@ -459,7 +459,8 @@ request (with the cached tokens, the cache writes, the re-prefilled tokens and t
|
|
|
459
459
|
reports them),
|
|
460
460
|
the decode speed, generation latency, cancellations, retries (the network's),
|
|
461
461
|
thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
|
|
462
|
-
(generations that ended at the provider's cap),
|
|
462
|
+
(generations that ended at the provider's cap), the thinking blocks a Claude 5.x preserved-thinking recovery dropped
|
|
463
|
+
(`thinking dropped:`, only when there were any; [configuration](configuration.md)), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the toolkit's prompt text (`kit prompt: coding (~20 tokens)`, only when its kit has one; [Toolkits](configuration.md#toolkits)), the prompt profile and the model the server says it ran, and the
|
|
463
464
|
cost when the provider reports one (OpenRouter's `usage.cost`), and the cost
|
|
464
465
|
estimated from a price under `hosts.<name>.models` when it reports none or 0,
|
|
465
466
|
marked `~` and naming the current model's host (`cost: $0.42 reported + ~$0.12 from hosts.gw.models prices (this session only)`;
|
data/docs/configuration.md
CHANGED
|
@@ -343,12 +343,16 @@ hosts:
|
|
|
343
343
|
|
|
344
344
|
`auth:` works on any host (`bearer` is the default everywhere but `api: anthropic`).
|
|
345
345
|
|
|
346
|
-
**
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
346
|
+
**Claude 5.x preserved thinking:** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each thinking
|
|
347
|
+
block to everything before it: the system prompt, the tools and the earlier messages. chi keeps the history
|
|
348
|
+
append-only, but the system prompt or the tools can change under a session: a resume or a worker respawn after
|
|
349
|
+
memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`, a plugin's tools, or an `llm_context` layer stubbing
|
|
350
|
+
older outputs. Then the API refuses the earlier thinking once (HTTP 400 "… bound to a different conversation"); chi
|
|
351
|
+
asks again with Anthropic's binding controls (or without those blocks), drops that thinking from the session for
|
|
352
|
+
good, and no turn fails. `/stats` counts it (`thinking dropped: N blocks (preserved-thinking recovery)`), and each
|
|
353
|
+
time costs one extra (free) 400 and one cache write. `reasoning_history` on these hosts is `all` by default; `turn`
|
|
354
|
+
drops more reasoning but never needs a recovery at a turn's start. Fireworks, Splash and other non-Claude models
|
|
355
|
+
never see these requests. internals/anthropic.md has the details.
|
|
352
356
|
|
|
353
357
|
The loop, tools, events, hooks and UIs are the chat loop's, as for `api: openai`; only the wire differs: the
|
|
354
358
|
system prompt goes as the request's `system` (later system notes as user text), tool calls as `tool_use` blocks,
|
|
@@ -640,8 +644,10 @@ What each backend gets:
|
|
|
640
644
|
| Messages host, `dialect: fireworks` | `thinking: {type: "disabled"}` | `thinking: {type: "adaptive"}` and `output_config: {effort: <level>}` (no `display`) |
|
|
641
645
|
|
|
642
646
|
On a Messages host `default` sends nothing: the provider's own default, which differs by model (some Claude models
|
|
643
|
-
think adaptively but show no thinking text, their blocks still going back; others, and Splash, don't think at all). A model that refuses `disabled` (
|
|
644
|
-
|
|
647
|
+
think adaptively but show no thinking text, their blocks still going back; others, and Splash, don't think at all). A model that refuses `disabled` and names `between_tools` in the 400 (Claude 5.x) gets `thinking: {type:
|
|
648
|
+
"between_tools"}` wherever `off` asked for `disabled`, from then on: the adapter keeps that per model for as long as it
|
|
649
|
+
lives, so a worker's turns keep it, and each side-answer client (recap, `ctx.ask_model`, a broadcast's triage) learns it
|
|
650
|
+
once for itself. One that refuses `disabled` without naming it gets the request again without it, as on any chat host. A model that refuses adaptive thinking
|
|
645
651
|
(older Claude: Haiku 4.5) is switched, once and for the worker's life, to the budget form: `thinking: {type:
|
|
646
652
|
"enabled", budget_tokens: B}` with B 2048 / 8192 / 24576 for low / medium / high and no effort; `max_tokens` always
|
|
647
653
|
stays above the budget (the budget is lowered to leave 1024 for the answer, or `max_tokens` raised to 2048). A
|
data/docs/internals/anthropic.md
CHANGED
|
@@ -17,7 +17,10 @@ ChatLoop::Run#generate
|
|
|
17
17
|
→ POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
|
|
18
18
|
→ AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
|
|
19
19
|
# finish_reason, provider_content)
|
|
20
|
+
→ a signature 400 (Claude 5.x preserved thinking): AnthropicBindingRecovery#advance, then the same
|
|
21
|
+
request with drop_block or the blocks stripped; ChatResponse#thinking_drop names them
|
|
20
22
|
→ a 400 it can fix: #refusal_fix, then the same request again (once per kind)
|
|
23
|
+
→ ChatLoop#settle_dropped_thinking! # the dropped blocks leave the stored model entries
|
|
21
24
|
```
|
|
22
25
|
|
|
23
26
|
## Files
|
|
@@ -28,11 +31,12 @@ Paths are under `lib/samagotchi/llm/`.
|
|
|
28
31
|
|---|---|
|
|
29
32
|
| `adapters.rb` | `Adapters::BY_API` (`openai`, `anthropic`), `.for(entry)`, `.build(api:, …)` for side requests (`IdleClient`) |
|
|
30
33
|
| `anthropic_messages.rb` | `AnthropicMessages`: the request body, `#chat` (streamed or not), the 400 fixes, `#list_models`, `#thinking_fields`, `#replays_content?` |
|
|
34
|
+
| `anthropic_binding_recovery.rb` | `AnthropicBindingRecovery`: the preserved-thinking recovery (`#advance` picks drop_block or strip for a signature 400, `#recovered_body`, `#with_thinking_drop`); one per adapter |
|
|
31
35
|
| `anthropic_wire.rb` | `AnthropicWire`: ChatLoop's wire messages as Messages' `system` and `messages`, the two breakpoints, tool ids and names |
|
|
32
36
|
| `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
|
|
33
37
|
| `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
|
|
34
38
|
| `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`) |
|
|
39
|
+
| `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`), `#settle_dropped_thinking!` |
|
|
36
40
|
|
|
37
41
|
## The adapter
|
|
38
42
|
|
|
@@ -51,7 +55,8 @@ with `tool_choice: {type: "auto"}` (when any), then the loop's options. The opti
|
|
|
51
55
|
(`default.max_tokens`), else the model list's `max_tokens` capped at `DEFAULT_MAX_TOKENS` (64000), and never over an
|
|
52
56
|
output cap a 400 named. `#with_budget_room` keeps `max_tokens` above a budget-form `budget_tokens` plus `MIN_BUDGET`.
|
|
53
57
|
|
|
54
|
-
`#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}
|
|
58
|
+
`#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}` (sent as `{type:
|
|
59
|
+
"between_tools"}` to a model whose 400 for `disabled` names that form, Claude 5.x: see the 400 fallbacks); `low`, `medium`
|
|
55
60
|
and `high` are `thinking: {type: "adaptive", display: <dialect's>}` with `output_config: {effort: <level>}`; `default`
|
|
56
61
|
sends nothing. `#thinking_off_refused_fields` (low effort) is what `IdleClient` sends a side ask whose model refused
|
|
57
62
|
thinking off.
|
|
@@ -112,8 +117,70 @@ text and `tool_use` blocks (a tool loop with thinking needs the last assistant t
|
|
|
112
117
|
- `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
|
|
113
118
|
run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
|
|
114
119
|
(`Run#generate`: it would resend the same body).
|
|
115
|
-
- A 400 about a thinking block's signature (`BadRequest#thinking_signature
|
|
116
|
-
|
|
120
|
+
- A 400 about a thinking block's signature (`BadRequest#thinking_signature?`) is recovered in the adapter, and the
|
|
121
|
+
blocks it cost leave the history (next section).
|
|
122
|
+
|
|
123
|
+
## Preserved thinking (Claude 5.x)
|
|
124
|
+
|
|
125
|
+
Claude 5.x models bind each thinking block to the prefix it was minted after: both `system` blocks (the stable part
|
|
126
|
+
and the per-session tail), the tool set, and every earlier message. Sent back after any of those changed, the block
|
|
127
|
+
gets a 400 `messages.N.content.M: Invalid signature in thinking block. The block is bound to a different
|
|
128
|
+
conversation.`, and its last sentence says what changed ("The `system` prompt differs …"). chi's history is
|
|
129
|
+
append-only within a process, so what changes the prefix is:
|
|
130
|
+
|
|
131
|
+
1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `PromptBuilder#reset!` drops it):
|
|
132
|
+
a new process (`--resume`, a respawn after `session.idle_exit_minutes`, a crash) after a memory index line, an
|
|
133
|
+
AGENTS.md, an appended file, a model note or a bundle memory changed; `/model` (the tail's `Model:` line, its
|
|
134
|
+
overlays and notes); `/thinking` (another key); a toolkit or profile change; a plugin's `tools_changed!`.
|
|
135
|
+
2. **A tool set change**: the same triggers, plus MCP lines that differ on a respawn.
|
|
136
|
+
3. **History edits by design**: the `llm_context` `stale`/`forget` stubs, and the image cut moving a batch.
|
|
137
|
+
|
|
138
|
+
A model switch never 400s: the API drops blocks another model can't read silently (`model_binding_mismatch`).
|
|
139
|
+
|
|
140
|
+
**Recover on the 400, then settle.** Normal requests carry no beta header and no `block_binding`, so Fireworks,
|
|
141
|
+
Splash and gateways see the same bytes as before. On a signature 400, before anything streamed,
|
|
142
|
+
`AnthropicBindingRecovery#advance` (one per adapter, `anthropic_binding_recovery.rb`) picks the next step, once each per request:
|
|
143
|
+
|
|
144
|
+
- **drop_block**: the same body with `thinking.block_binding: {prefix_mismatch_behavior: "drop_block"}` (the body's
|
|
145
|
+
adaptive or enabled thinking, `display` as sent; adaptive when the level sends none) and the header
|
|
146
|
+
`anthropic-beta: thinking-binding-controls-2026-08-01`. The answer's `input_transformations` (in `message_start`'s
|
|
147
|
+
message, or the body; `AnthropicAssembly#input_transformations`) list the dropped blocks by path into the body
|
|
148
|
+
sent; the adapter maps the `prefix_binding_mismatch` ones to block identities. Any other entry
|
|
149
|
+
(`model_binding_mismatch`: a switch back reads those blocks again) is counted as kept, never settled.
|
|
150
|
+
- **strip**: the block the 400 names (else the first replayed thinking block) and every thinking or
|
|
151
|
+
redacted_thinking block after it removed from the body (a trailing run, which Messages takes). For a bad
|
|
152
|
+
signature (the 400 without "bound to a different conversation": drop_block doesn't apply), thinking `disabled`
|
|
153
|
+
(block_binding goes only with adaptive or enabled), a model on the budget form with no thinking field, a host
|
|
154
|
+
that refused the controls (`binding_controls`, remembered), or a second signature 400 after drop_block.
|
|
155
|
+
|
|
156
|
+
A third signature 400 fails the turn. The extra requests are 400s, rejected before generation and not billed. The
|
|
157
|
+
step is request state that `#request_body`'s rebuild keeps, so a later `#refusal_fix` (display, cache_control,
|
|
158
|
+
output cap) in the same request keeps the binding or the strip. The response carries `thinking_drop`
|
|
159
|
+
(`LLM::ThinkingDrop`: `identities`, `how`), and the adapter logs `thinking_recovered` (`how`, `dropped`, `kept`,
|
|
160
|
+
`why`: the 400's last sentence).
|
|
161
|
+
|
|
162
|
+
A block's identity (`AnthropicWire.block_identity`, shared by the adapter and ChatLoop) is its `signature`, a
|
|
163
|
+
redacted block's `data`, or, when that is empty (Splash's `signature: ""`), `text:` + the SHA256 of its text.
|
|
164
|
+
|
|
165
|
+
`ChatLoop::Run#generate` hands those identities to `ChatLoop#settle_dropped_thinking!` before the step adds its own
|
|
166
|
+
entry, whatever the finish reason: each model entry holding one is replaced in place (same index) with a copy
|
|
167
|
+
without those blocks (`thinking:` stays; an entry left with none loses `anthropic_content`). That is the edit the
|
|
168
|
+
API made, so the next request is valid with no beta (a pruned history is valid: removing exactly the dropped set
|
|
169
|
+
leaves no gap), and the session file keeps it, since `Engine#kept_messages` and `#failed_messages` take the run's
|
|
170
|
+
conversation whole. It emits `:thinking_dropped` (`count`, `how`) and `thinking_dropped` on `:generation_completed`,
|
|
171
|
+
which `SessionMetrics` sums for `/stats`. A drop that settles nothing (blocks already settled came back, e.g. a
|
|
172
|
+
rolled-back turn's) logs `thinking_recovery_repeated` once per run. A cancelled, cut or dropped generation settles
|
|
173
|
+
nothing; the next request recovers again.
|
|
174
|
+
|
|
175
|
+
Each break costs one free 400, one cache write of the prefix from the edit point, and the reasoning the dropped
|
|
176
|
+
blocks carried. Whether the request after a drop_block one reads the cache that request wrote (it no longer carries
|
|
177
|
+
`block_binding`) is unverified.
|
|
178
|
+
|
|
179
|
+
Why not send drop_block on every request: it needs adaptive or enabled thinking on the body (`default` sends no
|
|
180
|
+
thinking field, `off` sends disabled), it would put a beta header on every request to every host, and it would drop
|
|
181
|
+
the same blocks again on every request until something pruned them. The reactive form does the same work in one
|
|
182
|
+
place, for one extra 400 round trip per break. The `anthropic-thinking-prefix-mismatch` diagnosis header (on the
|
|
183
|
+
400, undocumented) is kept on the provider error and logged as `diagnosis` on `thinking_recovered`.
|
|
117
184
|
|
|
118
185
|
## Cache breakpoints
|
|
119
186
|
|
|
@@ -144,15 +211,21 @@ thinking, nil for none), `cache_breakpoints` and `session_header`.
|
|
|
144
211
|
|
|
145
212
|
`AnthropicMessages#chat` handles the 400s it can fix itself, once each per request, and remembers the fix for the
|
|
146
213
|
adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logged under `http`; kept in an
|
|
147
|
-
`LLM::RefusalMemo`, locked, since the turn
|
|
214
|
+
`LLM::RefusalMemo`, locked, since the turn thread and the model-list reads share the host's adapter; `IdleClient`,
|
|
215
|
+
whose recap, side answers and triage each build their own adapter (`Adapters.build`), keeps what it learned for that
|
|
216
|
+
client's life only):
|
|
148
217
|
|
|
149
218
|
| 400 about | Fix | Remembered |
|
|
150
219
|
|---|---|---|
|
|
151
220
|
| `thinking.display` (`DISPLAY_RE`) | no `display` from now on (`#without_display`), the thinking kept | per host (`thinking_display_refused`) |
|
|
152
221
|
| 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`) |
|
|
222
|
+
| `thinking: {type: "disabled"}` where the 400 names `between_tools` (`THINKING_OFF_FORM_RE`; Claude 5.x) | `thinking: {type: "between_tools"}` wherever `disabled` was asked (`#thinking_between_tools`), so a recap or side answer (`IdleClient` step 0) and a `--thinking off` session never fall back to adaptive low or to no thinking fields; a model that refuses `between_tools` too raises the 400 as before | per model (`thinking_off_form_switched`) |
|
|
153
223
|
| `cache_control` | no breakpoints | per host (`cache_control_refused`) |
|
|
154
224
|
| `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
|
|
225
|
+
| `block_binding` or the binding beta, on a drop_block retry (`thinking_binding_unsupported?`) | the strip instead (Preserved thinking) | per host (`binding_controls_refused`) |
|
|
155
226
|
|
|
156
227
|
The display check comes before the adaptive one: its path (`thinking.adaptive.display`) names adaptive too. A context
|
|
157
|
-
overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these
|
|
158
|
-
|
|
228
|
+
overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these (`#refusal_fix` keeps
|
|
229
|
+
excluding them, so `ADAPTIVE_REFUSED_RE` never reads a binding 400's "adaptive" as a form refusal): a signature 400
|
|
230
|
+
goes to the binding recovery above, the rest on as they are, to the loop's own handling (ChatLoop's sampling and
|
|
231
|
+
thinking-fields fallbacks) or to the turn's failure.
|
data/docs/internals/engine.md
CHANGED
|
@@ -273,8 +273,8 @@ One iteration with tool calls:
|
|
|
273
273
|
→ an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
|
|
274
274
|
→ KernelLoop#dispatch_tool_call, or the denial text
|
|
275
275
|
→ :tool_call_completed, then the :after_tool_call hook
|
|
276
|
-
→ returns
|
|
277
|
-
|
|
276
|
+
→ returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
|
|
277
|
+
shown_label, refs, diff and output_file; fields a call doesn't produce are nil
|
|
278
278
|
→ :tool_dispatch_completed {iteration, call_count}
|
|
279
279
|
→ native: ToolResponse.joined(runs, ids:), one entry with the capped outputs
|
|
280
280
|
chat: ToolResponse.single(run, tool_call_id:, ids:) as each call finishes (the block), with the
|
|
@@ -473,6 +473,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
473
473
|
| `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
|
|
474
474
|
| `: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`, `model_segments` |
|
|
475
475
|
| `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
|
|
476
|
+
| `:thinking_dropped` | the chat loop settled a preserved-thinking recovery: the adapter dropped replayed thinking blocks and they left the history (`ChatLoop#settle_dropped_thinking!`; the step's `:generation_completed` carries `thinking_dropped`; internals/anthropic.md) | `{iteration, count, how}` (`how`: `:drop_block` or `:strip`) |
|
|
476
477
|
| `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
|
|
477
478
|
| `:steer_cut` | a steer cut the generation | `{iteration, source}` |
|
|
478
479
|
| `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
|
|
@@ -171,7 +171,9 @@ is prefilled once. The native and the chat loop use the same plan.
|
|
|
171
171
|
first reply, on a server that renders it; a stock Qwen template without `preserve_thinking` re-renders from the
|
|
172
172
|
reply before every user message whatever chi sends). A worker's
|
|
173
173
|
mid-turn checkpoint (`TurnCheckpoint`) is append-only too (the history plus what the loop appended) and
|
|
174
|
-
never changes the request bytes.
|
|
174
|
+
never changes the request bytes. On Messages with Claude 5.x a change to the prefix (the system prompt, the
|
|
175
|
+
tools, an earlier message) also costs the thinking blocks after it: one 400, then chi drops them for good
|
|
176
|
+
(internals/anthropic.md, "Preserved thinking").
|
|
175
177
|
3. **Deterministic order.** Tools, memory lists and anything else that's rendered from a hash or a set needs a
|
|
176
178
|
fixed sort. Plugin tools are sorted by (bundle, name) whatever order their plugins finished starting in
|
|
177
179
|
(R5a).
|
data/docs/sessions.md
CHANGED
|
@@ -80,6 +80,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
80
80
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
81
81
|
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
82
82
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
83
|
+
- The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only, and a fold forgets the height the grip set (`chi_composer_floor`): the box reopens at one line, or at its text's height. The start page's grip only resizes.
|
|
83
84
|
|
|
84
85
|
**Test-session hygiene:**
|
|
85
86
|
|
|
@@ -4,8 +4,8 @@ require "time"
|
|
|
4
4
|
require_relative "../session"
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
|
-
# Loaded on first use:
|
|
8
|
-
#
|
|
7
|
+
# Loaded on first use: this autoload keeps SessionManager and its ~80
|
|
8
|
+
# files unloaded until the delegate/session path needs them.
|
|
9
9
|
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
10
10
|
|
|
11
11
|
module Broadcast
|
|
@@ -11,8 +11,8 @@ require_relative "log"
|
|
|
11
11
|
require_relative "tools/delegate_cursor"
|
|
12
12
|
|
|
13
13
|
module Samagotchi
|
|
14
|
-
# Loaded on first use: session_manager requires
|
|
15
|
-
#
|
|
14
|
+
# Loaded on first use: session_manager requires this file (a require cycle
|
|
15
|
+
# otherwise).
|
|
16
16
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
17
17
|
# SessionContinue::OpenChild, the rows a plan refuses: loaded on first use too.
|
|
18
18
|
autoload :SessionContinue, File.expand_path("session_continue", __dir__)
|
|
@@ -12,8 +12,8 @@ require_relative "tools/delegate_wait"
|
|
|
12
12
|
require_relative "tools/delegate_cursor"
|
|
13
13
|
|
|
14
14
|
module Samagotchi
|
|
15
|
-
# Loaded on first use:
|
|
16
|
-
#
|
|
15
|
+
# Loaded on first use: this autoload keeps SessionManager and its ~80
|
|
16
|
+
# files unloaded until the delegate/session path needs them.
|
|
17
17
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
18
18
|
|
|
19
19
|
# A delegate child's news reaching its parent by itself (the delegate
|
|
@@ -9,8 +9,8 @@ require_relative "tools/delegate"
|
|
|
9
9
|
require_relative "tools/delegate_cursor"
|
|
10
10
|
|
|
11
11
|
module Samagotchi
|
|
12
|
-
# Loaded on first use:
|
|
13
|
-
#
|
|
12
|
+
# Loaded on first use: this autoload keeps SessionManager and its ~80
|
|
13
|
+
# files unloaded until the delegate/session path needs them.
|
|
14
14
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
15
15
|
|
|
16
16
|
# A parent's children as `/children` and the glance views show them: one
|
|
@@ -46,12 +46,14 @@ module Samagotchi
|
|
|
46
46
|
# Every page of the host's model list (MAX_MODEL_PAGES at most): the
|
|
47
47
|
# block gives a page's URI for the last page's last_id (nil first).
|
|
48
48
|
# @return [Array<ModelInfo>]
|
|
49
|
-
def list_model_pages
|
|
49
|
+
def list_model_pages(headers: {})
|
|
50
50
|
models = []
|
|
51
51
|
after = nil
|
|
52
52
|
MAX_MODEL_PAGES.times do
|
|
53
53
|
uri = yield(after)
|
|
54
|
-
|
|
54
|
+
request = Net::HTTP::Get.new(uri)
|
|
55
|
+
headers.each { |name, value| request[name] = value }
|
|
56
|
+
body = parse_json(@http.fetch(uri, request, log_fields: { purpose: "models" }).body, "model list")
|
|
55
57
|
models.concat(model_entries(body).map { |raw| model_info(raw) })
|
|
56
58
|
break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
|
|
57
59
|
|
|
@@ -44,14 +44,22 @@ module Samagotchi
|
|
|
44
44
|
@stop_reason = nil
|
|
45
45
|
@stop_details = nil
|
|
46
46
|
@model = nil
|
|
47
|
+
@input_transformations = []
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# The message's input_transformations (Claude 5.x: what the API did to
|
|
51
|
+
# the request before generating, e.g. the thinking blocks drop_block
|
|
52
|
+
# dropped), as sent (string keys); [] when it named none. The paths
|
|
53
|
+
# index into the request body, which only the adapter holds.
|
|
54
|
+
attr_reader :input_transformations
|
|
55
|
+
|
|
49
56
|
# A whole message: its model, usage, stop reason and blocks.
|
|
50
57
|
def add_message(message)
|
|
51
58
|
return unless message.is_a?(Hash)
|
|
52
59
|
|
|
53
60
|
@model = served(message["model"]) || @model
|
|
54
61
|
merge_usage(message["usage"])
|
|
62
|
+
read_transformations(message)
|
|
55
63
|
@stop_reason = message["stop_reason"] if message["stop_reason"]
|
|
56
64
|
@stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
|
|
57
65
|
Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
|
|
@@ -133,9 +141,15 @@ module Samagotchi
|
|
|
133
141
|
|
|
134
142
|
@model = served(message["model"]) || @model
|
|
135
143
|
merge_usage(message["usage"])
|
|
144
|
+
read_transformations(message)
|
|
136
145
|
["", ""]
|
|
137
146
|
end
|
|
138
147
|
|
|
148
|
+
def read_transformations(message)
|
|
149
|
+
entries = message["input_transformations"]
|
|
150
|
+
@input_transformations = entries.select { |entry| entry.is_a?(Hash) } if entries.is_a?(Array)
|
|
151
|
+
end
|
|
152
|
+
|
|
139
153
|
def start_block(index, block)
|
|
140
154
|
return ["", ""] unless block.is_a?(Hash)
|
|
141
155
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
require_relative "anthropic_wire"
|
|
5
|
+
require_relative "openai_chat"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module LLM
|
|
9
|
+
# The preserved-thinking recovery of AnthropicMessages (Claude 5.x: a
|
|
10
|
+
# replayed thinking block is bound to the prefix it was made under, and
|
|
11
|
+
# a prefix that changed gets a signature 400): which step a 400 takes
|
|
12
|
+
# (#advance), the body that step sends (#recovered_body) and the
|
|
13
|
+
# response's ThinkingDrop (#with_thinking_drop). One per adapter; a
|
|
14
|
+
# request's own state is a Recovery.
|
|
15
|
+
class AnthropicBindingRecovery
|
|
16
|
+
# The beta header thinking.block_binding needs, and the behaviour the
|
|
17
|
+
# recovery asks for: the API drops a thinking block whose prefix
|
|
18
|
+
# changed (and every thinking block after it) for that request.
|
|
19
|
+
BINDING_BETA = "thinking-binding-controls-2026-08-01"
|
|
20
|
+
DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
|
|
21
|
+
# A signature 400 that drop_block can settle: the block is valid but
|
|
22
|
+
# bound to another prefix. Without it the signature itself is bad
|
|
23
|
+
# (tampered, unreadable, another provider's): only a strip helps.
|
|
24
|
+
BOUND_ELSEWHERE_RE = /bound to a different conversation/i
|
|
25
|
+
# The block a signature 400 names: an index into the body sent.
|
|
26
|
+
BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
|
|
27
|
+
# The only drop the loop settles: a model_binding_mismatch (a /model
|
|
28
|
+
# downgrade) keeps its blocks, a switch back reads them again.
|
|
29
|
+
PREFIX_DROP = "prefix_binding_mismatch"
|
|
30
|
+
|
|
31
|
+
# One request's binding recovery (#advance): the step taken
|
|
32
|
+
# (nil, :drop_block or :strip), the steps tried, where a strip starts
|
|
33
|
+
# ([message, block] in the body), the last signature 400 and the body
|
|
34
|
+
# it answered, and the first 400's last sentence and diagnosis for the log line.
|
|
35
|
+
Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
|
|
36
|
+
|
|
37
|
+
# @param refusals [RefusalMemo] the adapter's (the :binding_controls
|
|
38
|
+
# the host refused is remembered there)
|
|
39
|
+
# @param host_name [String] for the log lines
|
|
40
|
+
# @param budget_form [#call] model -> whether it gets the budget form
|
|
41
|
+
# (AnthropicMessages#budget_form?)
|
|
42
|
+
def initialize(refusals:, host_name:, budget_form:)
|
|
43
|
+
@refusals = refusals
|
|
44
|
+
@host_name = host_name
|
|
45
|
+
@budget_form = budget_form
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The step a signature 400 for +body+ takes (set on +recovery+, once
|
|
49
|
+
# each per request), or nil: the error goes on as it is.
|
|
50
|
+
# - drop_block: the same body with thinking.block_binding and the beta
|
|
51
|
+
# header, for a block bound to another prefix, when the host takes
|
|
52
|
+
# the controls and the body's thinking can carry them (adaptive,
|
|
53
|
+
# enabled, or none: then adaptive; not disabled, nor none for a
|
|
54
|
+
# model on the budget form);
|
|
55
|
+
# - strip: the block the 400 names (else the first replayed thinking
|
|
56
|
+
# block) and every thinking block after it removed, for a bad
|
|
57
|
+
# signature, where drop_block can't apply, when the host refused
|
|
58
|
+
# the controls (remembered for the host), or after a second
|
|
59
|
+
# signature 400.
|
|
60
|
+
def advance(error, body, recovery, model)
|
|
61
|
+
return nil if body.nil? || error.status != 400
|
|
62
|
+
|
|
63
|
+
if error.thinking_binding_unsupported? && recovery.step == :drop_block
|
|
64
|
+
unless @refusals.refused?(:binding_controls)
|
|
65
|
+
@refusals.remember(:binding_controls)
|
|
66
|
+
Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
|
|
67
|
+
end
|
|
68
|
+
return strip_step(recovery)
|
|
69
|
+
end
|
|
70
|
+
return nil unless error.thinking_signature?
|
|
71
|
+
|
|
72
|
+
recovery.diagnosis ||= error.diagnosis
|
|
73
|
+
recovery.signature = [error.detail.to_s, body]
|
|
74
|
+
case recovery.step
|
|
75
|
+
when nil
|
|
76
|
+
return nil if thinking_positions(body).empty?
|
|
77
|
+
|
|
78
|
+
recovery.why = last_sentence(error.detail.to_s)
|
|
79
|
+
return strip_step(recovery) unless drop_block_fits?(error, body, model)
|
|
80
|
+
|
|
81
|
+
recovery.step = :drop_block
|
|
82
|
+
when :drop_block then strip_step(recovery)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# +body+ as the recovery step sends it, and the identities a strip
|
|
87
|
+
# removed (nil for none).
|
|
88
|
+
def recovered_body(body, recovery)
|
|
89
|
+
case recovery.step
|
|
90
|
+
when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
|
|
91
|
+
when :strip then stripped_body(body, recovery.strip_from)
|
|
92
|
+
else [body, nil]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# +response+ with the blocks this request went without
|
|
97
|
+
# (ThinkingDrop), logged: a strip's, or drop_block's
|
|
98
|
+
# prefix_binding_mismatch entries mapped by path into +body+ (the
|
|
99
|
+
# body sent); any other entry is counted as kept, never settled.
|
|
100
|
+
def with_thinking_drop(response, recovery, body, stripped, transformations, model)
|
|
101
|
+
if recovery.step == :strip
|
|
102
|
+
identities = stripped
|
|
103
|
+
kept = 0
|
|
104
|
+
else
|
|
105
|
+
drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
|
|
106
|
+
prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
|
|
107
|
+
identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
|
|
108
|
+
kept = others.size
|
|
109
|
+
end
|
|
110
|
+
fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
|
|
111
|
+
kept: kept, why: recovery.why }
|
|
112
|
+
fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
|
|
113
|
+
Log.info(:http, "thinking_recovered", **fields)
|
|
114
|
+
return response if identities.empty?
|
|
115
|
+
|
|
116
|
+
response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def drop_block_fits?(error, body, model)
|
|
122
|
+
return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
|
|
123
|
+
|
|
124
|
+
case body[:thinking]
|
|
125
|
+
when nil then !@budget_form.call(model)
|
|
126
|
+
when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
|
|
127
|
+
else false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The strip, from the block the last signature 400 names in the body
|
|
132
|
+
# it answered (a thinking block there), else the first replayed one.
|
|
133
|
+
def strip_step(recovery)
|
|
134
|
+
return nil if recovery.tried.include?(:strip)
|
|
135
|
+
|
|
136
|
+
detail, body = recovery.signature
|
|
137
|
+
positions = thinking_positions(body)
|
|
138
|
+
named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
|
|
139
|
+
recovery.strip_from = positions.include?(named) ? named : positions.first
|
|
140
|
+
recovery.step = recovery.strip_from && :strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# [message, block] of every thinking or redacted_thinking block in
|
|
144
|
+
# +body+'s messages.
|
|
145
|
+
def thinking_positions(body)
|
|
146
|
+
Array(body[:messages]).each_with_index.flat_map do |message, n|
|
|
147
|
+
Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if AnthropicWire.thinking_block?(block) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The body's thinking with block_binding: drop_block (adaptive
|
|
152
|
+
# without one: the binding controls need adaptive or enabled).
|
|
153
|
+
def drop_block_thinking(thinking)
|
|
154
|
+
thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
|
|
155
|
+
thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
|
|
156
|
+
thinking.merge(block_binding: DROP_BLOCK)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# +body+ without the thinking blocks from +from+ on (a trailing run:
|
|
160
|
+
# what Messages takes; a gap in the middle it refuses), and their
|
|
161
|
+
# identities. A message left empty says the placeholder.
|
|
162
|
+
def stripped_body(body, from)
|
|
163
|
+
first_message, first_block = from
|
|
164
|
+
removed = []
|
|
165
|
+
messages = body[:messages].each_with_index.map do |message, n|
|
|
166
|
+
next message if n < first_message
|
|
167
|
+
|
|
168
|
+
content = message[:content].each_with_index.reject do |block, m|
|
|
169
|
+
drop = (n > first_message || m >= first_block) && AnthropicWire.thinking_block?(block)
|
|
170
|
+
removed << AnthropicWire.block_identity(block) if drop
|
|
171
|
+
drop
|
|
172
|
+
end.map(&:first)
|
|
173
|
+
next message if content.size == message[:content].size
|
|
174
|
+
|
|
175
|
+
message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
|
|
176
|
+
end
|
|
177
|
+
[body.merge(messages: messages), removed.compact.uniq.freeze]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def identity_at(body, path)
|
|
181
|
+
n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
|
|
182
|
+
return nil unless n
|
|
183
|
+
|
|
184
|
+
block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
|
|
185
|
+
AnthropicWire.thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The last sentence of a 400 (what it says changed: "The `system`
|
|
189
|
+
# prompt differs …"), at most 200 characters.
|
|
190
|
+
def last_sentence(detail)
|
|
191
|
+
detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|