samagotchi 0.133.0 → 0.135.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 +116 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +50 -34
- data/docs/configuration.md +9 -6
- data/docs/guardrails.md +146 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +7 -6
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/internals/web-frontend.md +262 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -2
- data/docs/testing.md +34 -4
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +82 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
- data/lib/samagotchi/guardrails/pair_command.rb +828 -86
- data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
- data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +70 -16
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +462 -338
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +9 -4
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +27 -0
- data/lib/samagotchi/web/public/index.html +82 -37
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +17 -9
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +16 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
data/docs/sessions.md
CHANGED
|
@@ -6,6 +6,8 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
6
6
|
|
|
7
7
|
**Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
|
|
8
8
|
|
|
9
|
+
**Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`Engine#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`: the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
|
|
10
|
+
|
|
9
11
|
**Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
|
|
10
12
|
|
|
11
13
|
**Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
|
|
@@ -396,8 +398,9 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
|
|
|
396
398
|
- **Pair turns** (a participant's, see [Participants](internals/participants.md)) run their `execute` without the
|
|
397
399
|
project env. MCP servers are the session's, started once in the project env, so a pair turn's MCP calls do use
|
|
398
400
|
it. A participant's command that runs `mise` or `direnv`, and any write to mise's config (`mise.toml`,
|
|
399
|
-
`.mise.toml`, `mise.<env>.toml`, `.config/mise.toml`, `.config/mise/…`, `mise/` or `.mise/config*.toml
|
|
400
|
-
next worker start runs it, and its `[env]` can run commands),
|
|
401
|
+
`.mise.toml`, `mise.<env>.toml`, `.config/mise.toml`, `.config/mise/…`, `mise/` or `.mise/config*.toml`, and
|
|
402
|
+
`.tool-versions`: your next worker start runs it, and its `[env]` can run commands), a delete or move of one
|
|
403
|
+
included, asks you first; a read-only mention (`find . -name mise.toml`) doesn't. `.envrc` and mise's local files
|
|
401
404
|
(`mise.local.toml`, `mise.<env>.local.toml`, dotted or not, and `config.local.toml` / `config.<env>.local.toml` in
|
|
402
405
|
`.config/mise/`, `mise/`, `.mise/`) are secrets to them, as `.env` is.
|
|
403
406
|
- **What tools still drop**: every `execute`, task and `!cmd` takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
|
data/docs/testing.md
CHANGED
|
@@ -24,7 +24,9 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
|
|
|
24
24
|
global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
|
|
25
25
|
It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
|
|
26
26
|
Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
|
|
27
|
-
is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`.
|
|
27
|
+
is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the six
|
|
28
|
+
modules that hold the owner page's session state, one more: no write into `current`, `store.current` or `state()`
|
|
29
|
+
in place (`samagotchi/session-writes`; the store is the write path, see internals/web-frontend.md, The session store).
|
|
28
30
|
|
|
29
31
|
CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
|
|
30
32
|
(`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
|
|
@@ -41,7 +43,7 @@ paths (replace them with placeholders), no PATH tools (the suite pins `rg` as fo
|
|
|
41
43
|
that sets PATH to test the lookup is tagged `:real_rg`), no Ruby-version-dependent `inspect` (a Hash's or an
|
|
42
44
|
exception's text changes between Ruby versions: write the string yourself).
|
|
43
45
|
|
|
44
|
-
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`
|
|
46
|
+
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
|
|
45
47
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
46
48
|
`question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
|
|
47
49
|
`chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
|
|
@@ -69,6 +71,28 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
|
|
|
69
71
|
`repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
|
|
70
72
|
`CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
|
|
71
73
|
|
|
74
|
+
## Levels
|
|
75
|
+
|
|
76
|
+
Each level owns its own share; a detail is asserted once, at the lowest level that has it.
|
|
77
|
+
|
|
78
|
+
| Level | Ruby | Web | Owns |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| Unit | a class, its collaborators stubbed or passed in | `npm test` (node; happy-dom for lit) | the details and the variants: a message's text, each refusal, the edge cases |
|
|
81
|
+
| Functional | a component through its real seams in one process (an Engine on `test_kernel`, a real Bridge, `Web::App` on Rack) | chi web's routes (`spec/web/`, Rack) | the component's happy path and its main negative paths, and the payload it hands the next layer |
|
|
82
|
+
| Process / e2e | a real `bin/chi`, worker or script (`run_chi`, a spawned process) | Playwright (`npm run e2e`) | one run per mechanism (the wiring a lower level can't see: a require, a spawn, a real browser), and a few whole journeys |
|
|
83
|
+
|
|
84
|
+
- **The variants are the units'.** A higher-level spec says which mechanism it is the one test of and where its
|
|
85
|
+
variants live (`remote_attach_stream_spec.rb`, `steer_delivery_e2e_spec.rb`, `terminal_ui/attached_loop_bridge_spec.rb`).
|
|
86
|
+
- **One real chi per mechanism** (spec/support/chi_cli.rb): a file keeps one `run_chi` per mechanism and forks the
|
|
87
|
+
rest (`fork_chi`). A script that runs chi (a bundle's `scripts/pair`) likewise: its mechanism on real chi, its
|
|
88
|
+
variants on a fake one (`FakeChiContext`).
|
|
89
|
+
- **A spec that spawns per assertion gets a seam.** A unit spec that starts a process for every check (a `git`, a
|
|
90
|
+
Ruby boot) passes the collaborator in or fakes it: a guardrails `Context` takes `git:`, so give it one `GitInfo`
|
|
91
|
+
per example, as the wiring keeps one per turn.
|
|
92
|
+
- **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
|
|
93
|
+
a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
|
|
94
|
+
variant to a shared fixture (`spec/fixtures/composer_routes.json`).
|
|
95
|
+
|
|
72
96
|
## Waiting and time
|
|
73
97
|
|
|
74
98
|
A spec waits *for* something and never sleeps to wait: a fixed pause is too short on a loaded runner (a parallel
|
|
@@ -95,6 +119,9 @@ run, CI) and too long on a fast one. The contract a new spec follows:
|
|
|
95
119
|
4. **"Nothing happens" is shown by counting passes** (the worker's loop rounds, spec/support/worker_loop_rounds.rb;
|
|
96
120
|
a class's ticks), not by a pause.
|
|
97
121
|
5. **A wall-clock bound** only as "not the full N s", with at least 5× headroom measured on the slow (x86) laptop.
|
|
122
|
+
Write it `be < time_bound(3, below: 5)`: the bound scaled like a helper's deadline, and kept under the unscaled
|
|
123
|
+
deadline it tells apart from (here a 5 s read timeout), so the check still fails on the full wait at any scale. A
|
|
124
|
+
cost bound with nothing to tell apart from ("no quadratic blow-up") takes no `below:`.
|
|
98
125
|
|
|
99
126
|
The helpers are the bundle test kit's (`Samagotchi::Testing::Waiting`, [plugins.md](plugins.md#testing-a-bundle-experimental)),
|
|
100
127
|
so core and bundle specs share one copy; `SpecWaiting` (spec/support/waiting.rb) includes them in every example
|
|
@@ -107,6 +134,7 @@ group and also answers them as module calls (`SpecWaiting.pop!`, for a stubbed l
|
|
|
107
134
|
same deadline and message. A gate a stubbed lib thread waits on takes `timeout: 30`: it only ends a hang.
|
|
108
135
|
- `wait_until(timeout:) { … }` returns the block's last value (falsy at the deadline): for a value the example
|
|
109
136
|
checks after, or a `wait-ok` wait.
|
|
137
|
+
- `time_bound(seconds, below: nil)`: a wall-clock upper bound, scaled and kept under `below:` (rule 5).
|
|
110
138
|
- `eventually(timeout: 5) { expect(…) }` (spec only) retries until the expectations hold, then re-raises the last
|
|
111
139
|
failure prefixed `still failing after 5 s:`, so the matcher's own expected/got is in the message.
|
|
112
140
|
|
|
@@ -129,8 +157,8 @@ Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integrat
|
|
|
129
157
|
limit, `0` turns it off (a debugger session). The limit is not scaled.
|
|
130
158
|
|
|
131
159
|
**The cops.** `Samagotchi/SpecSleep` (an untagged `sleep` in a spec) and `Samagotchi/SilentWait` (a `wait_until`
|
|
132
|
-
whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`,
|
|
133
|
-
`Timeout.timeout`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
|
|
160
|
+
whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, an untagged
|
|
161
|
+
`Timeout.timeout`, or a statement `thread.join(N)`, whose N is unscaled and whose nil is dropped: use `join!`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
|
|
134
162
|
`eventually`, `join!` and `pop!` as an example's expectation (`RSpec/NoExpectationExample`), so a wait can be an
|
|
135
163
|
example's last line. What the cops can't see, `script/affected-specs` hints at (`timing:`, below).
|
|
136
164
|
|
|
@@ -180,10 +208,12 @@ any other file requires it, so a spec file run alone still finds its requires. E
|
|
|
180
208
|
| `env.rb` | *helper*: `with_env` sets ENV for a block and puts it back (nil unsets) |
|
|
181
209
|
| `failing_saves.rb` | shared context "failing session saves": `Session#save` raising ENOSPC while `saves_fail`, and the worker's `save_failed` records |
|
|
182
210
|
| `fake_chat_adapter.rb` | `FakeChatAdapter`: an `LLM::OpenAIChat` stand-in answering `#chat` with queued steps, recording each request |
|
|
211
|
+
| `fake_chi_context.rb` | `FakeChiContext`: a fake `chi context add/show/push/rm/ls` for the peer bundles' `scripts/pair` variants, logging each call |
|
|
183
212
|
| `fake_executables.rb` | `FakeExecutables`: fake commands (`gh`, `git`) for a spec's PATH, symlinks to one launcher (macOS's first-exec check is paid once) |
|
|
184
213
|
| `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
|
|
185
214
|
| `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
|
|
186
215
|
| `git_repo.rb` | *helper*: `git_repo`, a temp folder with `git init`, removed after the example |
|
|
216
|
+
| `lock_holder.rb` | *helper*: `spawn_lock_holder(session_dir)`, a stand-in worker process holding a session's owner lock, which says so on a pipe |
|
|
187
217
|
| `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
|
|
188
218
|
| `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
|
|
189
219
|
| `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative "atomic_file"
|
|
|
11
11
|
require_relative "bridge_secret"
|
|
12
12
|
require_relative "delivery"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
|
-
require_relative "
|
|
14
|
+
require_relative "turn_anchors"
|
|
15
15
|
|
|
16
16
|
require_relative "bridge/bounded_queue"
|
|
17
17
|
require_relative "bridge/card_store"
|
|
@@ -634,7 +634,7 @@ module Samagotchi
|
|
|
634
634
|
# turn (Guardrails::PairBoundary); a worker without it runs it as a
|
|
635
635
|
# review turn, and chi web says so (Delivery::PAIR_FEATURE).
|
|
636
636
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
637
|
-
VerifyAnchor::FEATURE,
|
|
637
|
+
VerifyAnchor::FEATURE, ReviseAnchor::FEATURE,
|
|
638
638
|
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
|
|
639
639
|
|
|
640
640
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
@@ -1073,7 +1073,10 @@ module Samagotchi
|
|
|
1073
1073
|
# request's principal; the Bridge is loopback and trusts its caller).
|
|
1074
1074
|
author = fetched(parsed, "author")
|
|
1075
1075
|
author = nil unless author.is_a?(String) && !author.empty?
|
|
1076
|
-
|
|
1076
|
+
# A Verify turn's claim, a Revise turn's draft; a participant can't
|
|
1077
|
+
# Revise (chi web drops it first, this is the worker's own layer).
|
|
1078
|
+
anchors = TurnAnchors.from(parsed)
|
|
1079
|
+
anchors = anchors.without_revises if Steer.participant_author?(author)
|
|
1077
1080
|
if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
|
|
1078
1081
|
return [{ "Allow" => "POST" }, 400,
|
|
1079
1082
|
{ error: "missing_fields", detail: "session_id and prompt are required" }]
|
|
@@ -1135,7 +1138,7 @@ module Samagotchi
|
|
|
1135
1138
|
delivery = Delivery::QUEUE if @accumulator.participant_turn?
|
|
1136
1139
|
queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
|
|
1137
1140
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1138
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1141
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors).tap do |ok|
|
|
1139
1142
|
next unless ok
|
|
1140
1143
|
|
|
1141
1144
|
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
@@ -1299,9 +1302,11 @@ module Samagotchi
|
|
|
1299
1302
|
|
|
1300
1303
|
# Write a turn into this session's input dir, reusing the file IPC the
|
|
1301
1304
|
# worker polls. Never calls run_turn across the boundary.
|
|
1302
|
-
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil,
|
|
1305
|
+
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil,
|
|
1306
|
+
anchors: nil)
|
|
1303
1307
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1304
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1308
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1309
|
+
anchors: anchors)
|
|
1305
1310
|
end
|
|
1306
1311
|
|
|
1307
1312
|
# ── HTTP plumbing ────────────────────────────────────────────────────────
|
|
@@ -14,8 +14,10 @@ require_relative "bridge_client/event_stream"
|
|
|
14
14
|
require_relative "bridge_client/stream_request"
|
|
15
15
|
|
|
16
16
|
module Samagotchi
|
|
17
|
-
# Loaded on first use (a Verify
|
|
18
|
-
# budget
|
|
17
|
+
# Loaded on first use (any turn's input, a Verify or a Revise turn's
|
|
18
|
+
# anchor): keeps the bundle test kit's load budget
|
|
19
|
+
# (spec/samagotchi/testing/sandbox_spec.rb).
|
|
20
|
+
autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
|
|
19
21
|
autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
|
|
20
22
|
|
|
21
23
|
# Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
|
|
@@ -192,16 +194,17 @@ module Samagotchi
|
|
|
192
194
|
# owner; sent only when given (SessionManager.deliver_turn)
|
|
193
195
|
# @param pending_limit [Integer, nil] with a participant's +author+: how
|
|
194
196
|
# many of their turns may be running or waiting (429 too_many_turns past it)
|
|
197
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
198
|
+
# turn's draft: their own body keys ("verify", "revises")
|
|
195
199
|
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
|
|
196
|
-
|
|
200
|
+
anchors: nil)
|
|
197
201
|
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
198
202
|
body[:no_interrupt] = true if no_interrupt
|
|
199
203
|
body[:author] = author if author
|
|
200
204
|
body[:pending_limit] = pending_limit if author && pending_limit
|
|
201
205
|
body[:images] = images if images && !images.empty?
|
|
202
206
|
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
203
|
-
|
|
204
|
-
body[:verify] = anchor.to_file if anchor
|
|
207
|
+
body.merge!(TurnAnchors.from(anchors).to_file) if anchors
|
|
205
208
|
body[:deadline] = deadline
|
|
206
209
|
post("turn", body, read_body: true)
|
|
207
210
|
end
|
data/lib/samagotchi/chi_home.rb
CHANGED
|
@@ -42,6 +42,15 @@ module Samagotchi
|
|
|
42
42
|
class Invalid < StandardError
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# A record of this process under Bundler (`bundle exec chi home set`)
|
|
46
|
+
# would name the bundle's Ruby and gems as the home every launch boots.
|
|
47
|
+
class UnderBundler < StandardError
|
|
48
|
+
def initialize(msg = "under Bundler (bundle exec) this chi's gem path is the bundle's, not a chi home: " \
|
|
49
|
+
"run chi itself, or name another Ruby with --ruby")
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
# home.json's fields. +gem_path+ is what `Gem.paths=` takes (dirs joined
|
|
46
55
|
# by File::PATH_SEPARATOR); +mise+ an absolute path or nil.
|
|
47
56
|
Record = Data.define(:ruby, :gem_home, :gem_path, :mise, :set_at, :set_by_version) do
|
|
@@ -274,8 +283,9 @@ module Samagotchi
|
|
|
274
283
|
nil
|
|
275
284
|
end
|
|
276
285
|
|
|
277
|
-
#
|
|
278
|
-
#
|
|
286
|
+
# Whether this process runs under Bundler (`bundle exec`), where a
|
|
287
|
+
# record of it would name the bundle's gems: record_first!, chi home
|
|
288
|
+
# set (UnderBundler) and chi update's @bundler.
|
|
279
289
|
def under_bundler?(env = ENV) = !!(defined?(::Bundler) || !env["BUNDLE_GEMFILE"].to_s.empty?)
|
|
280
290
|
|
|
281
291
|
# mise's absolute path: on +env+'s PATH, else in MISE_DIRS; nil without.
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -330,8 +330,6 @@ module Samagotchi
|
|
|
330
330
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli], lifecycle: :web_start),
|
|
331
331
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli], lifecycle: :web_start),
|
|
332
332
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
|
|
333
|
-
# The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
|
|
334
|
-
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
|
|
335
333
|
# The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
|
|
336
334
|
# footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
|
|
337
335
|
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
|
|
@@ -259,6 +259,24 @@ module Samagotchi
|
|
|
259
259
|
"the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
|
|
260
260
|
end
|
|
261
261
|
|
|
262
|
+
# The config dir the helper's chi reads (its home.json, config.yml):
|
|
263
|
+
# the launch file's XDG_CONFIG_HOME when baked, else ~/.config.
|
|
264
|
+
# @return [String]
|
|
265
|
+
def launch_config_home
|
|
266
|
+
baked = read_launch["env"]
|
|
267
|
+
dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
|
|
268
|
+
# Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
|
|
269
|
+
dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Whether the helper's chi reads this chi's config dir (home.json,
|
|
273
|
+
# config.yml): false for a run with XDG_CONFIG_HOME elsewhere (a spec,
|
|
274
|
+
# a smoke run), whose home and bundles the helper never sees, though
|
|
275
|
+
# its launch file sits under the real ~/Library.
|
|
276
|
+
def reads_this_config?
|
|
277
|
+
File.expand_path(launch_config_home) == Paths.config_home(env: @env)
|
|
278
|
+
end
|
|
279
|
+
|
|
262
280
|
# The chi version the launch file names, nil without one.
|
|
263
281
|
def launch_version = read_launch["version"]
|
|
264
282
|
|
|
@@ -316,10 +334,7 @@ module Samagotchi
|
|
|
316
334
|
# reads: the launch file's XDG_CONFIG_HOME when baked; disabled hosts
|
|
317
335
|
# are left out. @return [Array<Hash>] name:, hosts:, state:
|
|
318
336
|
def host_keys
|
|
319
|
-
|
|
320
|
-
dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
|
|
321
|
-
# Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
|
|
322
|
-
config_env = { "XDG_CONFIG_HOME" => dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir }
|
|
337
|
+
config_env = { "XDG_CONFIG_HOME" => launch_config_home }
|
|
323
338
|
hosts = ConfigFile.hosts_config(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
|
|
324
339
|
hosts.map do |name, entries|
|
|
325
340
|
state = if HostKeys.from_keychain(name, env: @env) then :keychain
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "steer"
|
|
|
15
15
|
require_relative "turn_note"
|
|
16
16
|
require_relative "turn_endings"
|
|
17
17
|
require_relative "turn_origin"
|
|
18
|
+
require_relative "revise_anchor"
|
|
18
19
|
require_relative "verify_anchor"
|
|
19
20
|
require_relative "web/participant_view"
|
|
20
21
|
require_relative "verdict"
|
|
@@ -1631,10 +1632,11 @@ module Samagotchi
|
|
|
1631
1632
|
seconds = turn.elapsed
|
|
1632
1633
|
synchronize_events do
|
|
1633
1634
|
kept, event = yield(seconds)
|
|
1634
|
-
|
|
1635
|
+
revises = stamp_opening(kept, turn, outcome)
|
|
1635
1636
|
replace_session_messages(turn.session, kept) if kept
|
|
1636
1637
|
verdict = record_verdict(turn, outcome)
|
|
1637
1638
|
event[:verdict] = verdict if verdict
|
|
1639
|
+
event[:revises] = revises.to_h if revises
|
|
1638
1640
|
emit_working_set(turn.on_event, turn.session, turn.session.messages)
|
|
1639
1641
|
turn.session.status = Session::STATUS_IDLE
|
|
1640
1642
|
record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
|
|
@@ -1647,16 +1649,63 @@ module Samagotchi
|
|
|
1647
1649
|
end
|
|
1648
1650
|
private :end_turn
|
|
1649
1651
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
+
# The turn's anchors, stamped on its opening message (the prompt, or a
|
|
1653
|
+
# wake turn's note) in the messages it keeps: display metadata, never
|
|
1654
|
+
# in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
|
|
1655
|
+
# A Verify turn's claim however it ended; a Revise turn's draft only
|
|
1656
|
+
# once it completed with an answer (#revision_target). Returns the
|
|
1657
|
+
# stamped ReviseAnchor (the end event's revises), or nil.
|
|
1658
|
+
def stamp_opening(messages, turn, outcome)
|
|
1659
|
+
return unless messages.is_a?(Array)
|
|
1660
|
+
|
|
1661
|
+
revises = revision_target(messages, turn, outcome)
|
|
1662
|
+
fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
|
|
1663
|
+
return if fields.empty?
|
|
1652
1664
|
|
|
1653
1665
|
opening = messages.reverse.find do |message|
|
|
1654
|
-
(message[:
|
|
1655
|
-
(message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1666
|
+
Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
|
|
1656
1667
|
end
|
|
1657
|
-
|
|
1668
|
+
return unless opening
|
|
1669
|
+
|
|
1670
|
+
opening.merge!(fields)
|
|
1671
|
+
revises
|
|
1658
1672
|
end
|
|
1659
|
-
private :
|
|
1673
|
+
private :stamp_opening
|
|
1674
|
+
|
|
1675
|
+
# The turn a Revise turn revised (D6: "this turn revised that one"):
|
|
1676
|
+
# only a completed turn with an answer (AnswerTail) has revised
|
|
1677
|
+
# anything. A draft no prompt in the messages carries is no target (a
|
|
1678
|
+
# stale page, a rolled-back turn); one a stamped turn already revised
|
|
1679
|
+
# is re-targeted to its stack's top, the newest version, which the
|
|
1680
|
+
# person meant ("this draft").
|
|
1681
|
+
def revision_target(messages, turn, outcome)
|
|
1682
|
+
return unless turn.revises && outcome == "completed"
|
|
1683
|
+
|
|
1684
|
+
slice = AnswerTail.turn_slice(messages, turn.id)
|
|
1685
|
+
return unless slice&.any? { |message| AnswerTail.answer?(message) }
|
|
1686
|
+
|
|
1687
|
+
target = turn.revises.turn_id
|
|
1688
|
+
seen = [turn.id]
|
|
1689
|
+
until seen.include?(target)
|
|
1690
|
+
return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
|
|
1691
|
+
|
|
1692
|
+
seen << target
|
|
1693
|
+
newer = messages.reverse.find do |message|
|
|
1694
|
+
Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
|
|
1695
|
+
end
|
|
1696
|
+
return ReviseAnchor.new(turn_id: target) unless newer
|
|
1697
|
+
|
|
1698
|
+
target = (newer[:turn_id] || newer["turn_id"]).to_s
|
|
1699
|
+
end
|
|
1700
|
+
reject_revise_anchor(turn.revises, :cycle)
|
|
1701
|
+
end
|
|
1702
|
+
private :revision_target
|
|
1703
|
+
|
|
1704
|
+
def reject_revise_anchor(anchor, reason)
|
|
1705
|
+
Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
|
|
1706
|
+
nil
|
|
1707
|
+
end
|
|
1708
|
+
private :reject_revise_anchor
|
|
1660
1709
|
|
|
1661
1710
|
def verified_anchor(session, anchor)
|
|
1662
1711
|
return unless anchor
|
|
@@ -2150,8 +2199,9 @@ module Samagotchi
|
|
|
2150
2199
|
# Continue that starts cold (after a model change).
|
|
2151
2200
|
# +wall+: the wall time it started (MessageTime.now, once): the
|
|
2152
2201
|
# :turn_started event's at and its user message's (and reminders') stamp.
|
|
2202
|
+
# +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
|
|
2153
2203
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
2154
|
-
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
|
|
2204
|
+
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
|
|
2155
2205
|
def resumed? = resumed ? true : false
|
|
2156
2206
|
|
|
2157
2207
|
# The turn's boundary events carry the origin only when it names a
|
|
@@ -2215,6 +2265,9 @@ module Samagotchi
|
|
|
2215
2265
|
# @param id [String, nil] the turn's id (a new uuid when nil): a caller
|
|
2216
2266
|
# that put the turn's first message in the session already (a context
|
|
2217
2267
|
# wake's note) names it with this id
|
|
2268
|
+
# @param verify [VerifyAnchor, nil] a Verify turn's claim (its verdict)
|
|
2269
|
+
# @param revises [ReviseAnchor, nil] a Revise turn's draft: stamped on
|
|
2270
|
+
# the prompt at the turn's end, metadata only (#stamp_opening)
|
|
2218
2271
|
#
|
|
2219
2272
|
# An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
|
|
2220
2273
|
# session as after a Stop (the prompt, the loop's finished tool
|
|
@@ -2226,9 +2279,11 @@ module Samagotchi
|
|
|
2226
2279
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
2227
2280
|
# one-line summary:.
|
|
2228
2281
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2229
|
-
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil,
|
|
2282
|
+
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, revises: nil,
|
|
2283
|
+
input_waiting: nil)
|
|
2230
2284
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
2231
|
-
continue: continue, id: id, verify: verify,
|
|
2285
|
+
continue: continue, id: id, verify: verify, revises: revises,
|
|
2286
|
+
input_waiting: input_waiting)
|
|
2232
2287
|
if continue
|
|
2233
2288
|
turn.resumed = resumed
|
|
2234
2289
|
turn.resume_from = resume_from
|
|
@@ -2276,7 +2331,8 @@ module Samagotchi
|
|
|
2276
2331
|
# The turn's state before anything can fail it: the session running,
|
|
2277
2332
|
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
2278
2333
|
# is set here and cleared in #release_turn only.
|
|
2279
|
-
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil,
|
|
2334
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, revises: nil,
|
|
2335
|
+
input_waiting: nil)
|
|
2280
2336
|
origin = TurnOrigin.of(origin)
|
|
2281
2337
|
# A participant's Verify fresh child: every turn is theirs (their
|
|
2282
2338
|
# author, boundary and cap), whoever started it.
|
|
@@ -2304,7 +2360,8 @@ module Samagotchi
|
|
|
2304
2360
|
|
|
2305
2361
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
2306
2362
|
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
2307
|
-
id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify)
|
|
2363
|
+
id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify),
|
|
2364
|
+
revises: ReviseAnchor.from(revises))
|
|
2308
2365
|
end
|
|
2309
2366
|
private :begin_turn
|
|
2310
2367
|
|
|
@@ -3044,6 +3101,15 @@ module Samagotchi
|
|
|
3044
3101
|
return if pending.nil?
|
|
3045
3102
|
|
|
3046
3103
|
outcome, judged = pending.wait(turn.controller)
|
|
3104
|
+
if outcome == :timeout
|
|
3105
|
+
# The session starts on its default without a word otherwise: a
|
|
3106
|
+
# slow System 1 host (a local one) is the usual cause.
|
|
3107
|
+
timeout_ms = SystemOne::Client.configured_timeout_ms
|
|
3108
|
+
Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
|
|
3109
|
+
reason: "System 1 gave no answer within guardrails.system_one.timeout_ms")
|
|
3110
|
+
@toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
|
|
3111
|
+
return
|
|
3112
|
+
end
|
|
3047
3113
|
unless outcome == :answered
|
|
3048
3114
|
Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
|
|
3049
3115
|
return
|
|
@@ -3070,6 +3136,10 @@ module Samagotchi
|
|
|
3070
3136
|
deferred: ToolkitSuggest.model_reply?(session.messages))
|
|
3071
3137
|
switched ? "suggested" : "chosen"
|
|
3072
3138
|
when "defer" then "defer"
|
|
3139
|
+
when "no_judgment"
|
|
3140
|
+
# System 1 failed (its own warn) or answered nothing usable.
|
|
3141
|
+
@toolkit_binding.fell_back(session, "System 1 gave no answer")
|
|
3142
|
+
"default"
|
|
3073
3143
|
else "default"
|
|
3074
3144
|
end
|
|
3075
3145
|
end
|
|
@@ -330,9 +330,10 @@ module Samagotchi
|
|
|
330
330
|
return verdict
|
|
331
331
|
end
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
# The card and the session file carry the facts as the hash they were.
|
|
334
|
+
answer = @ask.call(payload.merge(approval: payload[:approval].to_h))
|
|
334
335
|
system_one_label(judgment, answer, payload)
|
|
335
|
-
Guardrails::Approval.settle(verdict, answer, payload[:approval]
|
|
336
|
+
Guardrails::Approval.settle(verdict, answer, payload[:approval].scopes)
|
|
336
337
|
end
|
|
337
338
|
|
|
338
339
|
private
|
|
@@ -391,9 +392,9 @@ module Samagotchi
|
|
|
391
392
|
decision = system_one_decision(client, auto)
|
|
392
393
|
|
|
393
394
|
id = log.judgment(session_id: @session_lookup.call&.id,
|
|
394
|
-
rule: approval
|
|
395
|
+
rule: approval.rule, source: approval.source, tool: approval.tool,
|
|
395
396
|
**SystemOne::ApprovalQuestion.call_fields(approval),
|
|
396
|
-
repo_name: approval
|
|
397
|
+
repo_name: approval.repo_name,
|
|
397
398
|
p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
|
|
398
399
|
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
399
400
|
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
@@ -418,12 +419,13 @@ module Samagotchi
|
|
|
418
419
|
# user opted in, the never-list and chi's own files, the probability, and
|
|
419
420
|
# the rule's record. Any one that fails leaves the ask to the user. The
|
|
420
421
|
# entries are the judgment log read once for this ask.
|
|
422
|
+
# @param approval [Guardrails::ApprovalFacts] the ask's facts
|
|
421
423
|
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
422
424
|
# @param targets [Guardrails::Targets, nil] the call's, for its env:
|
|
423
425
|
def system_one_auto_allow?(client, approval, p, entries, targets: nil)
|
|
424
426
|
return false unless client.mode == "on"
|
|
425
427
|
|
|
426
|
-
rule = approval
|
|
428
|
+
rule = approval.rule.to_s
|
|
427
429
|
return false if rule.empty?
|
|
428
430
|
return false unless system_one_rules.include?(rule)
|
|
429
431
|
return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
|
|
@@ -456,7 +458,7 @@ module Samagotchi
|
|
|
456
458
|
id = judgment && judgment[:id]
|
|
457
459
|
return nil if id.nil?
|
|
458
460
|
|
|
459
|
-
scopes = Array(payload[:approval]
|
|
461
|
+
scopes = Array(payload[:approval].scopes)
|
|
460
462
|
if !answer.is_a?(Samagotchi::QuestionAnswer)
|
|
461
463
|
label = "none"
|
|
462
464
|
elsif answer.parent_agent?
|
|
@@ -6,6 +6,41 @@ require_relative "../question_answer"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
module Guardrails
|
|
9
|
+
# An ask's facts, as Approval.payload builds them (its approval:) and
|
|
10
|
+
# the question card shows them: the tool (tool, acts_as, label), what
|
|
11
|
+
# it acts on (command, paths, args, memory), where (cwd, repo_root,
|
|
12
|
+
# repo_name, branch), who asked and why (rule, source, reason), the
|
|
13
|
+
# offered scopes, an edit's preview (EditPreview.for) and, for a
|
|
14
|
+
# participant's turn, who asks (author, author_name, asked_by).
|
|
15
|
+
#
|
|
16
|
+
# In this process it is passed as this value; it crosses to the UIs and
|
|
17
|
+
# the session file (PendingQuestion's approval) as #to_h, the hash it
|
|
18
|
+
# always was: symbol keys, in member order, unset fields left out. A
|
|
19
|
+
# saved or sent one (string or symbol keys, missing fields, a key we
|
|
20
|
+
# don't know dropped) comes back through .from_h. memory and preview
|
|
21
|
+
# stay as given (string keys inside after a load).
|
|
22
|
+
ApprovalFacts = Data.define(:tool, :acts_as, :label, :command, :paths, :args, :memory, :cwd, :repo_root,
|
|
23
|
+
:repo_name, :branch, :rule, :source, :reason, :scopes, :preview, :author,
|
|
24
|
+
:author_name, :asked_by) do
|
|
25
|
+
def initialize(**fields)
|
|
26
|
+
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param raw [ApprovalFacts, Hash, nil] facts as built, saved or sent
|
|
30
|
+
# @return [ApprovalFacts, nil] nil for anything that is not facts
|
|
31
|
+
def self.from_h(raw)
|
|
32
|
+
return raw if raw.is_a?(ApprovalFacts)
|
|
33
|
+
return nil unless raw.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
new(**members.to_h { |m| [m, raw.key?(m) ? raw[m] : raw[m.to_s]] })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The card's and the session file's shape: symbol keys, in member
|
|
39
|
+
# order, unset fields left out.
|
|
40
|
+
# @return [Hash{Symbol => Object}]
|
|
41
|
+
def to_h = super.compact
|
|
42
|
+
end
|
|
43
|
+
|
|
9
44
|
# An ask as a question for the user (Engine#open_question), and the
|
|
10
45
|
# answer back as a verdict. The question text is plain and complete, so
|
|
11
46
|
# a UI that doesn't know approvals still shows everything; `approval:`
|
|
@@ -42,9 +77,10 @@ module Samagotchi
|
|
|
42
77
|
# their turn asks: it offers once only (PARTICIPANT_SCOPES), and the
|
|
43
78
|
# card names them (approval author:, author_name:, the header)
|
|
44
79
|
# @param author_name [String, nil] their name (participants.json)
|
|
45
|
-
# @return [Hash] open_question fields
|
|
46
|
-
# (
|
|
47
|
-
#
|
|
80
|
+
# @return [Hash] open_question fields, with approval: an ApprovalFacts
|
|
81
|
+
# (its caller hands open_question approval.to_h); the caller adds
|
|
82
|
+
# user_only: (ParentApprovals.protected_facts? on approval:, a file
|
|
83
|
+
# that requires this one)
|
|
48
84
|
def payload(verdict, label: nil, preview: nil, only: nil, author: nil, author_name: nil)
|
|
49
85
|
asker = asker(author, author_name)
|
|
50
86
|
only = PARTICIPANT_SCOPES if asker
|
|
@@ -60,7 +96,7 @@ module Samagotchi
|
|
|
60
96
|
allow_freeform: true,
|
|
61
97
|
effects: effects(scopes),
|
|
62
98
|
kind: KIND,
|
|
63
|
-
approval:
|
|
99
|
+
approval: ApprovalFacts.new(
|
|
64
100
|
tool: targets&.tool || verdict.call[:name].to_s,
|
|
65
101
|
# The tool the call acts as (an MCP tool behind call_tool).
|
|
66
102
|
acts_as: targets&.acts_as,
|
|
@@ -86,7 +122,7 @@ module Samagotchi
|
|
|
86
122
|
author: (author.to_s if asker),
|
|
87
123
|
author_name: (author_name_for(author_name) if asker),
|
|
88
124
|
asked_by: (format(ASKED_BY, asker) if asker)
|
|
89
|
-
|
|
125
|
+
)
|
|
90
126
|
}
|
|
91
127
|
end
|
|
92
128
|
|