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 +4 -4
- data/CHANGELOG.md +30 -1
- data/docs/architecture.md +3 -2
- data/docs/configuration.md +11 -0
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +11 -5
- data/lib/samagotchi/answer_command.rb +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/continue_offer.rb +34 -9
- data/lib/samagotchi/engine.rb +32 -2
- data/lib/samagotchi/llm/errors.rb +31 -0
- data/lib/samagotchi/llm/openai_chat.rb +14 -2
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/question_desk.rb +1 -7
- data/lib/samagotchi/reply_wait.rb +12 -2
- data/lib/samagotchi/turn_state.rb +22 -0
- data/lib/samagotchi/version.rb +1 -1
- 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: 9019626f95da411558ec5350303433de58fd07f9db6f9e22323856e4e2ed31a0
|
|
4
|
+
data.tar.gz: 015df1a469fb64ff5556ce6f683ee23abf039865a34584fff07e3f3c32e4724e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
396
|
-
|
|
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.
|
data/docs/configuration.md
CHANGED
|
@@ -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.
|
|
63
|
-
`--
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
@
|
|
111
|
-
@
|
|
112
|
-
|
|
113
|
-
if
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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}"
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = [] } }
|
data/lib/samagotchi/version.rb
CHANGED
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.
|
|
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
|