samagotchi 0.77.0 → 0.78.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 +26 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +10 -6
- data/docs/internals/anthropic.md +73 -5
- data/docs/internals/engine.md +1 -0
- 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/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
- data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
- data/lib/samagotchi/llm/chat_loop.rb +49 -2
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- 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 +217 -19
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/index.html +27 -3
- data/lib/samagotchi/worker.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b19c3433cc4f46956563a8e4e6532d8b60061084b8050b534b9230b3c608c54
|
|
4
|
+
data.tar.gz: 97b8e078c43e1c9901139f1f937d16e43b57b6147cd8298fbb9b77abb37c0b96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0022706491e62a2d7f603a5290fd4c5525b17c6712af6d31dc5ce163b36c4e4414074d7b4da609f890ec5e243ea89b33a5ba0621528d8face6c5226108d0a443'
|
|
7
|
+
data.tar.gz: da23fef030284a3401d5ec17017ef2a7d1165d30909f4a96e095823315bc38bbbe1eaf1bdfd76eefe604af19cd160c5f68fd19cb3c4ccb478b28bd9ea1de57eb
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.78.0] - 2026-10-17
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: dragging the message box's top-edge grip down folds the box away for room to read; the info bar stays at the
|
|
16
|
+
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
|
|
17
|
+
bring the box back (a draft marks the handle in the accent, a session waiting on you in the attention colour), and
|
|
18
|
+
Stop turn moves into the info bar while it is folded. The choice is remembered per browser, and an archived session
|
|
19
|
+
opens folded.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Workers no longer load the terminal UI or Reline, saving about 50–100 ms and 2 MB per worker start; a plugin
|
|
24
|
+
that uses Reline, StringIO, Forwardable or Tempfile must now require it itself.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Claude 5.x on `api: anthropic`: a request whose earlier thinking the API refuses after the system prompt or tools
|
|
29
|
+
changed (a resume or respawn after memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`) no longer fails the
|
|
30
|
+
turn. chi asks again with Anthropic's binding controls (or without those thinking blocks), drops that thinking from
|
|
31
|
+
the session, and `/stats` counts it as `thinking dropped`.
|
|
32
|
+
|
|
33
|
+
Update with `chi update` (the system bundle 0.78.0), then restart `chi web`.
|
|
34
|
+
|
|
11
35
|
## [0.77.0] - 2026-10-17
|
|
12
36
|
|
|
13
37
|
### Changed
|
|
@@ -3245,7 +3269,8 @@ and long-lived sessions.
|
|
|
3245
3269
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3246
3270
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3247
3271
|
|
|
3248
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3272
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.78.0...HEAD
|
|
3273
|
+
[0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
|
|
3249
3274
|
[0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
|
|
3250
3275
|
[0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
|
|
3251
3276
|
[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,
|
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): #binding_recovery, 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
|
|
@@ -32,7 +35,7 @@ Paths are under `lib/samagotchi/llm/`.
|
|
|
32
35
|
| `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
|
|
33
36
|
| `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
|
|
34
37
|
| `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`) |
|
|
38
|
+
| `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`), `#settle_dropped_thinking!` |
|
|
36
39
|
|
|
37
40
|
## The adapter
|
|
38
41
|
|
|
@@ -112,8 +115,70 @@ text and `tool_use` blocks (a tool loop with thinking needs the last assistant t
|
|
|
112
115
|
- `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
|
|
113
116
|
run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
|
|
114
117
|
(`Run#generate`: it would resend the same body).
|
|
115
|
-
- A 400 about a thinking block's signature (`BadRequest#thinking_signature
|
|
116
|
-
|
|
118
|
+
- A 400 about a thinking block's signature (`BadRequest#thinking_signature?`) is recovered in the adapter, and the
|
|
119
|
+
blocks it cost leave the history (next section).
|
|
120
|
+
|
|
121
|
+
## Preserved thinking (Claude 5.x)
|
|
122
|
+
|
|
123
|
+
Claude 5.x models bind each thinking block to the prefix it was minted after: both `system` blocks (the stable part
|
|
124
|
+
and the per-session tail), the tool set, and every earlier message. Sent back after any of those changed, the block
|
|
125
|
+
gets a 400 `messages.N.content.M: Invalid signature in thinking block. The block is bound to a different
|
|
126
|
+
conversation.`, and its last sentence says what changed ("The `system` prompt differs …"). chi's history is
|
|
127
|
+
append-only within a process, so what changes the prefix is:
|
|
128
|
+
|
|
129
|
+
1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `PromptBuilder#reset!` drops it):
|
|
130
|
+
a new process (`--resume`, a respawn after `session.idle_exit_minutes`, a crash) after a memory index line, an
|
|
131
|
+
AGENTS.md, an appended file, a model note or a bundle memory changed; `/model` (the tail's `Model:` line, its
|
|
132
|
+
overlays and notes); `/thinking` (another key); a toolkit or profile change; a plugin's `tools_changed!`.
|
|
133
|
+
2. **A tool set change**: the same triggers, plus MCP lines that differ on a respawn.
|
|
134
|
+
3. **History edits by design**: the `llm_context` `stale`/`forget` stubs, and the image cut moving a batch.
|
|
135
|
+
|
|
136
|
+
A model switch never 400s: the API drops blocks another model can't read silently (`model_binding_mismatch`).
|
|
137
|
+
|
|
138
|
+
**Recover on the 400, then settle.** Normal requests carry no beta header and no `block_binding`, so Fireworks,
|
|
139
|
+
Splash and gateways see the same bytes as before. On a signature 400, before anything streamed,
|
|
140
|
+
`AnthropicMessages#binding_recovery` picks the next step, once each per request:
|
|
141
|
+
|
|
142
|
+
- **drop_block**: the same body with `thinking.block_binding: {prefix_mismatch_behavior: "drop_block"}` (the body's
|
|
143
|
+
adaptive or enabled thinking, `display` as sent; adaptive when the level sends none) and the header
|
|
144
|
+
`anthropic-beta: thinking-binding-controls-2026-08-01`. The answer's `input_transformations` (in `message_start`'s
|
|
145
|
+
message, or the body; `AnthropicAssembly#input_transformations`) list the dropped blocks by path into the body
|
|
146
|
+
sent; the adapter maps the `prefix_binding_mismatch` ones to block identities. Any other entry
|
|
147
|
+
(`model_binding_mismatch`: a switch back reads those blocks again) is counted as kept, never settled.
|
|
148
|
+
- **strip**: the block the 400 names (else the first replayed thinking block) and every thinking or
|
|
149
|
+
redacted_thinking block after it removed from the body (a trailing run, which Messages takes). For a bad
|
|
150
|
+
signature (the 400 without "bound to a different conversation": drop_block doesn't apply), thinking `disabled`
|
|
151
|
+
(block_binding goes only with adaptive or enabled), a model on the budget form with no thinking field, a host
|
|
152
|
+
that refused the controls (`binding_controls`, remembered), or a second signature 400 after drop_block.
|
|
153
|
+
|
|
154
|
+
A third signature 400 fails the turn. The extra requests are 400s, rejected before generation and not billed. The
|
|
155
|
+
step is request state that `#request_body`'s rebuild keeps, so a later `#refusal_fix` (display, cache_control,
|
|
156
|
+
output cap) in the same request keeps the binding or the strip. The response carries `thinking_drop`
|
|
157
|
+
(`LLM::ThinkingDrop`: `identities`, `how`), and the adapter logs `thinking_recovered` (`how`, `dropped`, `kept`,
|
|
158
|
+
`why`: the 400's last sentence).
|
|
159
|
+
|
|
160
|
+
A block's identity (`AnthropicWire.block_identity`, shared by the adapter and ChatLoop) is its `signature`, a
|
|
161
|
+
redacted block's `data`, or, when that is empty (Splash's `signature: ""`), `text:` + the SHA256 of its text.
|
|
162
|
+
|
|
163
|
+
`ChatLoop::Run#generate` hands those identities to `ChatLoop#settle_dropped_thinking!` before the step adds its own
|
|
164
|
+
entry, whatever the finish reason: each model entry holding one is replaced in place (same index) with a copy
|
|
165
|
+
without those blocks (`thinking:` stays; an entry left with none loses `anthropic_content`). That is the edit the
|
|
166
|
+
API made, so the next request is valid with no beta (a pruned history is valid: removing exactly the dropped set
|
|
167
|
+
leaves no gap), and the session file keeps it, since `Engine#kept_messages` and `#failed_messages` take the run's
|
|
168
|
+
conversation whole. It emits `:thinking_dropped` (`count`, `how`) and `thinking_dropped` on `:generation_completed`,
|
|
169
|
+
which `SessionMetrics` sums for `/stats`. A drop that settles nothing (blocks already settled came back, e.g. a
|
|
170
|
+
rolled-back turn's) logs `thinking_recovery_repeated` once per run. A cancelled, cut or dropped generation settles
|
|
171
|
+
nothing; the next request recovers again.
|
|
172
|
+
|
|
173
|
+
Each break costs one free 400, one cache write of the prefix from the edit point, and the reasoning the dropped
|
|
174
|
+
blocks carried. Whether the request after a drop_block one reads the cache that request wrote (it no longer carries
|
|
175
|
+
`block_binding`) is unverified.
|
|
176
|
+
|
|
177
|
+
Why not send drop_block on every request: it needs adaptive or enabled thinking on the body (`default` sends no
|
|
178
|
+
thinking field, `off` sends disabled), it would put a beta header on every request to every host, and it would drop
|
|
179
|
+
the same blocks again on every request until something pruned them. The reactive form does the same work in one
|
|
180
|
+
place, for one extra 400 round trip per break. The `anthropic-thinking-prefix-mismatch` diagnosis header (on the
|
|
181
|
+
400, undocumented) is kept on the provider error and logged as `diagnosis` on `thinking_recovered`.
|
|
117
182
|
|
|
118
183
|
## Cache breakpoints
|
|
119
184
|
|
|
@@ -152,7 +217,10 @@ adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logg
|
|
|
152
217
|
| 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`) |
|
|
153
218
|
| `cache_control` | no breakpoints | per host (`cache_control_refused`) |
|
|
154
219
|
| `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
|
|
220
|
+
| `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
221
|
|
|
156
222
|
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
|
-
|
|
223
|
+
overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these (`#refusal_fix` keeps
|
|
224
|
+
excluding them, so `ADAPTIVE_REFUSED_RE` never reads a binding 400's "adaptive" as a form refusal): a signature 400
|
|
225
|
+
goes to the binding recovery above, the rest on as they are, to the loop's own handling (ChatLoop's sampling and
|
|
226
|
+
thinking-fields fallbacks) or to the turn's failure.
|
data/docs/internals/engine.md
CHANGED
|
@@ -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
|
|
@@ -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
|
|
|
@@ -43,6 +43,11 @@ module Samagotchi
|
|
|
43
43
|
# breakpoints for the host;
|
|
44
44
|
# - max_tokens over the model's output limit: the limit the message
|
|
45
45
|
# names, per model.
|
|
46
|
+
# A signature 400 (Claude 5.x preserved thinking: a replayed block bound
|
|
47
|
+
# to a prefix that changed) is recovered here too (#binding_recovery):
|
|
48
|
+
# asked again with drop_block, or with the blocks stripped, and the
|
|
49
|
+
# dropped blocks handed to the loop (ChatResponse#thinking_drop), which
|
|
50
|
+
# removes them from the history.
|
|
46
51
|
class AnthropicMessages
|
|
47
52
|
include ModelListing
|
|
48
53
|
include AdapterCommon
|
|
@@ -71,6 +76,27 @@ module Samagotchi
|
|
|
71
76
|
# (configured sampling passes as written).
|
|
72
77
|
DROPPED_OPTIONS = %i[reasoning_effort chat_template_kwargs stream_options prompt_cache_key logprobs top_logprobs
|
|
73
78
|
max_completion_tokens].freeze
|
|
79
|
+
# The beta header thinking.block_binding needs, and the behaviour the
|
|
80
|
+
# recovery asks for: the API drops a thinking block whose prefix
|
|
81
|
+
# changed (and every thinking block after it) for that request.
|
|
82
|
+
BINDING_BETA = "thinking-binding-controls-2026-08-01"
|
|
83
|
+
DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
|
|
84
|
+
# A signature 400 that drop_block can settle: the block is valid but
|
|
85
|
+
# bound to another prefix. Without it the signature itself is bad
|
|
86
|
+
# (tampered, unreadable, another provider's): only a strip helps.
|
|
87
|
+
BOUND_ELSEWHERE_RE = /bound to a different conversation/i
|
|
88
|
+
# The block a signature 400 names: an index into the body sent.
|
|
89
|
+
BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
|
|
90
|
+
THINKING_TYPES = %w[thinking redacted_thinking].freeze
|
|
91
|
+
# The only drop the loop settles: a model_binding_mismatch (a /model
|
|
92
|
+
# downgrade) keeps its blocks, a switch back reads them again.
|
|
93
|
+
PREFIX_DROP = "prefix_binding_mismatch"
|
|
94
|
+
|
|
95
|
+
# One request's binding recovery (#binding_recovery): the step taken
|
|
96
|
+
# (nil, :drop_block or :strip), the steps tried, where a strip starts
|
|
97
|
+
# ([message, block] in the body), the last signature 400 and the body
|
|
98
|
+
# it answered, and the first 400's last sentence and diagnosis for the log line.
|
|
99
|
+
Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
|
|
74
100
|
|
|
75
101
|
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
|
|
76
102
|
|
|
@@ -111,14 +137,29 @@ module Samagotchi
|
|
|
111
137
|
def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
|
|
112
138
|
session_id: nil)
|
|
113
139
|
fixed = []
|
|
140
|
+
recovery = Recovery.new(tried: [])
|
|
141
|
+
shown = false
|
|
114
142
|
begin
|
|
115
|
-
body
|
|
116
|
-
|
|
143
|
+
# A rebuilt body (a refusal fix's retry) keeps the recovery.
|
|
144
|
+
body, stripped = recovered_body(request_body(messages, tools, model, options), recovery)
|
|
145
|
+
headers = recovery.step == :drop_block ? { "anthropic-beta" => BINDING_BETA } : {}
|
|
146
|
+
request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
|
|
117
147
|
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
148
|
+
response, transformations =
|
|
149
|
+
if @stream
|
|
150
|
+
chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, -> { shown = true })
|
|
151
|
+
else
|
|
152
|
+
chat_once(request, cancel_controller, log_fields)
|
|
153
|
+
end
|
|
154
|
+
recovery.step ? with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
|
|
121
155
|
rescue BadRequest => e
|
|
156
|
+
# Only before anything was shown: a mid-stream error must not
|
|
157
|
+
# repeat its deltas.
|
|
158
|
+
if !shown && binding_recovery(e, body, recovery, model)
|
|
159
|
+
recovery.tried << recovery.step
|
|
160
|
+
retry
|
|
161
|
+
end
|
|
162
|
+
|
|
122
163
|
fix = refusal_fix(e, body, model)
|
|
123
164
|
raise if fix.nil? || fixed.include?(fix)
|
|
124
165
|
|
|
@@ -249,6 +290,152 @@ module Samagotchi
|
|
|
249
290
|
body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
|
|
250
291
|
end
|
|
251
292
|
|
|
293
|
+
# The step a signature 400 for +body+ takes (set on +recovery+, once
|
|
294
|
+
# each per request), or nil: the error goes on as it is.
|
|
295
|
+
# - drop_block: the same body with thinking.block_binding and the beta
|
|
296
|
+
# header, for a block bound to another prefix, when the host takes
|
|
297
|
+
# the controls and the body's thinking can carry them (adaptive,
|
|
298
|
+
# enabled, or none: then adaptive; not disabled, nor none for a
|
|
299
|
+
# model on the budget form);
|
|
300
|
+
# - strip: the block the 400 names (else the first replayed thinking
|
|
301
|
+
# block) and every thinking block after it removed, for a bad
|
|
302
|
+
# signature, where drop_block can't apply, when the host refused
|
|
303
|
+
# the controls (remembered for the host), or after a second
|
|
304
|
+
# signature 400.
|
|
305
|
+
def binding_recovery(error, body, recovery, model)
|
|
306
|
+
return nil if body.nil? || error.status != 400
|
|
307
|
+
|
|
308
|
+
if error.thinking_binding_unsupported? && recovery.step == :drop_block
|
|
309
|
+
unless @refusals.refused?(:binding_controls)
|
|
310
|
+
@refusals.remember(:binding_controls)
|
|
311
|
+
Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
|
|
312
|
+
end
|
|
313
|
+
return strip_step(recovery)
|
|
314
|
+
end
|
|
315
|
+
return nil unless error.thinking_signature?
|
|
316
|
+
|
|
317
|
+
recovery.diagnosis ||= error.diagnosis
|
|
318
|
+
recovery.signature = [error.detail.to_s, body]
|
|
319
|
+
case recovery.step
|
|
320
|
+
when nil
|
|
321
|
+
return nil if thinking_positions(body).empty?
|
|
322
|
+
|
|
323
|
+
recovery.why = last_sentence(error.detail.to_s)
|
|
324
|
+
return strip_step(recovery) unless drop_block_fits?(error, body, model)
|
|
325
|
+
|
|
326
|
+
recovery.step = :drop_block
|
|
327
|
+
when :drop_block then strip_step(recovery)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def drop_block_fits?(error, body, model)
|
|
332
|
+
return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
|
|
333
|
+
|
|
334
|
+
case body[:thinking]
|
|
335
|
+
when nil then !budget_form?(model)
|
|
336
|
+
when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
|
|
337
|
+
else false
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The strip, from the block the last signature 400 names in the body
|
|
342
|
+
# it answered (a thinking block there), else the first replayed one.
|
|
343
|
+
def strip_step(recovery)
|
|
344
|
+
return nil if recovery.tried.include?(:strip)
|
|
345
|
+
|
|
346
|
+
detail, body = recovery.signature
|
|
347
|
+
positions = thinking_positions(body)
|
|
348
|
+
named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
|
|
349
|
+
recovery.strip_from = positions.include?(named) ? named : positions.first
|
|
350
|
+
recovery.step = recovery.strip_from && :strip
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# [message, block] of every thinking or redacted_thinking block in
|
|
354
|
+
# +body+'s messages.
|
|
355
|
+
def thinking_positions(body)
|
|
356
|
+
Array(body[:messages]).each_with_index.flat_map do |message, n|
|
|
357
|
+
Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if thinking_block?(block) }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?((block["type"] || block[:type]).to_s)
|
|
362
|
+
|
|
363
|
+
# +body+ as the recovery step sends it, and the identities a strip
|
|
364
|
+
# removed (nil for none).
|
|
365
|
+
def recovered_body(body, recovery)
|
|
366
|
+
case recovery.step
|
|
367
|
+
when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
|
|
368
|
+
when :strip then stripped_body(body, recovery.strip_from)
|
|
369
|
+
else [body, nil]
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# The body's thinking with block_binding: drop_block (adaptive
|
|
374
|
+
# without one: the binding controls need adaptive or enabled).
|
|
375
|
+
def drop_block_thinking(thinking)
|
|
376
|
+
thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
|
|
377
|
+
thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
|
|
378
|
+
thinking.merge(block_binding: DROP_BLOCK)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# +body+ without the thinking blocks from +from+ on (a trailing run:
|
|
382
|
+
# what Messages takes; a gap in the middle it refuses), and their
|
|
383
|
+
# identities. A message left empty says the placeholder.
|
|
384
|
+
def stripped_body(body, from)
|
|
385
|
+
first_message, first_block = from
|
|
386
|
+
removed = []
|
|
387
|
+
messages = body[:messages].each_with_index.map do |message, n|
|
|
388
|
+
next message if n < first_message
|
|
389
|
+
|
|
390
|
+
content = message[:content].each_with_index.reject do |block, m|
|
|
391
|
+
drop = (n > first_message || m >= first_block) && thinking_block?(block)
|
|
392
|
+
removed << AnthropicWire.block_identity(block) if drop
|
|
393
|
+
drop
|
|
394
|
+
end.map(&:first)
|
|
395
|
+
next message if content.size == message[:content].size
|
|
396
|
+
|
|
397
|
+
message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
|
|
398
|
+
end
|
|
399
|
+
[body.merge(messages: messages), removed.compact.uniq.freeze]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# +response+ with the blocks this request went without
|
|
403
|
+
# (ThinkingDrop), logged: a strip's, or drop_block's
|
|
404
|
+
# prefix_binding_mismatch entries mapped by path into +body+ (the
|
|
405
|
+
# body sent); any other entry is counted as kept, never settled.
|
|
406
|
+
def with_thinking_drop(response, recovery, body, stripped, transformations, model)
|
|
407
|
+
if recovery.step == :strip
|
|
408
|
+
identities = stripped
|
|
409
|
+
kept = 0
|
|
410
|
+
else
|
|
411
|
+
drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
|
|
412
|
+
prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
|
|
413
|
+
identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
|
|
414
|
+
kept = others.size
|
|
415
|
+
end
|
|
416
|
+
fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
|
|
417
|
+
kept: kept, why: recovery.why }
|
|
418
|
+
fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
|
|
419
|
+
Log.info(:http, "thinking_recovered", **fields)
|
|
420
|
+
return response if identities.empty?
|
|
421
|
+
|
|
422
|
+
response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def identity_at(body, path)
|
|
426
|
+
n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
|
|
427
|
+
return nil unless n
|
|
428
|
+
|
|
429
|
+
block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
|
|
430
|
+
thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# The last sentence of a 400 (what it says changed: "The `system`
|
|
434
|
+
# prompt differs …"), at most 200 characters.
|
|
435
|
+
def last_sentence(detail)
|
|
436
|
+
detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
|
|
437
|
+
end
|
|
438
|
+
|
|
252
439
|
# What a 400 for +body+ changes (remembered), or nil: the error goes
|
|
253
440
|
# on as it is.
|
|
254
441
|
def refusal_fix(error, body, model)
|
|
@@ -309,30 +496,40 @@ module Samagotchi
|
|
|
309
496
|
raise error if error
|
|
310
497
|
raise ProtocolError.new("#{@host_name}: no content in the Messages response", host: @host_name) unless message?(body)
|
|
311
498
|
|
|
312
|
-
AnthropicAssembly.from_message(body)
|
|
499
|
+
assembly = AnthropicAssembly.from_message(body)
|
|
500
|
+
[assembly.response, assembly.input_transformations]
|
|
313
501
|
end
|
|
314
502
|
|
|
315
503
|
def message?(body) = body.is_a?(Hash) && body["content"].is_a?(Array)
|
|
316
504
|
|
|
317
505
|
# The SSE stream: each `data:` event goes through the assembly (the
|
|
318
506
|
# `event:` lines are not needed, so a gateway that drops them works);
|
|
319
|
-
# on_delta gets every event but pings, with its text and reasoning
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
507
|
+
# on_delta gets every event but pings, with its text and reasoning;
|
|
508
|
+
# +on_shown+ hears each event that shows something. Returns the
|
|
509
|
+
# response and the message's input_transformations.
|
|
510
|
+
def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, on_shown = nil)
|
|
511
|
+
last = nil
|
|
512
|
+
response = stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
|
|
513
|
+
what: "stream event") do |assembly, event, shown|
|
|
514
|
+
last = assembly
|
|
323
515
|
content, reasoning = assembly.add(event)
|
|
324
|
-
|
|
516
|
+
if AnthropicAssembly.shows?(event)
|
|
517
|
+
shown.call
|
|
518
|
+
on_shown&.call
|
|
519
|
+
end
|
|
325
520
|
on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
|
|
326
521
|
end
|
|
522
|
+
[response, last&.input_transformations || []]
|
|
327
523
|
end
|
|
328
524
|
|
|
329
|
-
|
|
525
|
+
# +extra_headers+ go after the defaults (the binding beta).
|
|
526
|
+
def post_request(url, body, session_id: nil, extra_headers: {})
|
|
330
527
|
headers = { "anthropic-version" => API_VERSION }
|
|
331
528
|
unless session_id.to_s.empty?
|
|
332
529
|
headers["Session-Id"] = session_id.to_s
|
|
333
530
|
headers[@dialect.session_header] = session_id.to_s if @dialect.session_header
|
|
334
531
|
end
|
|
335
|
-
json_post(url, body, headers)
|
|
532
|
+
json_post(url, body, headers.merge(extra_headers))
|
|
336
533
|
end
|
|
337
534
|
end
|
|
338
535
|
end
|