samagotchi 0.82.0 → 0.83.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 +18 -1
- data/README.md +2 -2
- data/docs/architecture.md +3 -0
- data/docs/configuration.md +52 -9
- data/docs/hooks.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +57 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +1 -0
- data/docs/sub-agent.md +4 -0
- data/lib/samagotchi/bundles/core/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +11 -0
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +228 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +25 -8
- data/lib/samagotchi/engine.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +8 -1
- data/lib/samagotchi/reply_wait.rb +13 -15
- data/lib/samagotchi/session_metrics/cost.rb +37 -0
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/testing/host.rb +26 -1
- data/lib/samagotchi/testing/recorder.rb +4 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
- data/lib/samagotchi/tools/delegate_wait.rb +40 -6
- data/lib/samagotchi/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b7fb93e6e4741b5380cfaeaa2a27d37dd326099f2d3cf568b659941e9c34930
|
|
4
|
+
data.tar.gz: bc53710bd1f4dab4d2d5d4e1eb23814f367fc47833b930fd6bd7ee3a1411423b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 882ac69395d70f9b27e8b9922b704c1b1818ad400eeb4551dac38c905df7822d17ac43450f71b5c5a0653b0af9b45470098981f9ee25e36dc2f45a1bb2a9eeee
|
|
7
|
+
data.tar.gz: 1e982daa8adf0881d87ea82256e96565cceb09ab4d6793325a87ce0214df3355a5798c3da75f9afab30126aba7bd5f7f9facf7dfd1f032e3c2cfd9a5e4aaeed4
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.83.0] - 2026-10-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Bundles: `ctx.cost` reads the session's spend so far (reported + estimated, side requests included).
|
|
16
|
+
- The cost-cap bundle (in the core profile): a per-session $ cap that pauses the turn with Continue / Stop, `/cap`
|
|
17
|
+
to see or set it; config `bundles: cost-cap: default/child/warn_at`. Existing installs get it with `chi update`
|
|
18
|
+
(core 0.3.2, + cost-cap).
|
|
19
|
+
- Docs: docs/configuration.md "When a change applies" says, per config.yml key, whether an edit applies on the
|
|
20
|
+
next turn, at worker start, at session creation or at `chi web` start.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- A delegate reply already taken as a report isn't returned again by `delegate_result` in the same turn.
|
|
25
|
+
- `chi send --wait ID` with no message recognizes a finished turn's reply across time zones.
|
|
26
|
+
|
|
11
27
|
## [0.82.0] - 2026-10-18
|
|
12
28
|
|
|
13
29
|
### Added
|
|
@@ -3364,7 +3380,8 @@ and long-lived sessions.
|
|
|
3364
3380
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3365
3381
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3366
3382
|
|
|
3367
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3383
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.83.0...HEAD
|
|
3384
|
+
[0.83.0]: https://github.com/dm1try/samagotchi/compare/v0.82.0...v0.83.0
|
|
3368
3385
|
[0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
|
|
3369
3386
|
[0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
|
|
3370
3387
|
[0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
|
data/README.md
CHANGED
|
@@ -78,8 +78,8 @@ Messages API, picks the
|
|
|
78
78
|
model (or asks, when there are several), sends one test request and writes
|
|
79
79
|
the config. With a config already there, it adds a `hosts:` entry and keeps
|
|
80
80
|
the rest of the file. Then it installs the `core` bundles (loop-guard,
|
|
81
|
-
check-in, guardrails, slow-commands, agents-md: the safety set for a
|
|
82
|
-
local model) and, on a terminal, offers `dev` (known-names, mcp, btw, skills,
|
|
81
|
+
check-in, guardrails, slow-commands, agents-md, cost-cap: the safety set for a
|
|
82
|
+
small local model, the cost cap inert until you set one) and, on a terminal, offers `dev` (known-names, mcp, btw, skills,
|
|
83
83
|
source-links). Edit the
|
|
84
84
|
file later as in [Configure](#configure); `chi bootstrap --help` has the
|
|
85
85
|
options.
|
data/docs/architecture.md
CHANGED
|
@@ -346,6 +346,9 @@ Side requests (the idle recap, a plugin's `ctx.ask_model` such as `/btw`) are co
|
|
|
346
346
|
`analytics.json` between turns (a running turn's end saves it). `tokens`, `model_segments` and the turn records
|
|
347
347
|
stay the turns' only. `SessionMetrics#persist` is serialized (its own mutex, taken before the metrics one), so two
|
|
348
348
|
threads' saves land in snapshot order.
|
|
349
|
+
A bundle's `ctx.cost` reads the same sums (`SessionMetrics#cost`, a frozen `SessionMetrics::Cost`): the `tokens`
|
|
350
|
+
block's `cost_sum` and `cost_estimate_sum` with the running turn's finished generations folded in, plus the `side`
|
|
351
|
+
block's, so it counts what earlier workers spent too ([plugins.md](plugins.md)).
|
|
349
352
|
|
|
350
353
|
The page's frequent reads of `GET /api/sessions/:id` stay light too:
|
|
351
354
|
- `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
|
data/docs/configuration.md
CHANGED
|
@@ -100,15 +100,15 @@ Behavior:
|
|
|
100
100
|
`config: unknown key 'default.modle' (did you mean 'default.model'?)`.
|
|
101
101
|
Names you choose under the maps below (host names, model ids) don't warn.
|
|
102
102
|
- Environment variables and CLI flags win over config-file values.
|
|
103
|
-
- An edit to the file needs no restart of `chi web
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
through its environment.
|
|
109
|
-
- A worker reads `hosts:` from the file
|
|
110
|
-
started later) sees a host added after its parent started. Hosts
|
|
111
|
-
variable sets them.
|
|
103
|
+
- An edit to the file needs no restart of `chi web`. A new worker reads the
|
|
104
|
+
file when it starts; a running worker sees only settings whose readers check
|
|
105
|
+
the file again (see [When a change applies](#when-a-change-applies)). The web
|
|
106
|
+
model picker refreshes `hosts:` when the file changes, but a running worker
|
|
107
|
+
keeps its host registry until it is restarted. A worker gets the CLI flags of
|
|
108
|
+
the chi that started it through its environment.
|
|
109
|
+
- A newly started worker reads `hosts:` from the file, so a delegate (or any
|
|
110
|
+
session started later) sees a host added after its parent started. Hosts
|
|
111
|
+
come only from config.yml: no environment variable sets them.
|
|
112
112
|
|
|
113
113
|
This lets you run `chi` without repeating common defaults such as model
|
|
114
114
|
and llama host/port on every invocation.
|
|
@@ -125,6 +125,49 @@ any per-run `--memory` values (config baseline first, deduped). A per-run
|
|
|
125
125
|
`--mute NAME` removes an entry from the merged list for that session (see
|
|
126
126
|
"Muting a memory" in cli.md).
|
|
127
127
|
|
|
128
|
+
## When a change applies
|
|
129
|
+
|
|
130
|
+
The config reader checks the file's modification time and size whenever a
|
|
131
|
+
setting is read. That makes a changed value available to a live reader; it
|
|
132
|
+
does not reload every object that already holds a snapshot. "Worker start"
|
|
133
|
+
includes a worker respawn. A running turn keeps the settings it already
|
|
134
|
+
resolved.
|
|
135
|
+
|
|
136
|
+
| Key | Applies | Note |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `default.model` | New session | Existing sessions keep their saved model; change one with `/model`. |
|
|
139
|
+
| `default.input`, `no_default_input`, `no_interrupt` | Next CLI run | These set up that run's first prompt or turn limit. |
|
|
140
|
+
| `default.max_tokens`, `max_tool_output_chars`, `read.*`, `execute.*` except `execute.description`, `image.*` | Next matching request or tool use | An operation already in progress keeps its resolved limits. |
|
|
141
|
+
| `server.host`, `server.port`, `server.transport`, `hosts.*` | Worker start | Includes host addresses, APIs, declared models and host-level settings. The web model picker refreshes `hosts:` on file change; a running worker does not. |
|
|
142
|
+
| `server.open_timeout`, `server.read_timeout`, `server.first_token_timeout` | Worker start | The worker's clients retain the timeouts set when they were built. |
|
|
143
|
+
| `model_aliases` | Next model lookup | A session keeps its resolved model unless it is explicitly switched. |
|
|
144
|
+
| `models.<key>.profile` | Session start or `/model` | It is resolved when that model is selected; editing it does not switch the current model's profile by itself. |
|
|
145
|
+
| Other `models.<key>.*` settings | Next turn or model lookup | Sampling, thinking, vision, context, window, price and steer settings are resolved from the file for their next use. |
|
|
146
|
+
| `thinking.level` | Next turn start | A session's own `/thinking` level is saved in that session and takes precedence; a running turn keeps its level. |
|
|
147
|
+
| `cache.*` | Next request or turn-end warm-up | Cache key and TTL are read for a request; warm-up is checked after a turn. |
|
|
148
|
+
| `retry.max`, `retry.base_delay`, `retry.max_delay` | Worker start | The client's network retry policy is built with its HTTP client. |
|
|
149
|
+
| `retry.empty_answer` | Next empty-answer retry | It is checked when a turn needs to retry an empty answer. |
|
|
150
|
+
| `memories` | Worker start | The running engine holds its preload list. The config list is not saved in the session file, so a respawn reads the current list. |
|
|
151
|
+
| `append_system_prompt` | Worker start for the file list | The config list is not saved in the session file. File text is read when the prompt is built; `/model` rebuilds it, while an already-built prompt is cached. CLI `--append-system-prompt` files are saved in the session. |
|
|
152
|
+
| `toolkit.default`, `toolkit.child` | Session creation | The chosen toolkit is saved; changing the defaults does not move existing sessions. |
|
|
153
|
+
| `toolkit.kits`, `execute.description`, `llm_context.policy` | Worker start | These are held by the engine's toolkit or tool registry. |
|
|
154
|
+
| `thinking.turn_preamble`, `skip_agents_md` | Prompt rebuild | A running engine caches its built prompt; a new worker or `/model` rebuild uses the changed value. |
|
|
155
|
+
| `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `status.line` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. |
|
|
156
|
+
| `context.every_seconds` | Next context poll | The poller checks it for each source that has no explicit interval. |
|
|
157
|
+
| `guardrails.*` | Next tool call or approval | Guardrail rules and disable lists are reloaded when the config file changes; rule settings take effect on that evaluation. |
|
|
158
|
+
| `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
|
|
159
|
+
| `recap.*` | Worker start | The engine builds its recap job once. |
|
|
160
|
+
| `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
|
|
161
|
+
| `session.idle_exit_minutes`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer; the other values are read for the next corresponding decision. |
|
|
162
|
+
| `session.retention_*` | Next retention sweep | `chi web` sweeps on its configured schedule and on `GET /api/sessions`; `chi sessions prune` uses the values for that invocation. |
|
|
163
|
+
| `web.*` | `chi web` start | Restart `chi web` to change its bind address, port or page settings. |
|
|
164
|
+
| `log.*` | Process start | Each process holds its configured log sink and level after first use; restart that process. |
|
|
165
|
+
| `history.file` | Next history access | The TUI and web composer resolve its path when they read or append history. |
|
|
166
|
+
| `broadcast.*` | Next `chi broadcast` | Each broadcast invocation reads its targeting and triage settings. |
|
|
167
|
+
| `update.*` | Next `chi update` | The command reads which components to update when it runs. |
|
|
168
|
+
| `kitty.*`, `tmux.*` | `chi desktop upgrade` or desktop update | These settings are copied into the desktop helper's launch file. |
|
|
169
|
+
| `model.profile`, `reasoning_history` | Not from config.yml | These are CLI/environment settings; `models:` and `hosts:` provide their model- and host-specific forms. |
|
|
170
|
+
|
|
128
171
|
### Environment variables
|
|
129
172
|
|
|
130
173
|
Every setting has an environment variable (except the ones the table marks
|
data/docs/hooks.md
CHANGED
|
@@ -592,7 +592,7 @@ Notes:
|
|
|
592
592
|
- Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
|
|
593
593
|
- Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
|
|
594
594
|
- A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md). There `ctx.notify`, `ctx.steer` and the other `ctx` helpers act as this page's `event[:notify]`… do for the event the block runs for.
|
|
595
|
-
- Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
|
|
595
|
+
- Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)), `chi bundle install cost-cap` (a plugin: a per-session $ cap, `/cap`, see [Plugins](plugins.md#the-cost-cap-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
|
|
596
596
|
- Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
|
|
597
597
|
|
|
598
598
|
Lifecycle:
|
data/docs/memory.md
CHANGED
|
@@ -316,7 +316,7 @@ profiles, meta bundles that install a set of them:
|
|
|
316
316
|
|
|
317
317
|
| profile | bundles |
|
|
318
318
|
|---|---|
|
|
319
|
-
| `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
|
|
319
|
+
| `core` | loop-guard, check-in, guardrails, slow-commands, agents-md, cost-cap: the recommended safety set |
|
|
320
320
|
| `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer, codex-peer |
|
|
321
321
|
|
|
322
322
|
`chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
|
data/docs/plugins.md
CHANGED
|
@@ -730,6 +730,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
730
730
|
| `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
|
|
731
731
|
| `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
|
|
732
732
|
| `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
|
|
733
|
+
| `ctx.cost` | the session's spend so far, read now, as a frozen value: `total` (USD, reported + estimated, side requests included; 0.0 before any), `reported` (by providers, `usage.cost`), `estimated` (from `hosts.<name>.models` prices; a generation is one or the other, never both), `side` (recap, `/btw`, `ask_model`: their share of `total`) and `known?` (false while generations or side requests ran but none was reported or priced, so a limit on it can't be enforced; a session with some priced generations reads known, the unpriced ones counting 0). It includes the running turn's finished generations and what earlier workers of the session spent. Use its methods, not its class. Needs chi 0.83.0 |
|
|
733
734
|
| `ctx.settings` | the bundle's settings, frozen |
|
|
734
735
|
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
|
|
735
736
|
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
@@ -1081,7 +1082,7 @@ CHANGELOG line and bumps the shipped bundles that use it. Anything else under
|
|
|
1081
1082
|
|
|
1082
1083
|
**Stable**
|
|
1083
1084
|
|
|
1084
|
-
1. `Samagotchi::Plugin::Api` / `chi.*` as documented above, including
|
|
1085
|
+
1. `Samagotchi::Plugin::Api` / `chi.*` and `ctx` as documented above (`ctx.cost`'s value by its methods), including
|
|
1085
1086
|
`Samagotchi::Plugin::ToolResult`, `Samagotchi::Plugin::ModelError` (and its
|
|
1086
1087
|
subclass `Samagotchi::Plugin::ModelCancelled`),
|
|
1087
1088
|
`Samagotchi::Plugin::Service::Stopped`, `Samagotchi::Plugin::Sessions::Error`
|
|
@@ -1145,6 +1146,7 @@ bundle.model_answers << "a summary" # the next ask_model's
|
|
|
1145
1146
|
bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripted and recorded
|
|
1146
1147
|
bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
|
|
1147
1148
|
bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
|
|
1149
|
+
bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
|
|
1148
1150
|
bundle.shutdown
|
|
1149
1151
|
```
|
|
1150
1152
|
|
|
@@ -1168,6 +1170,9 @@ bundle.shutdown
|
|
|
1168
1170
|
**fails the test at teardown** (RSpec and Minitest adapters: after the cleanup, and only a test that passed), so a
|
|
1169
1171
|
spec can't reach the network. With plain `Bundle.load` there is no teardown: check `bundle.http.unmatched`.
|
|
1170
1172
|
`bundle.set(cancelled: true)` makes a call without `cancel:` raise `HttpCancelled`.
|
|
1173
|
+
- **`ctx.cost`** is zero and known unless the test sets it: `bundle.set(cost: 2.5)` (reported USD), a Hash of the
|
|
1174
|
+
parts (`reported:`, `estimated:`, `side:`, `known:`; `total` is reported + estimated) or a callable giving either,
|
|
1175
|
+
called at each read (a counting lambda shows whether the bundle read it).
|
|
1171
1176
|
- **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
|
|
1172
1177
|
go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
|
|
1173
1178
|
- **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
|
|
@@ -1542,6 +1547,57 @@ Not caught (yet):
|
|
|
1542
1547
|
- loops across turns;
|
|
1543
1548
|
- alternating calls (A, B, A, B) that each return something new.
|
|
1544
1549
|
|
|
1550
|
+
## The cost-cap bundle
|
|
1551
|
+
|
|
1552
|
+
`chi bundle install cost-cap` installs the bundle shipped with chi (`chi bootstrap` installs it with the `core`
|
|
1553
|
+
profile; an existing install gets it with `chi update`). It caps what one session spends, so neither you nor the
|
|
1554
|
+
model has to watch `chi sessions stats`: the harness watches the money and the model never sees any of it (no tool,
|
|
1555
|
+
no steer, no prompt text). It is inert without a cap, and there is none by default.
|
|
1556
|
+
|
|
1557
|
+
```yaml
|
|
1558
|
+
# config.yml
|
|
1559
|
+
bundles:
|
|
1560
|
+
cost-cap:
|
|
1561
|
+
default: 5.00 # USD cap for a session with none set; absent/0/off: no cap
|
|
1562
|
+
child: 2.00 # for delegate children; absent: default
|
|
1563
|
+
warn_at: 0.8 # tell the user (only) once at this share of the cap; 0/off: no warning
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
- **What counts:** `ctx.cost`'s total: what the provider reported (`usage.cost`), else the estimate from the
|
|
1567
|
+
model's configured price (`hosts.<name>.models.<id>.price`), plus side requests (the idle recap, `/btw`,
|
|
1568
|
+
`ask_model`), including what earlier workers of the session spent.
|
|
1569
|
+
- **When it checks:** before each generation, so before the money is spent. A generation that crosses the cap is
|
|
1570
|
+
caught at the start of the next one: the overshoot is at most one generation (plus any side requests between).
|
|
1571
|
+
A turn whose last generation crossed it ends normally, and the next turn's first generation asks.
|
|
1572
|
+
- **At `warn_at` of the cap** you get one notice, `cost-cap> $2.41 of $3.00 spent (80%)`; a raised cap warns again.
|
|
1573
|
+
- **At the cap** the turn pauses on a question (header "Cost cap"): `Cost cap reached: $3.04 spent of $3.00.
|
|
1574
|
+
Continue raises it to $6.00.` with **Continue** (another cap's worth: $3 → $6 → $9, as many steps as it takes to
|
|
1575
|
+
pass the spend, saved for the session) and **Stop** (the turn stops, `canceled` with `stopped_by: cost-cap`, and a
|
|
1576
|
+
card shows the spend, reported, estimated and side). With no one to ask (`--non-interactive`) it stops.
|
|
1577
|
+
- **The cap of a session:** the one `/cap` or a Continue set for it, else `child:` for a delegate child, else
|
|
1578
|
+
`default:`. Delegates and continued links each have their own cap; there is no family cap.
|
|
1579
|
+
- **A host it can't watch:** a model that reports no cost and has no configured price (a local server, a free
|
|
1580
|
+
model that reports 0) reads unknown: one notice, `the $3.00 cap can't be enforced: <model> reports no cost and has
|
|
1581
|
+
no price`, and it never stops for an unknown cost. A session with some priced generations counts the unpriced
|
|
1582
|
+
ones as $0.
|
|
1583
|
+
|
|
1584
|
+
**`/cap`** (works while a turn runs; the next generation reads it):
|
|
1585
|
+
|
|
1586
|
+
- `/cap`: `cost-cap: $1.20 spent of $3.00 (reported $1.10, estimated $0.10, side $0.02)`, or `cost-cap: no cap
|
|
1587
|
+
($1.20 spent)`, with `(can't be enforced: no cost reported or priced)` when it can't be.
|
|
1588
|
+
- `/cap 5` or `/cap $5`: the cap (and what Continue adds) becomes $5.00 for this session. A cap at or under the
|
|
1589
|
+
spend is allowed: the next generation asks.
|
|
1590
|
+
- `/cap off` (or `/cap 0`): no cap for this session, over the config's `default:`.
|
|
1591
|
+
|
|
1592
|
+
A parent agent starts a session with a cap by sending `/cap N` as its first message (`chi send --new -m '/cap 3'`).
|
|
1593
|
+
At the cap, `chi send --wait` exits 3 with a question of kind `hook` (header "Cost cap"); `chi answer ID --question
|
|
1594
|
+
Q --option Continue` (or `--option 1`) goes on, `--option Stop` stops the turn ([sub-agent.md](sub-agent.md)). A
|
|
1595
|
+
parent may Continue: nothing restricts it, as with the step limit. A background delegate child at its cap waits on
|
|
1596
|
+
its own card and doesn't wake its parent ([sessions.md](sessions.md#delegating)).
|
|
1597
|
+
|
|
1598
|
+
The state is one file per session, `<data_dir>/sessions/<session id>.json` (`{"cap": 3.0, "step": 3.0}`), removed
|
|
1599
|
+
with the session.
|
|
1600
|
+
|
|
1545
1601
|
## The check-in bundle
|
|
1546
1602
|
|
|
1547
1603
|
`chi bundle install check-in` installs the bundle shipped with chi (`chi
|
data/docs/releasing.md
CHANGED
|
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
|
|
|
13
13
|
and `rake release:check` enforce it). The system bundle upgrades itself when
|
|
14
14
|
chi starts.
|
|
15
15
|
- **Every other shipped bundle** (btw, check-in, guardrails, known-names,
|
|
16
|
-
loop-guard, mcp, skills, slow-commands, agents-md, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
|
|
16
|
+
loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
|
|
17
17
|
`manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
|
|
18
18
|
`chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
|
|
19
19
|
- a change to a bundle's files bumps that bundle's `version:`
|
data/docs/sessions.md
CHANGED
|
@@ -253,6 +253,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
253
253
|
- A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
|
|
254
254
|
- What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
|
|
255
255
|
- **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
|
|
256
|
+
- **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap": a parent waiting on it (`delegate`, `delegate_result`) gets it back as `status: question` with the `chi answer` commands, `--option Continue` or `--option Stop`. A **background** child (`wait: false`) paused at its cap does not wake its parent: delegate reports skip hook questions (they are the user's, on the child's card) and the approval relay carries approvals only, so the child waits on its card, with the web's bell. The parent sees it at its next `delegate_result`.
|
|
256
257
|
- **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
|
|
257
258
|
- The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
|
|
258
259
|
- How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_approvals` by the parent and again by the child.
|
data/docs/sub-agent.md
CHANGED
|
@@ -105,6 +105,10 @@ A chi parent's `delegate_result` gets a child's step-limit question back as a
|
|
|
105
105
|
`delegate session:` (which drops the question), or report back. It isn't
|
|
106
106
|
relayed to the parent's user the way approvals are.
|
|
107
107
|
|
|
108
|
+
A session at its cost cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)) waits the same way, as a
|
|
109
|
+
question of kind `hook` with header "Cost cap" and options `Continue` and `Stop`: `--option Continue` raises the cap
|
|
110
|
+
by another cap's worth and the turn goes on, `--option Stop` ends it `canceled` with `stopped_by: cost-cap`.
|
|
111
|
+
|
|
108
112
|
## Approvals
|
|
109
113
|
|
|
110
114
|
A chi parent (the `delegate` tool) doesn't go through any of this: its
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: core
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Recommended safety set"
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
|
|
7
|
+
# Its members' highest floor (cost-cap's)
|
|
8
|
+
requires_chi: ">= 0.83.0"
|
|
8
9
|
includes:
|
|
9
10
|
- loop-guard
|
|
10
11
|
- check-in
|
|
11
12
|
- guardrails
|
|
12
13
|
- slow-commands
|
|
13
14
|
- agents-md
|
|
15
|
+
- cost-cap
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cost-cap
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:bc6683af0063b90d6a3d7c2796bccf446043dfb1b3f07c04bd54d22d26199e1d
|
|
10
|
+
# ctx.cost is new in chi 0.83.0
|
|
11
|
+
requires_chi: ">= 0.83.0"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The cost-cap bundle (docs/plugins.md, The cost-cap bundle): a per-session
|
|
4
|
+
# cap on what the session spends (ctx.cost: reported + estimated, side
|
|
5
|
+
# requests included). Before each generation it compares the spend with
|
|
6
|
+
# the cap: at warn_at of it the user (only) gets one notice, at the cap the
|
|
7
|
+
# turn pauses on a question, Continue (the cap goes up by a step) or Stop
|
|
8
|
+
# (the turn stops, with a card). With no one to ask it stops. The model
|
|
9
|
+
# never sees any of it: no tool, no steer, no prompt text.
|
|
10
|
+
#
|
|
11
|
+
# Settings (config.yml, bundles: cost-cap:), read at each use from
|
|
12
|
+
# ctx.settings, never at load:
|
|
13
|
+
# default: 5.00 USD cap for a session with none set; absent/0/off: none
|
|
14
|
+
# child: 2.00 for delegate children (ctx.delegate?); absent: default
|
|
15
|
+
# warn_at: 0.8 the share of the cap that warns; 0/off: no warning
|
|
16
|
+
#
|
|
17
|
+
# State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
|
|
18
|
+
# written by /cap and by Continue; "cap": null (or false) is off for the
|
|
19
|
+
# session, over the config. Without a file the cap comes from the settings.
|
|
20
|
+
# The cap is resolved once per session id (ctx.delegate? parses the session
|
|
21
|
+
# file) and kept until /cap or Continue writes a new one.
|
|
22
|
+
require "json"
|
|
23
|
+
require "fileutils"
|
|
24
|
+
|
|
25
|
+
class Plugin
|
|
26
|
+
USAGE = "usage: /cap [N | $N | off] (USD; /cap alone shows the spend)"
|
|
27
|
+
OPTIONS = %w[Continue Stop].freeze
|
|
28
|
+
WARN_AT = 0.8
|
|
29
|
+
|
|
30
|
+
def initialize(_settings)
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@resolved = nil # {session:, cap:, step:}
|
|
33
|
+
@warned = nil # [session id, cap] the share warning was given for
|
|
34
|
+
@unknown_warned = nil # the session id the "can't be enforced" notice was given for
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register(chi)
|
|
38
|
+
chi.on(:before_generation) { |_event, ctx| check(ctx) }
|
|
39
|
+
# Only the "can't be enforced" notice: a turn of one generation (or a
|
|
40
|
+
# session's last) has no next before_generation to give it.
|
|
41
|
+
chi.on(:after_generation) { |_event, ctx| check_known(ctx) }
|
|
42
|
+
chi.command("/cap", "see or set this session's cost cap: /cap, /cap 5, /cap off", anytime: true) do |args, ctx|
|
|
43
|
+
command(args, ctx)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The cap in USD (a positive Float) or nil (none, 0, off, false).
|
|
48
|
+
def self.amount(value)
|
|
49
|
+
number = case value
|
|
50
|
+
when Numeric then value.to_f
|
|
51
|
+
when String then value.strip.delete_prefix("$").then { |s| s.match?(/\A\d+(\.\d+)?\z/) ? s.to_f : nil }
|
|
52
|
+
end
|
|
53
|
+
number&.positive? ? number : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
|
|
57
|
+
# doesn't read "$0.00 spent of $0.00".
|
|
58
|
+
def self.money(value)
|
|
59
|
+
return format("$%.2f", value) if value.zero? || value.abs >= 0.1
|
|
60
|
+
|
|
61
|
+
format("$%.4f", value).sub(/(\.\d\d\d*?)0+\z/, '\1')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def check(ctx)
|
|
67
|
+
resolved = resolve(ctx)
|
|
68
|
+
cap = resolved[:cap]
|
|
69
|
+
return unless cap
|
|
70
|
+
|
|
71
|
+
cost = ctx.cost
|
|
72
|
+
return unenforceable(ctx, resolved[:session], cap) unless cost.known?
|
|
73
|
+
|
|
74
|
+
total = cost.total
|
|
75
|
+
return at_cap(ctx, resolved, cost) if total >= cap
|
|
76
|
+
|
|
77
|
+
share = warn_share(ctx)
|
|
78
|
+
return unless share && total >= share * cap
|
|
79
|
+
return unless first_time?(:@warned, [resolved[:session], cap])
|
|
80
|
+
|
|
81
|
+
ctx.notify("#{money(total)} of #{money(cap)} spent (#{(total / cap * 100).floor}%)", level: :warn)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_known(ctx)
|
|
85
|
+
resolved = resolve(ctx)
|
|
86
|
+
return unless resolved[:cap]
|
|
87
|
+
|
|
88
|
+
unenforceable(ctx, resolved[:session], resolved[:cap]) unless ctx.cost.known?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unenforceable(ctx, session, cap)
|
|
92
|
+
return unless first_time?(:@unknown_warned, session)
|
|
93
|
+
|
|
94
|
+
ctx.notify("the #{money(cap)} cap can't be enforced: #{ctx.model || "the model"} reports no cost and has no price",
|
|
95
|
+
level: :warn)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def at_cap(ctx, resolved, cost)
|
|
99
|
+
cap = resolved[:cap]
|
|
100
|
+
raised = raised_cap(cap, resolved[:step] || cap, cost.total)
|
|
101
|
+
answer = ctx.ask_user(question: "Cost cap reached: #{money(cost.total)} spent of #{money(cap)}. " \
|
|
102
|
+
"Continue raises it to #{money(raised)}.",
|
|
103
|
+
header: "Cost cap", options: OPTIONS)
|
|
104
|
+
if continue?(answer)
|
|
105
|
+
save(ctx, resolved[:session], "cap" => raised, "step" => resolved[:step] || cap)
|
|
106
|
+
ctx.notify("cap raised to #{money(raised)}")
|
|
107
|
+
else
|
|
108
|
+
stop(ctx, cap, cost)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Another step on the cap, as many steps as it takes to pass the spend
|
|
113
|
+
# (a turn that overshot by more than a step asks once, not each step).
|
|
114
|
+
def raised_cap(cap, step, total)
|
|
115
|
+
raised = cap + step
|
|
116
|
+
raised += step while raised <= total
|
|
117
|
+
raised.round(6)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def continue?(answer)
|
|
121
|
+
return false unless answer
|
|
122
|
+
|
|
123
|
+
Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stop(ctx, cap, cost)
|
|
127
|
+
return unless ctx.stop_turn("cost cap reached (#{money(cost.total)} of #{money(cap)})")
|
|
128
|
+
|
|
129
|
+
ctx.card(title: "cost-cap stopped the turn",
|
|
130
|
+
body: "Spent #{breakdown(cost)} of a #{money(cap)} cap.\n\n`/cap N` raises it, `/cap off` removes it.",
|
|
131
|
+
level: :warn)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def command(args, ctx)
|
|
135
|
+
arg = args.to_s.strip.downcase
|
|
136
|
+
return show(ctx) if arg.empty?
|
|
137
|
+
|
|
138
|
+
session = ctx.session_id
|
|
139
|
+
return "cost-cap: no session yet" unless session
|
|
140
|
+
|
|
141
|
+
if %w[off 0].include?(arg)
|
|
142
|
+
save(ctx, session, "cap" => nil)
|
|
143
|
+
return "cost-cap: no cap for this session"
|
|
144
|
+
end
|
|
145
|
+
value = arg.match?(/\A\$?\d+(\.\d+)?\z/) ? self.class.amount(arg) : nil
|
|
146
|
+
return USAGE unless value
|
|
147
|
+
|
|
148
|
+
save(ctx, session, "cap" => value, "step" => value)
|
|
149
|
+
"cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
"cost-cap: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def show(ctx)
|
|
155
|
+
cost = ctx.cost
|
|
156
|
+
cap = resolve(ctx)[:cap]
|
|
157
|
+
return "cost-cap: no cap (#{money(cost.total)} spent)" unless cap
|
|
158
|
+
|
|
159
|
+
line = "cost-cap: #{money(cost.total)} spent of #{money(cap)} (#{parts(cost)})"
|
|
160
|
+
cost.known? ? line : "#{line} (can't be enforced: no cost reported or priced)"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parts(cost) = "reported #{money(cost.reported)}, estimated #{money(cost.estimated)}, side #{money(cost.side)}"
|
|
164
|
+
|
|
165
|
+
def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
|
|
166
|
+
|
|
167
|
+
# The session's cap and step now: the state file's, else child: for a
|
|
168
|
+
# delegate, else default:. Cached per session id.
|
|
169
|
+
def resolve(ctx)
|
|
170
|
+
session = ctx.session_id
|
|
171
|
+
cached = @mutex.synchronize { @resolved }
|
|
172
|
+
return cached if cached && cached[:session] == session
|
|
173
|
+
|
|
174
|
+
resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
|
|
175
|
+
@mutex.synchronize { @resolved = resolved }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def state(ctx, session)
|
|
179
|
+
return nil unless session
|
|
180
|
+
|
|
181
|
+
saved = JSON.parse(File.read(state_path(ctx, session)))
|
|
182
|
+
return nil unless saved.is_a?(Hash) && saved.key?("cap")
|
|
183
|
+
|
|
184
|
+
cap = self.class.amount(saved["cap"])
|
|
185
|
+
{ cap: cap, step: self.class.amount(saved["step"]) || cap }
|
|
186
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def configured(ctx)
|
|
191
|
+
settings = ctx.settings
|
|
192
|
+
return { cap: nil, step: nil } unless settings.key?("default") || settings.key?("child")
|
|
193
|
+
|
|
194
|
+
key = settings.key?("child") && ctx.delegate? ? "child" : "default"
|
|
195
|
+
cap = self.class.amount(settings[key])
|
|
196
|
+
{ cap: cap, step: cap }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def warn_share(ctx)
|
|
200
|
+
settings = ctx.settings
|
|
201
|
+
return WARN_AT unless settings.key?("warn_at")
|
|
202
|
+
|
|
203
|
+
share = settings["warn_at"]
|
|
204
|
+
share.is_a?(Numeric) && share.positive? ? share : nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def save(ctx, session, data)
|
|
208
|
+
path = state_path(ctx, session)
|
|
209
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
210
|
+
Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
|
|
211
|
+
cap = self.class.amount(data["cap"])
|
|
212
|
+
@mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
|
|
216
|
+
|
|
217
|
+
def money(value) = self.class.money(value)
|
|
218
|
+
|
|
219
|
+
# Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
|
|
220
|
+
def first_time?(ivar, key)
|
|
221
|
+
@mutex.synchronize do
|
|
222
|
+
next false if instance_variable_get(ivar) == key
|
|
223
|
+
|
|
224
|
+
instance_variable_set(ivar, key)
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
require_relative "client_id"
|
|
5
6
|
require_relative "session"
|
|
6
7
|
require_relative "session_inbox"
|
|
@@ -129,7 +130,7 @@ module Samagotchi
|
|
|
129
130
|
# (DelegateWait.finish, its report variant), the cursor it moves the child to,
|
|
130
131
|
# and the cursor it was read from (a commit after the model moved it
|
|
131
132
|
# itself, with delegate_result, leaves it alone).
|
|
132
|
-
Report = Data.define(:child_id, :text, :before, :after, :rings) do
|
|
133
|
+
Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
|
|
133
134
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
134
135
|
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
135
136
|
end
|
|
@@ -142,6 +143,7 @@ module Samagotchi
|
|
|
142
143
|
@state_dir = state_dir
|
|
143
144
|
@session_dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
144
145
|
@predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
|
|
146
|
+
Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
|
|
145
147
|
# child id → the Report this turn took for it (rings merged).
|
|
146
148
|
@taken = {}
|
|
147
149
|
end
|
|
@@ -177,12 +179,16 @@ module Samagotchi
|
|
|
177
179
|
end
|
|
178
180
|
|
|
179
181
|
# The turn that took the reports was kept: move each child's cursor on
|
|
180
|
-
#
|
|
181
|
-
# delegate_result), then delete the rings.
|
|
182
|
+
# and delete the rings.
|
|
182
183
|
def commit
|
|
183
184
|
@taken.each_value do |report|
|
|
184
185
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
185
|
-
cursor
|
|
186
|
+
next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
|
|
187
|
+
|
|
188
|
+
before = report.before.with(report_claim: nil)
|
|
189
|
+
after = report.after.with(report_claim: nil)
|
|
190
|
+
current = cursor.with(report_claim: nil)
|
|
191
|
+
current == before ? after : current
|
|
186
192
|
end
|
|
187
193
|
delete_rings(report.rings)
|
|
188
194
|
rescue StandardError => e
|
|
@@ -193,6 +199,11 @@ module Samagotchi
|
|
|
193
199
|
|
|
194
200
|
# The turn was rolled back: its rings stay for the next one.
|
|
195
201
|
def release
|
|
202
|
+
@taken.each_value do |report|
|
|
203
|
+
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
204
|
+
cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
|
|
205
|
+
end
|
|
206
|
+
end
|
|
196
207
|
@taken = {}
|
|
197
208
|
end
|
|
198
209
|
|
|
@@ -237,13 +248,19 @@ module Samagotchi
|
|
|
237
248
|
timeout: 0, turn: before.turn)
|
|
238
249
|
return nil unless reportable?(wait)
|
|
239
250
|
|
|
240
|
-
|
|
241
|
-
# a later wait on it would repeat its reply.
|
|
251
|
+
claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
|
|
242
252
|
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
243
|
-
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
253
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
|
|
244
254
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
255
|
+
claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
|
|
256
|
+
return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
|
|
257
|
+
state_dir: @state_dir)
|
|
258
|
+
|
|
259
|
+
# A reported end is the follow-up turn's too (DelegateCursor#turn):
|
|
260
|
+
# a later wait on it would repeat its reply.
|
|
261
|
+
after = after.with(report_claim: claim)
|
|
245
262
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
246
|
-
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
|
|
263
|
+
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
|
|
247
264
|
rescue ArgumentError
|
|
248
265
|
nil # the child is gone
|
|
249
266
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -2581,7 +2581,8 @@ module Samagotchi
|
|
|
2581
2581
|
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
2582
2582
|
# returns [] itself: the || keeps a nil builder safe).
|
|
2583
2583
|
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2584
|
-
frontend: -> { interface }
|
|
2584
|
+
frontend: -> { interface },
|
|
2585
|
+
cost: -> { @metrics.cost }
|
|
2585
2586
|
)
|
|
2586
2587
|
end
|
|
2587
2588
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../log"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
+
require_relative "../session_metrics/cost"
|
|
6
7
|
require_relative "../guardrails/context"
|
|
7
8
|
require_relative "../idle_client"
|
|
8
9
|
require_relative "service"
|
|
@@ -51,10 +52,11 @@ module Samagotchi
|
|
|
51
52
|
# ctx.http call without cancel: uses, nil for none: an init task's own,
|
|
52
53
|
# else the running turn's for work done for it (the turn's thread, or a
|
|
53
54
|
# plugin handler: +handler+) until the turn has ended.
|
|
55
|
+
# +cost+ returns the session's spend now (SessionMetrics::Cost).
|
|
54
56
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
55
57
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
56
58
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
|
|
57
|
-
:cancel_controller, keyword_init: true)
|
|
59
|
+
:cancel_controller, :cost, keyword_init: true)
|
|
58
60
|
|
|
59
61
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
60
62
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -140,6 +142,11 @@ module Samagotchi
|
|
|
140
142
|
# current_model_only names it
|
|
141
143
|
def model_key = @host.model_key&.call
|
|
142
144
|
|
|
145
|
+
# @return [SessionMetrics::Cost] the session's spend so far, read now:
|
|
146
|
+
# #total, #reported, #estimated, #side (Float USD) and #known?
|
|
147
|
+
# (docs/plugins.md); zero and known with no session
|
|
148
|
+
def cost = @host.cost&.call || SessionMetrics::Cost.zero
|
|
149
|
+
|
|
143
150
|
# @return [String, nil] the name of the session's toolkit ("full" for
|
|
144
151
|
# one from before toolkits; nil with no session)
|
|
145
152
|
def toolkit = @host.toolkit&.call
|
|
@@ -181,8 +181,9 @@ module Samagotchi
|
|
|
181
181
|
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
182
182
|
baseline && ended == baseline[:last_turn]
|
|
183
183
|
file = newest_reply(id, state_dir: state_dir)
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
path = File.join(reply_dir(id, state_dir: state_dir), file) if file
|
|
185
|
+
if file && reply_is_turns?(path, ended)
|
|
186
|
+
reply = Result.new(status: :done, text: File.read(path), file: file)
|
|
186
187
|
reply.session = settled(session, id, state_dir: state_dir)
|
|
187
188
|
return reply
|
|
188
189
|
end
|
|
@@ -194,6 +195,9 @@ module Samagotchi
|
|
|
194
195
|
!(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
|
|
195
196
|
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
196
197
|
baseline && ended == baseline[:last_turn]
|
|
198
|
+
# The turn-specific reply lookup above already found its file, if
|
|
199
|
+
# one exists. Without it, don't use the newest file as a fallback:
|
|
200
|
+
# it could belong to another turn.
|
|
197
201
|
return no_reply(session, baseline, fresh: true)
|
|
198
202
|
end
|
|
199
203
|
|
|
@@ -440,19 +444,13 @@ module Samagotchi
|
|
|
440
444
|
cursor.nil? || file > cursor
|
|
441
445
|
end
|
|
442
446
|
|
|
443
|
-
# Whether the reply file +
|
|
444
|
-
# not an earlier turn's: the worker records
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def reply_is_turns?(file, ended_at)
|
|
451
|
-
name = file.to_s
|
|
452
|
-
return false if name.length < 23
|
|
453
|
-
|
|
454
|
-
Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
|
|
455
|
-
rescue ArgumentError
|
|
447
|
+
# Whether the reply file at +path+ is the turn that ended at +ended_at+'s,
|
|
448
|
+
# not an earlier turn's: the worker records ended_at before writing the
|
|
449
|
+
# reply, so its mtime is at least the turn's end and earlier replies'
|
|
450
|
+
# mtimes precede it. Unlike the filename, mtime is independent of TZ.
|
|
451
|
+
def reply_is_turns?(path, ended_at)
|
|
452
|
+
File.mtime(path) >= Time.parse(ended_at.to_s)
|
|
453
|
+
rescue ArgumentError, SystemCallError
|
|
456
454
|
false
|
|
457
455
|
end
|
|
458
456
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionMetrics
|
|
5
|
+
# The session's spend so far (SessionMetrics#cost, what a bundle's
|
|
6
|
+
# ctx.cost reads), in USD: +reported+ by providers (usage.cost) and
|
|
7
|
+
# +estimated+ from configured prices (ModelPrice.estimate: a generation
|
|
8
|
+
# is one or the other, never both), each the main loop's and the side
|
|
9
|
+
# requests' together; +side+ is the side requests' share of the total
|
|
10
|
+
# (recap, /btw, ask_model). +known+ is false while generations or side
|
|
11
|
+
# requests ran but none was reported or priced: a cap can't be enforced
|
|
12
|
+
# on such a session. A session with some priced and some unpriced
|
|
13
|
+
# generations reads known (the unpriced ones count 0).
|
|
14
|
+
# Bundles use its methods only, never this constant's name.
|
|
15
|
+
Cost = Data.define(:reported, :estimated, :side, :known) do
|
|
16
|
+
def self.zero = new(reported: 0.0, estimated: 0.0, side: 0.0, known: true)
|
|
17
|
+
|
|
18
|
+
# @param totals [Totals] the session's sums, the running turn folded in
|
|
19
|
+
# @param side [SideTotals]
|
|
20
|
+
# @return [Cost]
|
|
21
|
+
def self.of(totals, side)
|
|
22
|
+
side_reported = side.cost_sum.to_f
|
|
23
|
+
side_estimated = side.cost_estimate_sum.to_f
|
|
24
|
+
reported = totals.cost_sum.to_f + side_reported
|
|
25
|
+
estimated = totals.cost_estimate_sum.to_f + side_estimated
|
|
26
|
+
ran = (totals.prompt_sum + totals.completion_sum).positive? || side.requests.positive?
|
|
27
|
+
new(reported: reported, estimated: estimated, side: side_reported + side_estimated,
|
|
28
|
+
known: !ran || reported.positive? || estimated.positive?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Float] reported + estimated, side requests included
|
|
32
|
+
def total = reported + estimated
|
|
33
|
+
|
|
34
|
+
def known? = known
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -12,6 +12,7 @@ require_relative "model_price"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_metrics/totals"
|
|
14
14
|
require_relative "session_metrics/model_segment"
|
|
15
|
+
require_relative "session_metrics/cost"
|
|
15
16
|
require_relative "session_metrics/side_totals"
|
|
16
17
|
require_relative "side_usage"
|
|
17
18
|
|
|
@@ -365,6 +366,14 @@ module Samagotchi
|
|
|
365
366
|
end
|
|
366
367
|
end
|
|
367
368
|
|
|
369
|
+
# The session's spend now (ctx.cost): its turns' sums with the running
|
|
370
|
+
# turn's finished generations folded in, and its side requests.
|
|
371
|
+
# Safe from any thread.
|
|
372
|
+
# @return [Cost]
|
|
373
|
+
def cost
|
|
374
|
+
@mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
|
|
375
|
+
end
|
|
376
|
+
|
|
368
377
|
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
369
378
|
# caller (SessionObserver / TerminalUI) but we still guard internally.
|
|
370
379
|
# @param event [Hash]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../cancellation_controller"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
|
+
require_relative "../session_metrics/cost"
|
|
5
6
|
require_relative "recorder"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -48,9 +49,33 @@ module Samagotchi
|
|
|
48
49
|
toolkit: -> { recorder[:toolkit] },
|
|
49
50
|
declared_tools: -> { recorder[:declared_tools] || tool_names.call },
|
|
50
51
|
use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
|
|
51
|
-
cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil }
|
|
52
|
+
cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
|
|
53
|
+
cost: -> { cost(recorder[:cost]) }
|
|
52
54
|
)
|
|
53
55
|
end
|
|
56
|
+
|
|
57
|
+
# The parts a test may give bundle.set(cost: {...}).
|
|
58
|
+
COST_PARTS = %i[reported estimated side known].freeze
|
|
59
|
+
|
|
60
|
+
# The SessionMetrics::Cost ctx.cost returns for the recorder's +cost+
|
|
61
|
+
# value (Recorder::DEFAULTS).
|
|
62
|
+
# @return [SessionMetrics::Cost]
|
|
63
|
+
def cost(value)
|
|
64
|
+
value = value.call while value.respond_to?(:call)
|
|
65
|
+
case value
|
|
66
|
+
when nil then SessionMetrics::Cost.zero
|
|
67
|
+
when SessionMetrics::Cost then value
|
|
68
|
+
when Numeric then SessionMetrics::Cost.zero.with(reported: value.to_f)
|
|
69
|
+
when Hash
|
|
70
|
+
unknown = value.keys.map(&:to_sym) - COST_PARTS
|
|
71
|
+
raise ArgumentError, "unknown cost parts: #{unknown.join(", ")}" unless unknown.empty?
|
|
72
|
+
|
|
73
|
+
parts = value.transform_keys(&:to_sym)
|
|
74
|
+
SessionMetrics::Cost.new(reported: parts.fetch(:reported, 0).to_f, estimated: parts.fetch(:estimated, 0).to_f,
|
|
75
|
+
side: parts.fetch(:side, 0).to_f, known: parts.fetch(:known, true))
|
|
76
|
+
else raise ArgumentError, "cost: a number, a Hash or a callable, not #{value.inspect}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
54
79
|
end
|
|
55
80
|
|
|
56
81
|
# The hook runtime (Hooks::Runtime: event[:notify] and the other
|
|
@@ -32,7 +32,10 @@ module Samagotchi
|
|
|
32
32
|
DEFAULTS = {
|
|
33
33
|
session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
|
|
34
34
|
model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
|
|
35
|
-
prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true
|
|
35
|
+
prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true,
|
|
36
|
+
# ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
|
|
37
|
+
# (reported:, estimated:, side:, known:) or a callable giving one.
|
|
38
|
+
cost: nil
|
|
36
39
|
}.freeze
|
|
37
40
|
|
|
38
41
|
attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
|
|
@@ -14,8 +14,9 @@ module Samagotchi
|
|
|
14
14
|
# of the child's turn the last follow-up went to (Delegate.follow_up),
|
|
15
15
|
# whose own reply a wait takes (ReplyWait's turn:); nil once that turn
|
|
16
16
|
# was reported, or when unknown (a new child, an older worker).
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# +report_claim+: a report reserved until the parent's turn is settled.
|
|
18
|
+
DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
|
|
19
|
+
def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
|
|
19
20
|
|
|
20
21
|
def self.empty = new
|
|
21
22
|
|
|
@@ -33,6 +34,13 @@ module Samagotchi
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def to_json_hash = to_h.transform_keys(&:to_s).compact
|
|
37
|
+
|
|
38
|
+
def self.from_json_hash(data)
|
|
39
|
+
return empty unless data.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
|
|
42
|
+
messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
|
|
43
|
+
end
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# The parent's cursors, one per child, in <parent dir>/delegates.json, so
|
|
@@ -68,6 +76,40 @@ module Samagotchi
|
|
|
68
76
|
end
|
|
69
77
|
end
|
|
70
78
|
|
|
79
|
+
# Reserve a report only if the cursor read to build it is still current.
|
|
80
|
+
# @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
|
|
81
|
+
def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
|
|
82
|
+
locked(parent_id, state_dir: state_dir) do
|
|
83
|
+
all = read(parent_id, state_dir: state_dir)
|
|
84
|
+
cursor = get_from(all, child_id)
|
|
85
|
+
next nil unless cursor == expected
|
|
86
|
+
|
|
87
|
+
claimed = cursor.with(report_claim: claim)
|
|
88
|
+
all[child_id] = claimed.to_json_hash
|
|
89
|
+
write(parent_id, all, state_dir: state_dir)
|
|
90
|
+
claimed
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A new owner can retry reports left reserved by a worker that exited
|
|
95
|
+
# before committing or releasing its turn. No cursor file means no
|
|
96
|
+
# claims: the lock would create the session dir and its lock file, so
|
|
97
|
+
# an empty session would no longer count as empty.
|
|
98
|
+
def clear_report_claims(parent_id, state_dir:)
|
|
99
|
+
return unless File.exist?(path(parent_id, state_dir: state_dir))
|
|
100
|
+
|
|
101
|
+
locked(parent_id, state_dir: state_dir) do
|
|
102
|
+
all = read(parent_id, state_dir: state_dir)
|
|
103
|
+
changed = false
|
|
104
|
+
all.each_value do |data|
|
|
105
|
+
next unless data.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
changed = true if data.delete("report_claim")
|
|
108
|
+
end
|
|
109
|
+
write(parent_id, all, state_dir: state_dir) if changed
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
71
113
|
# Add the cursors of +entries+ (child id → cursor fields, as #read
|
|
72
114
|
# gives them) that the parent has none for; one it has is kept.
|
|
73
115
|
# @return [Array<String>] the child ids added
|
|
@@ -94,11 +136,7 @@ module Samagotchi
|
|
|
94
136
|
end
|
|
95
137
|
|
|
96
138
|
def get_from(all, child_id)
|
|
97
|
-
|
|
98
|
-
return DelegateCursor.empty unless data.is_a?(Hash)
|
|
99
|
-
|
|
100
|
-
DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
|
|
101
|
-
question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
|
|
139
|
+
DelegateCursor.from_json_hash(all[child_id])
|
|
102
140
|
end
|
|
103
141
|
|
|
104
142
|
def path(parent_id, state_dir:)
|
|
@@ -24,6 +24,7 @@ module Samagotchi
|
|
|
24
24
|
# the wait's first look (a fast failure) still ends it. The cursors are
|
|
25
25
|
# on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
|
|
26
26
|
module DelegateWait
|
|
27
|
+
Advance = Data.define(:cursor, :already_reported)
|
|
27
28
|
TIMEOUT_DEFAULT = 600
|
|
28
29
|
POLL_INTERVAL = 0.5
|
|
29
30
|
# Seconds with no worker before the child counts as gone: as long as
|
|
@@ -66,6 +67,11 @@ module Samagotchi
|
|
|
66
67
|
reports = reports_mode(peers)
|
|
67
68
|
others = relay && others_for(peers.session_id, child_id, relay, sd)
|
|
68
69
|
cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
|
|
70
|
+
report_claim = cursor.report_claim
|
|
71
|
+
if report_claim
|
|
72
|
+
cursor = DelegateCursor.from_json_hash(report_claim["cursor"]).with(report_claim: report_claim)
|
|
73
|
+
end
|
|
74
|
+
|
|
69
75
|
baseline = cursor.baseline
|
|
70
76
|
left = timeout.to_i
|
|
71
77
|
outcomes = relayed_outcomes.delete(key) || []
|
|
@@ -78,7 +84,13 @@ module Samagotchi
|
|
|
78
84
|
left -= monotonic - started
|
|
79
85
|
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
80
86
|
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
81
|
-
advance(peers.session_id, child_id, wait, state_dir: sd)
|
|
87
|
+
movement = advance(peers.session_id, child_id, wait, state_dir: sd)
|
|
88
|
+
if movement&.already_reported
|
|
89
|
+
file = movement.cursor.report_claim&.fetch("file", nil) || wait.file
|
|
90
|
+
return already_reported_result(child_id, file, state_dir: sd)
|
|
91
|
+
end
|
|
92
|
+
return already_reported_result(child_id, report_claim["file"], state_dir: sd) if report_claim && wait.status == :timeout
|
|
93
|
+
|
|
82
94
|
text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
|
|
83
95
|
return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
|
|
84
96
|
end
|
|
@@ -159,15 +171,37 @@ module Samagotchi
|
|
|
159
171
|
# was reported; a question keeps it. Nothing moves on a timeout or a
|
|
160
172
|
# cancel.
|
|
161
173
|
# @param wait [ReplyWait::Result]
|
|
162
|
-
# @return [
|
|
174
|
+
# @return [Advance, nil] the cursor and whether another path reported it
|
|
163
175
|
def advance(parent_id, child_id, wait, state_dir:)
|
|
164
176
|
return nil unless REPORTED.include?(wait.status) && wait.session
|
|
165
177
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
already_reported = false
|
|
179
|
+
cursor = DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |current|
|
|
180
|
+
if (current.report_claim && current.report_claim["file"] == wait.file) ||
|
|
181
|
+
(wait.status == :done && current.reply_file == wait.file)
|
|
182
|
+
already_reported = true
|
|
183
|
+
next current
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
current = current.with(reply_file: wait.file) if wait.status == :done
|
|
187
|
+
current = current.with(turn: nil) unless wait.status == :waiting_for_answer
|
|
188
|
+
current.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
170
189
|
end
|
|
190
|
+
Advance.new(cursor: cursor, already_reported: already_reported)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def already_reported_result(child_id, file, state_dir:)
|
|
194
|
+
path = if file.to_s.empty?
|
|
195
|
+
nil
|
|
196
|
+
else
|
|
197
|
+
File.join(ReplyWait.reply_dir(child_id, state_dir: state_dir), file)
|
|
198
|
+
end
|
|
199
|
+
text = if path
|
|
200
|
+
"session: #{child_id}\nstatus: reported\nThe reply was already reported to this session (file: #{File.basename(file)})."
|
|
201
|
+
else
|
|
202
|
+
"session: #{child_id}\nstatus: reported\nThe child's latest status was already reported to this session."
|
|
203
|
+
end
|
|
204
|
+
RefText.wrap(text, path)
|
|
171
205
|
end
|
|
172
206
|
|
|
173
207
|
# The tool result for how the wait ended.
|
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.83.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -200,6 +200,8 @@ files:
|
|
|
200
200
|
- lib/samagotchi/bundles/coordinator/plugin.rb
|
|
201
201
|
- lib/samagotchi/bundles/coordinator/skill_coordinator.md
|
|
202
202
|
- lib/samagotchi/bundles/core/manifest.yml
|
|
203
|
+
- lib/samagotchi/bundles/cost-cap/manifest.yml
|
|
204
|
+
- lib/samagotchi/bundles/cost-cap/plugin.rb
|
|
203
205
|
- lib/samagotchi/bundles/dev/manifest.yml
|
|
204
206
|
- lib/samagotchi/bundles/github-pr/manifest.yml
|
|
205
207
|
- lib/samagotchi/bundles/github-pr/plugin.rb
|
|
@@ -469,6 +471,7 @@ files:
|
|
|
469
471
|
- lib/samagotchi/session_inbox.rb
|
|
470
472
|
- lib/samagotchi/session_manager.rb
|
|
471
473
|
- lib/samagotchi/session_metrics.rb
|
|
474
|
+
- lib/samagotchi/session_metrics/cost.rb
|
|
472
475
|
- lib/samagotchi/session_metrics/model_segment.rb
|
|
473
476
|
- lib/samagotchi/session_metrics/side_totals.rb
|
|
474
477
|
- lib/samagotchi/session_metrics/totals.rb
|