samagotchi 0.21.0 → 0.22.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9d794c2749a0f1293cb16d7afeb7e023f38dd8798532e4dcb2d0e9fcb575af1
4
- data.tar.gz: 8ade3692c344214cc8cdbe90c1fb8539242639f7cbf8707c751a8d2b02f9548a
3
+ metadata.gz: 9019626f95da411558ec5350303433de58fd07f9db6f9e22323856e4e2ed31a0
4
+ data.tar.gz: 015df1a469fb64ff5556ce6f683ee23abf039865a34584fff07e3f3c32e4724e
5
5
  SHA512:
6
- metadata.gz: b3eb054e3b0dd3ded63ef8de4bfff37d8d36971dbe02107ac2e30114fadc12563807d4c7bdc4d3fdf8b801a7bd8250fcb5e73a547604b0feba7382abfdff0ee7
7
- data.tar.gz: 168c3cfef2a37c4f5e3f6e2782b614a546f8307793e41af0efe200b2afa8d100b725889e5bb8fcf520fa7c2a0be18faa4cedb10a9ddf573bedac162a1404f133
6
+ metadata.gz: 1bd652f5d5c2d549dce4eba12cc6577f34cb80ae2a95ea4d63492b4581ed510a5f4eeb8b741f86da7af39198dbfc4981e5d6e7d019dfff7d90f8c0a19722cd6b
7
+ data.tar.gz: fe6e4cbc412b112d157ad16a1f176fa1308ed094498eb982770b5a6b87859a89c9d544b9c0791d953e0823e51e09396f13e484eccdb240cdd0a0daad8dfe3f8e
data/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.22.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - Claude models on OpenAI-compatible hosts (OpenRouter) use Anthropic's prompt cache: chat requests mark the
16
+ system message and the last message with `cache_control`, so each step reads the earlier prompt from the
17
+ cache (about a tenth of the price) instead of paying for all of it again. The log line shows `cache=on`.
18
+
19
+ ### Changed
20
+
21
+ - An HTTP 402 is no longer a "rejected the request" bad request. OpenRouter's "would exceed your available
22
+ credits given your current in-flight requests" (credit reserved by another running request) is retried like a
23
+ 429, 20s apart (about 100s with the default `retry.max`), instead of failing the turn at once; any other 402
24
+ fails as `out of credits on host <name>: …; add credits, then send again`, with `error_kind` `credits`.
25
+ - `chi answer --option Continue --text "…"` is accepted: the text joins the continued turn as a steer (marked as the parent agent's for `chi answer`), instead of being refused.
26
+ - The Continue answer's text keeps its line breaks in the continued turn's steer, and it is dropped (logged
27
+ `steer_dropped why=not_begun`) when the continue turn fails before it begins or a typed `/continue no` answered
28
+ the offer first, instead of joining whatever turn ran next.
29
+ - `chi answer --format json` says `"answered_here": false` when the question was no longer open (answered
30
+ elsewhere, or another one waits now): it still waits for what comes next, but the JSON no longer reads as if
31
+ the given answer went in.
32
+ - `chi send --wait` and `chi answer` with `--format json` say why a turn stopped: a failed turn adds `error_kind`
33
+ (the provider error's kind, e.g. `credits`, `server`) and `retryable`, a canceled one `cancel_reason` (`user`,
34
+ `hook`, `ctrl_c`, `manual`) and `stopped_by` (the hook, e.g. `loop-guard`), when known. The session's
35
+ `last_turn` records them.
36
+
37
+ Update with `chi update` (no bundle versions moved). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
38
+
11
39
  ## [0.21.0] - 2026-10-04
12
40
 
13
41
  ### Added
@@ -1245,7 +1273,8 @@ and long-lived sessions.
1245
1273
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1246
1274
  hotkey panel that send selected text or the clipboard to your sessions.
1247
1275
 
1248
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.21.0...HEAD
1276
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.22.0...HEAD
1277
+ [0.22.0]: https://github.com/dm1try/samagotchi/compare/v0.21.0...v0.22.0
1249
1278
  [0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
1250
1279
  [0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
1251
1280
  [0.19.0]: https://github.com/dm1try/samagotchi/compare/v0.18.1...v0.19.0
data/docs/architecture.md CHANGED
@@ -392,8 +392,9 @@ output) and cancel. Cancel closes the in-flight socket from the
392
392
  `CancellationController` listener, so it works on any thread.
393
393
 
394
394
  **Errors.** A failed request raises an `LLM::ProviderError` of one kind:
395
- `ConnectionError` (`RetryExhausted`), `RateLimited`, `ServerError`, `AuthError`,
396
- `BadRequest` (`context_overflow?`) or `ProtocolError`. Engine keeps the turn's
395
+ `ConnectionError` (`RetryExhausted`), `RateLimited` (`CreditsHeld`: a 402 about
396
+ credit held by in-flight requests, retried after 20 s), `OutOfCredits` (any other
397
+ 402), `ServerError`, `AuthError`, `BadRequest` (`context_overflow?`) or `ProtocolError`. Engine keeps the turn's
397
398
  conversation (the prompt plus completed tool iterations) and emits
398
399
  `:turn_failed` with `error_kind:`, `retryable:`, `host:` and a one-line
399
400
  `summary:`, which the REPL, the attached TUI and the web show.
@@ -513,6 +513,12 @@ Every chat request carries the session's id as a `Session-Id` header (next to `U
513
513
  gateway that spreads requests over several providers can key on it to keep one conversation on one provider, so
514
514
  prompt caches hit and every turn is answered by the same model. Servers that don't know the header ignore it.
515
515
 
516
+ Chat requests for a Claude model (an id containing `claude`, such as `anthropic/claude-sonnet-5.5`) on a remote
517
+ OpenAI-compatible host get two prompt-cache breakpoints (`cache_control` on the system message and on the last
518
+ message), so each step reads the earlier prompt from Anthropic's cache instead of paying for it again; the debug
519
+ log's line shows `cache=on`. Nothing to configure. A provider behind a gateway may still not cache: check the
520
+ cached tokens in `/stats`.
521
+
516
522
  ## Llama Model Routing
517
523
 
518
524
  To explicitly route requests to a named model in llama.cpp, set:
@@ -607,6 +613,9 @@ Transient network failures are retried automatically with exponential backoff.
607
613
  - Retry scope: transient network errors (timeouts, reset connections, EOF/socket reachability failures),
608
614
  HTTP 429 and HTTP 500/502/503/504/529. A `Retry-After` header replaces the backoff delay; one longer than
609
615
  60s is not waited out and the error is reported instead.
616
+ - An HTTP 402 about credit held by in-flight requests (OpenRouter reserves credit for each running request, so
617
+ one session's long request can make another's fail) is retried like a 429, 20s apart unless `Retry-After`
618
+ says otherwise: with the default `retry.max` that is about 100s. With `retry.max: 0` it fails at once.
610
619
  - A refused connection (nothing listening) is not retried: the turn fails at once with `can't reach host <name> at
611
620
  <address> (connection refused) — is the server running?`.
612
621
  - A stream that has already produced output is never retried (the retry would repeat it); it fails the turn.
@@ -653,6 +662,8 @@ message (before, a failed llama.cpp `/completion` ended the turn as
653
662
  |---|---|---|
654
663
  | connection | reset, timed out, dropped mid-stream; refused | yes (network retry), not mid-stream; refused: no |
655
664
  | rate limited | HTTP 429 | yes, honouring `Retry-After` |
665
+ | credits held | HTTP 402 "… in-flight requests" | yes, after 20s (or `Retry-After`) |
666
+ | credits | any other HTTP 402: out of credits | no |
656
667
  | server | HTTP 5xx, llama.cpp's mid-stream `error:` event | 500/502/503/504/529 only |
657
668
  | auth | HTTP 401/403 | no |
658
669
  | bad request | other 4xx; a prompt larger than the context window, whatever the status | no |
data/docs/sessions.md CHANGED
@@ -172,7 +172,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
172
172
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
173
173
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
174
174
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
175
- - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
175
+ - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
176
176
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
177
177
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
178
178
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
data/docs/sub-agent.md CHANGED
@@ -37,7 +37,7 @@ Exit codes, both commands:
37
37
  | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
38
38
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
39
39
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
40
- | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it) |
40
+ | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web or `chi sessions stop`; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
41
41
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
42
42
  | 130 | `running` | Ctrl-C; the turn goes on |
43
43
 
@@ -59,8 +59,11 @@ A turn that runs out of steps (`turn.max_iterations`, default 100) before it
59
59
  answers waits as a question of kind `continue` (`"limit": 100`, options
60
60
  `Continue` and `Stop`). `answer_with` continues it:
61
61
  `chi answer ID --question QID --option Continue`, and the continued turn's
62
- reply (or its next question) comes back on that same wait. To stop it:
63
- `--option Stop`, with `--text "why"` for the model (Continue takes no text);
62
+ reply (or its next question) comes back on that same wait. With
63
+ `--text "…"` the text joins the continued turn as a steer (as a `chi send -m`
64
+ into a running turn does, marked as coming from the parent agent), so one
65
+ answer continues and redirects it. To stop it:
66
+ `--option Stop`, with `--text "why"` for the model;
64
67
  that wait ends with `status: not_continued`, exit 0, and the turn's work so far
65
68
  stays in the session. A message instead (`chi send ID -m "…"`) drops the
66
69
  question and starts a new turn. It can't be dismissed.
@@ -132,7 +135,9 @@ answers from any local process.
132
135
  - `chi send --wait ID` with no message waits past a question already reported
133
136
  (the step-limit question too), until someone answers it.
134
137
  - An answer to a question that is no longer open (you answered it in the web
135
- first) isn't sent; `chi answer` says so and waits for what comes next.
138
+ first) isn't sent; `chi answer` says so and waits for what comes next. Its
139
+ JSON then has `"answered_here": false` (absent when the answer went in), so
140
+ an `answered` status there is someone else's answer's turn, not yours.
136
141
  - `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
137
142
  file. Agent shells usually pass `/dev/null`, which is fine.
138
143
 
@@ -165,7 +170,8 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
165
170
  - `status: running` (exit 4): still working. Wait again:
166
171
  `chi send --wait --format json --timeout 500 <session_id>`.
167
172
  - `question.kind: continue`: chi ran out of steps before it answered. If it is
168
- getting somewhere, continue it (`answer_with`, `--option Continue`); if not,
173
+ getting somewhere, continue it (`answer_with`, `--option Continue`, with
174
+ `--text "…"` to steer it on the way); if not,
169
175
  stop it (`--option Stop --text "why"`, exit 0, status `not_continued`) or send
170
176
  a narrower follow-up instead. Ask your user when unsure.
171
177
  - Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
@@ -34,7 +34,8 @@ module Samagotchi
34
34
  with it), then waits for what comes next and prints it as chi send
35
35
  --wait does.
36
36
  --question QID the question's id (chi send --wait prints it); a
37
- question no longer open is not answered
37
+ question no longer open is not answered (the JSON
38
+ says "answered_here": false)
38
39
  --option N|LABEL
39
40
  an option, by number (1-based) or label; repeat
40
41
  on a multi-select question
@@ -47,7 +48,8 @@ module Samagotchi
47
48
  An approval (a guardrail's ask) can be denied here: deny it, and
48
49
  tell your user; allowing it is the user's. With
49
50
  guardrails.parent_approvals: once, "Allow once" is let through.
50
- A step-limit question (kind continue): --option Continue, or
51
+ A step-limit question (kind continue): --option Continue [--text
52
+ STEER] (the text joins the continued turn as a steer), or
51
53
  --option Stop [--text WHY] (exit 0, status not_continued); it
52
54
  can't be dismissed.
53
55
  Exit: 0 answered, 1 failed, refused or the worker is gone, 2
@@ -214,9 +216,11 @@ module Samagotchi
214
216
  end
215
217
 
216
218
  # The question was answered elsewhere (the web first), or another one
217
- # waits now: said, then what comes next is waited for.
219
+ # waits now: said, then what comes next is waited for (the JSON says
220
+ # answered_here: false).
218
221
  # @return [nil]
219
222
  def not_open(qid)
223
+ @answered_here = false
220
224
  error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
221
225
  nil
222
226
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.21.0
3
+ version: 0.22.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -64,10 +64,11 @@ module Samagotchi
64
64
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
65
  @reported = true
66
66
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
67
- json: @json, timeout: timeout)
67
+ json: @json, timeout: timeout, extra: report_extra)
68
68
  rescue Interrupt
69
69
  if @json
70
- @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id))
70
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
71
+ extra: report_extra))
71
72
  @stdout.flush
72
73
  @reported = true
73
74
  end
@@ -77,6 +78,12 @@ module Samagotchi
77
78
  error_line("#{command_name}: the session is gone (deleted while waiting)")
78
79
  Exit::FAILED
79
80
  end
81
+
82
+ # More keys for the JSON object: answered_here: false when chi
83
+ # answer's answer didn't go in (the question was no longer open).
84
+ def report_extra
85
+ @answered_here == false ? { answered_here: false } : {}
86
+ end
80
87
  end
81
88
  end
82
89
  end
@@ -41,6 +41,9 @@ module Samagotchi
41
41
  # A question was posted since the last close (it may have been
42
42
  # answered or superseded meanwhile).
43
43
  @asked = false
44
+ # The text a Continue answer came with ({text:, source:}): it steers
45
+ # the continue turn (#run_continue_turn).
46
+ @continue_steer = nil
44
47
  end
45
48
 
46
49
  attr_writer :queue_command
@@ -62,6 +65,7 @@ module Samagotchi
62
65
  def drop(origin)
63
66
  return unless awaiting?
64
67
 
68
+ @continue_steer = nil
65
69
  close("dropped")
66
70
  @engine.synchronize_events do
67
71
  @turn_flow.drop_offer!
@@ -95,6 +99,10 @@ module Samagotchi
95
99
  close(resolved ? "answered" : "dropped") unless awaiting?
96
100
  return unless resolved
97
101
 
102
+ # Answered without a turn (a typed /continue no that got there before
103
+ # the answer's /continue yes): the answer's text steers nothing.
104
+ @continue_steer = nil unless result.resume
105
+
98
106
  # An offer answered without a turn ("no") is activity: the recap
99
107
  # written at the offer (it says the turn stopped) gets rewritten.
100
108
  @engine.record_activity unless result.resume
@@ -103,18 +111,26 @@ module Samagotchi
103
111
  # The continue offer was answered yes: resume the conversation without a
104
112
  # user message, with the iteration limit the offer's turn had. A failure
105
113
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
106
- # asks again.
114
+ # asks again. The answer's text is carried into the turn as it begins;
115
+ # a turn that never began (a raise before it) leaves it to no other.
107
116
  def run_continue_turn(command)
108
117
  offer = @turn_flow.offer
109
118
  close("answered")
110
- @turn_flow.before_continue_turn
111
- @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
112
- max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
113
- if error
114
- open(offer)
115
- else
116
- after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
119
+ steer = @continue_steer
120
+ @continue_steer = nil
121
+ begin
122
+ @engine.steer_next_turn(steer[:text], source: steer[:source]) if steer
123
+ @turn_flow.before_continue_turn
124
+ @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
125
+ max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
126
+ if error
127
+ open(offer)
128
+ else
129
+ after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
130
+ end
117
131
  end
132
+ ensure
133
+ @engine.drop_carried_steers if steer
118
134
  end
119
135
  end
120
136
 
@@ -164,9 +180,18 @@ module Samagotchi
164
180
  end
165
181
 
166
182
  # The question's answer as the /continue line a typed answer would be:
167
- # Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
183
+ # Continue → yes, and a text with it steers the continue turn (from the
184
+ # parent agent when the answer says so); Stop → no; a text (with Stop,
185
+ # or alone) → no, <text>.
168
186
  def answered(answer, client_id:)
187
+ # The /continue line takes the text on one line; the steer keeps its
188
+ # line breaks.
169
189
  text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
190
+ @continue_steer = nil
191
+ if Array(answer[:selected]).include?(CONTINUE) && !text.empty?
192
+ @continue_steer = { text: answer[:freeform].to_s.strip,
193
+ source: answer[:by] == "parent_agent" ? "parent_agent" : "user" }
194
+ end
170
195
  line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
171
196
  elsif text.empty? then "/continue no"
172
197
  else "/continue no, #{text}"
@@ -325,6 +325,21 @@ module Samagotchi
325
325
  @turn_state.steer(text, source: source)
326
326
  end
327
327
 
328
+ # Like #steer, for the turn that begins next (a continue turn answered
329
+ # with a text): it joins that turn at its first boundary. Callable from
330
+ # any thread; a turn that never begins keeps it for the next one, unless
331
+ # #drop_carried_steers drops it.
332
+ # @return [Boolean] whether it was queued (false: blank)
333
+ def steer_next_turn(text, source:)
334
+ @turn_state.steer_next_turn(text, source: source)
335
+ end
336
+
337
+ # Drop the #steer_next_turn steers no turn began with (the continue turn
338
+ # failed before it began), logged; a no-op once a turn took them.
339
+ def drop_carried_steers
340
+ log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
341
+ end
342
+
328
343
  # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
329
344
  # in a --non-interactive run) and the plugin steers. At the after-answer
330
345
  # boundary the steers are dropped: the model answered, and a nudge would
@@ -1089,7 +1104,8 @@ module Samagotchi
1089
1104
  # @param limit [Integer, nil] the turn ran out of iterations at this
1090
1105
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1091
1106
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1092
- def record_last_turn(session, outcome, seconds, origin, limit: nil)
1107
+ # @param event [Hash] the turn's end event (#stop_facts)
1108
+ def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1093
1109
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1094
1110
  source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1095
1111
  elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
@@ -1098,9 +1114,23 @@ module Samagotchi
1098
1114
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1099
1115
  "seconds" => seconds.round(1), "origin" => source }
1100
1116
  session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1117
+ session.last_turn.merge!(stop_facts(event))
1101
1118
  end
1102
1119
  private :record_last_turn
1103
1120
 
1121
+ # Why a turn stopped, from its end event, for a parent agent's wait
1122
+ # (ReplyWait): a provider error's kind and whether a retry may help, a
1123
+ # cancel's reason and the hook that stopped it. Only what is known.
1124
+ def stop_facts(event)
1125
+ case event&.dig(:type)
1126
+ when :turn_failed then { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable] }.compact
1127
+ when :turn_canceled
1128
+ { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1129
+ else {}
1130
+ end
1131
+ end
1132
+ private :stop_facts
1133
+
1104
1134
  # How every turn ends (completed, canceled, failed): the session gets
1105
1135
  # what the turn kept, goes idle and records the ending, and the end
1106
1136
  # event is announced, as one step of the event log (a snapshot taken
@@ -1118,7 +1148,7 @@ module Samagotchi
1118
1148
  kept, event = yield(seconds)
1119
1149
  replace_session_messages(turn.session, kept) if kept
1120
1150
  turn.session.status = Session::STATUS_IDLE
1121
- record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
1151
+ record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
1122
1152
  emit_event(turn.on_event, turn.tag(event))
1123
1153
  end
1124
1154
  turn.ended = true
@@ -22,6 +22,9 @@ module Samagotchi
22
22
  # ConnectionError no answer (refused, reset, timeout); RetryExhausted,
23
23
  # ConnectionRefused
24
24
  # RateLimited 429, with retry_after when the server says
25
+ # CreditsHeld 402 "… in-flight requests": credit reserved by other
26
+ # requests; a RateLimited, retried after 20 s
27
+ # OutOfCredits any other 402; never retried
25
28
  # ServerError 5xx or a server's error event mid-stream
26
29
  # AuthError 401/403, or an API key variable that is not set
27
30
  # BadRequest other 4xx (a context overflow is one, whatever status;
@@ -84,6 +87,24 @@ module Samagotchi
84
87
  def default_retryable? = true
85
88
  end
86
89
 
90
+ # A 402 that says the account's credit is reserved by requests still in
91
+ # flight (OpenRouter: "This request would exceed your available credits
92
+ # given your current in-flight requests"): it clears once they settle,
93
+ # so it is retried like a 429, after CREDITS_SETTLE_DELAY unless the
94
+ # server says how long.
95
+ class CreditsHeld < RateLimited
96
+ def kind = :credits_held
97
+
98
+ def summary = "credits on host #{host} are held by in-flight requests (retry after #{retry_after.to_f.ceil}s)"
99
+ end
100
+
101
+ # Any other 402: the account has run out of credits. Never retried.
102
+ class OutOfCredits < ProviderError
103
+ def kind = :credits
104
+
105
+ def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
106
+ end
107
+
87
108
  class ServerError < ProviderError
88
109
  def kind = :server
89
110
 
@@ -238,6 +259,10 @@ module Samagotchi
238
259
  VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
239
260
  # A 400 about the thinking fields chi sent (Thinking.chat_fields).
240
261
  REASONING_REFUSED_RE = /reasoning|thinking/i
262
+ # A 402 about credit held by in-flight requests (see CreditsHeld).
263
+ CREDITS_HELD_RE = /in-flight requests/i
264
+ # Seconds to wait before retrying a CreditsHeld without a Retry-After.
265
+ CREDITS_SETTLE_DELAY = 20.0
241
266
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
242
267
 
243
268
  module_function
@@ -259,6 +284,12 @@ module Samagotchi
259
284
  when 401, 403 then AuthError.new(text, **options)
260
285
  when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
261
286
  when 408 then ServerError.new(text, retryable: true, **options)
287
+ when 402
288
+ if CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
289
+ CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
290
+ else
291
+ OutOfCredits.new(text, **options)
292
+ end
262
293
  when 400..499
263
294
  tools = TOOLS_UNSUPPORTED_RE.match?(message)
264
295
  BadRequest.new(text, tools_unsupported: tools,
@@ -6,6 +6,7 @@ require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
7
  require_relative "errors"
8
8
  require_relative "http"
9
+ require_relative "prompt_cache"
9
10
  require_relative "usage"
10
11
  require_relative "utf8_scrub"
11
12
 
@@ -121,7 +122,8 @@ module Samagotchi
121
122
  session_id: nil)
122
123
  body = request_body(messages, tools, model, options)
123
124
  request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
124
- log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body) }
125
+ log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body),
126
+ cache: ("on" if PromptCache.marked?(body[:messages])) }
125
127
  return chat_once(request, cancel_controller, log_fields) unless @stream
126
128
 
127
129
  assembly = Assembly.new
@@ -204,7 +206,7 @@ module Samagotchi
204
206
  def request_body(messages, tools, model, options)
205
207
  body = {
206
208
  model: model,
207
- messages: Array(messages).map { |message| wire_message(message) },
209
+ messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
208
210
  temperature: 0.0,
209
211
  stream: @stream
210
212
  }
@@ -217,6 +219,16 @@ module Samagotchi
217
219
  body.merge(options || {}).compact
218
220
  end
219
221
 
222
+ # Prompt-cache breakpoints (PromptCache) on the wire copies, for a
223
+ # Claude model's chat requests to a remote host: a one-shot side ask
224
+ # would pay for a cache write nothing reads, and a local model named
225
+ # after Claude must not get Array content.
226
+ def cache_breakpoints(messages, model)
227
+ return messages unless @remote && @purpose == "chat"
228
+
229
+ PromptCache.mark(messages, model: model)
230
+ end
231
+
220
232
  # The body's fields beyond the conversation and the stream, for the
221
233
  # log line: "temperature=0.6 presence_penalty=1.5".
222
234
  def sampling_summary(body)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module LLM
5
+ # Prompt-cache breakpoints for Claude models behind an OpenAI-compatible
6
+ # host (OpenRouter, a gateway). Anthropic caches a prompt only up to a
7
+ # content part marked with cache_control, in the order tools → system →
8
+ # messages, so two marks cover a chat request:
9
+ # - the first system message (with it, the tool list), and
10
+ # - the last non-system message with content, so the next request (the
11
+ # same prefix plus new messages) reads everything up to it.
12
+ # Mid and tail system messages (reminders, turn notes) never take the
13
+ # second mark. Other models' messages pass through untouched.
14
+ module PromptCache
15
+ CONTROL = { type: "ephemeral" }.freeze
16
+
17
+ module_function
18
+
19
+ # Whether +model+ (the bare id a host is asked for) names a Claude model.
20
+ def claude?(model) = model.to_s.match?(/claude/i)
21
+
22
+ # +messages+ (wire messages: symbol keys, content a String or an Array
23
+ # of parts) with the breakpoints set when +model+ is a Claude model;
24
+ # the given messages as they are otherwise. The marked messages and
25
+ # parts are copies: the caller's are never changed.
26
+ # @return [Array<Hash>]
27
+ def mark(messages, model:)
28
+ return messages unless claude?(model)
29
+
30
+ indexes = [system_index(messages), last_index(messages)].compact.uniq
31
+ return messages if indexes.empty?
32
+
33
+ messages.each_with_index.map { |message, index| indexes.include?(index) ? marked(message) : message }
34
+ end
35
+
36
+ # Whether any message in +messages+ carries a breakpoint.
37
+ def marked?(messages)
38
+ Array(messages).any? do |message|
39
+ content = message[:content]
40
+ content.is_a?(Array) && content.any? { |part| part.is_a?(Hash) && control?(part) }
41
+ end
42
+ end
43
+
44
+ def system_index(messages)
45
+ index = messages.index { |message| message[:role].to_s == "system" }
46
+ index if index && content?(messages[index][:content])
47
+ end
48
+
49
+ # The last message that isn't a system message and has content (an
50
+ # assistant tool_calls message has nil or "").
51
+ def last_index(messages)
52
+ messages.rindex { |message| message[:role].to_s != "system" && content?(message[:content]) }
53
+ end
54
+
55
+ def content?(content)
56
+ case content
57
+ when String then !content.empty?
58
+ when Array then content.any? { |part| part.is_a?(Hash) }
59
+ else false
60
+ end
61
+ end
62
+
63
+ # A String becomes one text part; an Array gets the mark on its last
64
+ # text part (the last part when none is text).
65
+ def marked(message)
66
+ content = message[:content]
67
+ return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }]) if content.is_a?(String)
68
+
69
+ target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
70
+ content.rindex { |part| part.is_a?(Hash) }
71
+ part = content[target]
72
+ key = part.key?("type") ? "cache_control" : :cache_control
73
+ parts = content.dup
74
+ parts[target] = part.merge(key => CONTROL)
75
+ message.merge(content: parts)
76
+ end
77
+
78
+ def control?(part) = part.key?(:cache_control) || part.key?("cache_control")
79
+
80
+ private_class_method :system_index, :last_index, :content?, :marked, :control?
81
+ end
82
+ end
83
+ end
@@ -70,8 +70,10 @@ module Samagotchi
70
70
 
71
71
  # @param session_id [String]
72
72
  # @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
73
+ # @param extra [Hash] more keys for the object (chi answer's
74
+ # answered_here: false)
73
75
  # @return [Hash] the --format json object
74
- def json(result, session_id:, timeout: nil)
76
+ def json(result, session_id:, timeout: nil, extra: {})
75
77
  report = { status: status(result), session_id: session_id }
76
78
  case report[:status]
77
79
  when "answered" then report[:text] = result.text.to_s
@@ -79,24 +81,39 @@ module Samagotchi
79
81
  question = question(result.question)
80
82
  report[:question] = question
81
83
  report[:answer_with] = answer_with(session_id, question)
82
- else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
84
+ else
85
+ report.merge!(stop_facts(result))
86
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout)
87
+ end
88
+ report.merge(extra)
89
+ end
90
+
91
+ # Why a failed or canceled turn stopped, as far as last_turn knows
92
+ # (ReplyWait::Result): error_kind and retryable, cancel_reason and
93
+ # stopped_by; absent when unknown.
94
+ # @return [Hash]
95
+ def stop_facts(result)
96
+ case status(result)
97
+ when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
98
+ when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
99
+ else {}
83
100
  end
84
- report
85
101
  end
86
102
 
87
103
  # @return [String] the JSON object as one line
88
- def json_line(result, session_id:, timeout: nil)
89
- JSON.generate(json(result, session_id: session_id, timeout: timeout))
104
+ def json_line(result, session_id:, timeout: nil, extra: {})
105
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
90
106
  end
91
107
 
92
108
  # Print a wait's end the way the commands do: the JSON object on
93
109
  # stdout, or the reply on stdout and anything else on stderr (a
94
110
  # question in full) after "chi send: ".
95
111
  # @param command [String] "chi send", "chi answer"
112
+ # @param extra [Hash] more keys for the JSON object (#json)
96
113
  # @return [Integer] the exit status
97
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
114
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
98
115
  if json
99
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
116
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
100
117
  elsif result.status == :done
101
118
  stdout.puts(result.text)
102
119
  else
@@ -455,7 +455,7 @@ module Samagotchi
455
455
  def settle_standing(standing, answer, client_id)
456
456
  clear_saved_question
457
457
  emit({ type: :question_answered, id: answer[:id], answer: answer })
458
- standing[:on_answer].call(answer.except(:by), client_id: client_id)
458
+ standing[:on_answer].call(answer, client_id: client_id)
459
459
  rescue StandardError => e
460
460
  Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
461
461
  end
@@ -472,12 +472,6 @@ module Samagotchi
472
472
  raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})" unless invalid.empty?
473
473
  raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
474
474
  raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
475
- # A step-limit question: Continue resumes the turn as it was; a text
476
- # goes with Stop (steering a continue is not a thing).
477
- if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
478
- raise ArgumentError, "Continue takes no text (a text goes with Stop)"
479
- end
480
-
481
475
  indices = sel.map { |v| opts.index(v) }
482
476
  # The one whose answer settles the approval (Approval.settle, by index).
483
477
  if parent_setting
@@ -33,7 +33,16 @@ module Samagotchi
33
33
  # "failed", text is the failure's summary when the turn note has one.
34
34
  # @!attribute limit [Integer, nil] the iteration limit an "exhausted"
35
35
  # turn ran out at
36
- Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, keyword_init: true)
36
+ # @!attribute error_kind [String, nil] a "failed" turn's provider error
37
+ # kind (credits, server, auth, …), from last_turn
38
+ # @!attribute retryable [Boolean, nil] whether that error may pass on a
39
+ # retry
40
+ # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
41
+ # (user, hook, ctrl_c, manual)
42
+ # @!attribute stopped_by [String, nil] the hook that canceled it
43
+ # (loop-guard)
44
+ Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
45
+ :stopped_by, keyword_init: true)
37
46
 
38
47
  module_function
39
48
 
@@ -151,7 +160,8 @@ module Samagotchi
151
160
  return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
152
161
 
153
162
  text = outcome == "failed" ? failure_summary(session.messages) : nil
154
- Result.new(status: :no_reply, outcome: outcome, text: text)
163
+ why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
164
+ Result.new(status: :no_reply, outcome: outcome, text: text, **why)
155
165
  end
156
166
 
157
167
  # The summary in the failed-turn note at the conversation's tail, nil
@@ -23,6 +23,7 @@ module Samagotchi
23
23
  @controller = nil
24
24
  @sink = nil
25
25
  @steers = []
26
+ @carried_steers = []
26
27
  end
27
28
 
28
29
  # ── Activity clock ──────────────────────────────────────────────────
@@ -69,6 +70,8 @@ module Samagotchi
69
70
  @running = true
70
71
  @controller = controller
71
72
  @sink = sink
73
+ @steers.concat(@carried_steers)
74
+ @carried_steers = []
72
75
  end
73
76
  end
74
77
 
@@ -118,6 +121,25 @@ module Samagotchi
118
121
  true
119
122
  end
120
123
 
124
+ # Queue +text+ for the next turn that begins: it is a steer of that turn
125
+ # from its first boundary on (a continue turn's answer text, which has
126
+ # no turn running yet to steer). Taken by #begin!.
127
+ # @return [Boolean] whether it was queued (false: blank)
128
+ def steer_next_turn(text, source:)
129
+ text = text.to_s.strip
130
+ return false if text.empty?
131
+
132
+ @lock.synchronize { @carried_steers << { text: text, source: source.to_s } }
133
+ true
134
+ end
135
+
136
+ # Drop what #steer_next_turn queued and no turn began with (a no-op
137
+ # once #begin! took it).
138
+ # @return [Array<Hash>] the steers dropped ({text:, source:})
139
+ def drop_carried_steers
140
+ @lock.synchronize { @carried_steers.tap { @carried_steers = [] } }
141
+ end
142
+
121
143
  # @return [Array<Hash>] the steers queued so far ({text:, source:}), taken
122
144
  def take_steers
123
145
  @lock.synchronize { @steers.tap { @steers = [] } }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.21.0"
4
+ VERSION = "0.22.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -270,6 +270,7 @@ files:
270
270
  - lib/samagotchi/llm/native_backend.rb
271
271
  - lib/samagotchi/llm/native_tool_normalizer.rb
272
272
  - lib/samagotchi/llm/openai_chat.rb
273
+ - lib/samagotchi/llm/prompt_cache.rb
273
274
  - lib/samagotchi/llm/turn_settings.rb
274
275
  - lib/samagotchi/llm/usage.rb
275
276
  - lib/samagotchi/llm/utf8_scrub.rb