samagotchi 0.12.0 → 0.13.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 +62 -1
- data/README.md +20 -7
- data/bin/chi +5 -2
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +40 -14
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +0 -1
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +34 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +15 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +16 -4
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -3
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- metadata +7 -1
data/docs/hooks.md
CHANGED
|
@@ -9,7 +9,8 @@ hooks. See [Plugins](plugins.md).
|
|
|
9
9
|
|
|
10
10
|
## Configuration
|
|
11
11
|
|
|
12
|
-
Add a `hooks:` section to your global config file (
|
|
12
|
+
Add a `hooks:` section to your global config file (`$XDG_CONFIG_HOME/samagotchi/config.yml`, `$XDG_CONFIG_HOME`
|
|
13
|
+
defaulting to `~/.config`):
|
|
13
14
|
|
|
14
15
|
```yaml
|
|
15
16
|
hooks:
|
|
@@ -48,15 +49,15 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
48
49
|
|
|
49
50
|
| Event | When it fires | Event payload |
|
|
50
51
|
|-------|--------------|---------------|
|
|
51
|
-
| `:session_start` |
|
|
52
|
+
| `:session_start` | The first turn this chi process runs for the session (so again after `--resume`, a worker that idle-exited and woke, or a restart) | `{ type: :session_start, session_id: "..." }` |
|
|
52
53
|
| `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
|
|
53
|
-
| `:after_turn` | After a turn completed or was
|
|
54
|
+
| `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
54
55
|
| `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
|
|
55
56
|
| `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
56
57
|
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
|
|
57
58
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
|
58
59
|
| `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
|
|
59
|
-
| `:session_end` | After
|
|
60
|
+
| `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
|
|
60
61
|
|
|
61
62
|
Every event also carries the hook runtime (next section): `hook:` (the label
|
|
62
63
|
of the hook about to run) and the callables `notify:`, `ask_user:`,
|
|
@@ -268,7 +269,7 @@ You can also register hooks programmatically during a turn (they are cleared
|
|
|
268
269
|
automatically after each `run_turn`):
|
|
269
270
|
|
|
270
271
|
```ruby
|
|
271
|
-
engine = Samagotchi::Engine.new
|
|
272
|
+
engine = Samagotchi::Engine.new
|
|
272
273
|
engine.register_hook(:before_turn) do |event|
|
|
273
274
|
puts "Turn starting..."
|
|
274
275
|
end
|
|
@@ -404,7 +405,8 @@ stripped of surrounding whitespace, except file text, an edit's
|
|
|
404
405
|
|
|
405
406
|
A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
|
|
406
407
|
`new_text:` (its `content:` is `""`). A plugin or MCP tool's call has its
|
|
407
|
-
arguments whole on `args:` (string keys) instead
|
|
408
|
+
arguments whole on `args:` (string keys) instead, and its `content:` is those
|
|
409
|
+
arguments as JSON.
|
|
408
410
|
|
|
409
411
|
## Bundle Hooks (unified workflow bundle)
|
|
410
412
|
|
|
@@ -439,6 +441,7 @@ hooks:
|
|
|
439
441
|
on_error: log
|
|
440
442
|
priority: 100
|
|
441
443
|
trust_level: reviewed # reviewed | experimental (default)
|
|
444
|
+
requires_chi: ">= 0.12.0" # optional: a gem-style requirement, as for plugins
|
|
442
445
|
needs: [gh] # optional: outside commands the memories use (docs/memory.md#bundles-that-need-outside-commands)
|
|
443
446
|
```
|
|
444
447
|
|
|
@@ -449,7 +452,8 @@ Notes:
|
|
|
449
452
|
- `sha256` is integrity (not authenticity). No signing in v1. Install records the sha256 of the copied file; at `Engine.new` a hook whose file differs is not loaded (reinstall the bundle after editing one by hand).
|
|
450
453
|
- Hook code is the bundle author's source of truth: on upgrade, hooks are overwritten; if the installed file was locally modified, a warning is emitted (`was locally modified; overwriting`).
|
|
451
454
|
- `on_error` defaults to `skip` for every event, `:before_tool_call` included: a raising hook is silent and the call goes ahead. `log` warns. `fail_closed` (only on `:before_tool_call`; on any other event it acts as `skip`) denies the call the hook raised on. Choose `fail_closed` for a hook that is a guardrail, whose failure should stop tool calls rather than let them through.
|
|
452
|
-
-
|
|
455
|
+
- `requires_chi`: when this chi doesn't meet it, none of the bundle's hooks load (each is reported, `not loaded: it requires chi …`), as for a [plugin](plugins.md#loading-and-when-it-fails).
|
|
456
|
+
- A `fail_closed` `:before_tool_call` hook is required: if it is missing, fails to load, its sha256 differs or chi doesn't meet the bundle's `requires_chi`, chi denies every tool call until it is fixed.
|
|
453
457
|
- A bundle can also ship YAML rules in `guardrails/*.yml`; see [Guardrails](guardrails.md#the-guardrails-bundle).
|
|
454
458
|
- Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
|
|
455
459
|
- Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
|
|
@@ -459,8 +463,8 @@ Notes:
|
|
|
459
463
|
|
|
460
464
|
Lifecycle:
|
|
461
465
|
|
|
462
|
-
- `chi bundle install <source>` copies `hooks/*.rb` to
|
|
463
|
-
- `Engine.new` loads `config.yml` hooks first, then bundle hooks via `Provenance.
|
|
466
|
+
- `chi bundle install <source>` copies the hook files its manifest's `hooks:` map lists (only those; with no `hooks:` map, every `hooks/*.rb`) to `$XDG_CONFIG_HOME/samagotchi/memories/.bundles/<name>/hooks/` (`$XDG_CONFIG_HOME` defaults to `~/.config`) and persists metadata + `trust_level` + `source_commit` (git HEAD) to provenance.
|
|
467
|
+
- `Engine.new` loads `config.yml` hooks first, then bundle hooks via `MemoryBundle::Provenance.each_installed(holding: :hooks)` → `Hooks::BundleLoader.load`. Bundle hooks are process-scoped (they survive the per-turn `clear_hooks`; only plain hooks are cleared). Experimental bundles emit a one-line startup warning.
|
|
464
468
|
- `chi bundle status`, `diff`, `uninstall`, `build` are hook-aware (counts, metadata, removal).
|
|
465
469
|
|
|
466
470
|
## The source-links bundle
|
data/docs/memory.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Samagotchi stores memories in two scopes:
|
|
4
4
|
|
|
5
|
-
- Project scope:
|
|
5
|
+
- Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<name>_<hash>/`, one folder per
|
|
6
6
|
git repository (named and hashed by its root), shared by all its worktrees and
|
|
7
7
|
subdirectories; outside a repository, one per working directory
|
|
8
|
-
- System scope:
|
|
8
|
+
- System scope: `$XDG_CONFIG_HOME/samagotchi/memories`
|
|
9
|
+
|
|
10
|
+
`$XDG_CONFIG_HOME` falls back to `~/.config` when unset.
|
|
9
11
|
|
|
10
12
|
Tool behavior:
|
|
11
13
|
|
|
@@ -19,7 +21,7 @@ Tool behavior:
|
|
|
19
21
|
|
|
20
22
|
Each memory entry may have a companion file named `<name>.<model-key>.md` in the same scope directory. When the entry is read under a matching model, the overlay body is appended automatically, separated by the standard `---` separator with a `Model-specific guidance (<key>):` header.
|
|
21
23
|
|
|
22
|
-
- **Key derivation**: The harness normalizes the
|
|
24
|
+
- **Key derivation**: The harness normalizes the model name sent to the server, without its host prefix (`box:qwen3.6-35b-a3b` keys as `qwen3.6-35b-a3b`; an alias keys as its target, and the alias's own key is read when the target has no overlay) (lowercase, replace non-alphanumeric with `-`, squeeze dashes) to derive the file key. For example, `qwen3.6-35b-a3b` → `qwen3-6-35b-a3b`.
|
|
23
25
|
- **Saving overlays**: Pass `current_model_only: true` to `memory_write` (the harness resolves the model key automatically). This writes the content as `<name>.<model-key>.md` and skips index maintenance.
|
|
24
26
|
- **Dormancy**: Overlays are only active under the matching model key; other models see the base entry only.
|
|
25
27
|
- **Invariant**: The base entry is the contract. Overlays only add model-specific guidance and never contradict the base protocol.
|
data/docs/plugins.md
CHANGED
|
@@ -24,7 +24,7 @@ version: 1.0.0
|
|
|
24
24
|
plugin:
|
|
25
25
|
file: plugin.rb
|
|
26
26
|
sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
|
|
27
|
-
requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2")
|
|
27
|
+
requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
|
|
28
28
|
needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -73,7 +73,7 @@ Settings work as they do for hooks ([hooks.md](hooks.md#settings)). An
|
|
|
73
73
|
|
|
74
74
|
### `chi.command(name, description, anytime: false) { |args, ctx| … }`
|
|
75
75
|
|
|
76
|
-
This adds a slash command. `name` is `/name
|
|
76
|
+
This adds a slash command. `name` is `/name`: a lowercase letter first, then a–z, 0–9, `_` and `-`, at most 32 characters after the `/`.
|
|
77
77
|
`args` is the text after the name, stripped, or `""` when there is none. The
|
|
78
78
|
block returns the text to show (a String), or nil to show nothing. If the
|
|
79
79
|
block raises, the user sees `/name: <error>`.
|
|
@@ -638,8 +638,8 @@ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
|
|
|
638
638
|
0.1.0 shipped `btw.md` as one, and `chi bundle upgrade btw` leaves that file
|
|
639
639
|
(and its index line) behind. Drop it with `chi bundle uninstall btw`, then
|
|
640
640
|
`chi bundle install btw`. After an upgrade already ran, delete
|
|
641
|
-
|
|
642
|
-
there by hand.
|
|
641
|
+
`$XDG_CONFIG_HOME/samagotchi/memories/btw.md` (`$XDG_CONFIG_HOME` defaults to
|
|
642
|
+
`~/.config`) and its `**btw**` line in `index.md` there by hand.
|
|
643
643
|
|
|
644
644
|
## The mcp bundle
|
|
645
645
|
|
data/docs/sessions.md
CHANGED
|
@@ -25,7 +25,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
25
25
|
|
|
26
26
|
<a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered; a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
|
|
27
27
|
|
|
28
|
-
**Lazy sweep:** automatic prune runs at most once per
|
|
28
|
+
**Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
|
|
29
29
|
|
|
30
30
|
**CLI:**
|
|
31
31
|
|
|
@@ -40,6 +40,8 @@ chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...]
|
|
|
40
40
|
chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
A session whose worker waits for an answer (a question, a hook's question or a guardrail approval) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
|
|
44
|
+
|
|
43
45
|
Examples:
|
|
44
46
|
|
|
45
47
|
```sh
|
|
@@ -146,7 +148,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
146
148
|
- It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then.
|
|
147
149
|
- A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
|
|
148
150
|
- Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
|
|
149
|
-
- `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image
|
|
151
|
+
- `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
|
|
150
152
|
- One line per session: `sent`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
|
|
151
153
|
|
|
152
154
|
The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
|
|
@@ -166,10 +168,11 @@ chi send --wait 3fa2 # sends nothing: waits fo
|
|
|
166
168
|
- `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
|
|
167
169
|
- `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
|
|
168
170
|
- `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
|
|
169
|
-
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question or a guardrail approval),
|
|
170
|
-
- `--
|
|
171
|
+
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question or a guardrail approval), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
|
|
172
|
+
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
173
|
+
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
171
174
|
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
172
|
-
- A session nobody attaches to stalls at its first guardrail ask until someone
|
|
175
|
+
- A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
|
173
176
|
|
|
174
177
|
## Delegating
|
|
175
178
|
|
|
@@ -177,7 +180,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
177
180
|
|
|
178
181
|
- `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
|
|
179
182
|
- `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
|
|
180
|
-
- What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval (nothing is answered for it: attach or use the web, then wait again); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `running` on timeout. A reply saved before the child's worker idle-exited is returned at once.
|
|
183
|
+
- What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval, 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); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: 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. A reply saved before the child's worker idle-exited is returned at once.
|
|
181
184
|
- Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
|
|
182
185
|
- Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
|
|
183
186
|
- The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
|
data/docs/sub-agent.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# chi as a sub-agent
|
|
2
|
+
|
|
3
|
+
Another agent (Claude Code, Codex, a script) can hand a task to chi and get the
|
|
4
|
+
answer back, the way you would from a terminal: `chi send --new --wait` starts a
|
|
5
|
+
session in a background worker and prints its reply. The session is an
|
|
6
|
+
ordinary one, so you (the human) can watch it live in `chi web` or join it with
|
|
7
|
+
`chi --attach ID`.
|
|
8
|
+
|
|
9
|
+
When chi's model asks a question (`ask_user_question`), a hook asks one, or a
|
|
10
|
+
guardrail wants an approval, chi stops and hands the question up: the wait ends
|
|
11
|
+
with exit 3 and the question, its options and the command that answers it. The
|
|
12
|
+
worker keeps the turn paused. The parent answers with `chi answer`, from what it
|
|
13
|
+
knows or after asking its own user, and chi goes on from there. The model gets
|
|
14
|
+
the answer as its tool result, as if you had answered in the web.
|
|
15
|
+
|
|
16
|
+
## The commands
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
chi send --new --wait --format json [--dir D] [--model M] [--timeout S] -m "task"
|
|
20
|
+
chi send --wait --format json [--timeout S] [-m "follow-up"] ID
|
|
21
|
+
chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
|
|
22
|
+
chi answer ID --question QID --dismiss [--timeout S] [--format json]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`chi answer` waits after answering, the same way `chi send --wait` does, so the
|
|
26
|
+
next question comes back as exit 3 again. `--option` is 1-based or the label,
|
|
27
|
+
repeated on a multi-select question. `--text` is free text (where the question
|
|
28
|
+
allows it) or the reason for a Deny. `--dismiss` leaves the question
|
|
29
|
+
unanswered: the model doesn't do what it asked about and finishes its reply.
|
|
30
|
+
|
|
31
|
+
Exit codes, both commands:
|
|
32
|
+
|
|
33
|
+
| Exit | `status` | Meaning |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| 0 | `answered` | `text` is chi's reply |
|
|
36
|
+
| 3 | `question` | chi waits for an answer: `question`, `answer_with` |
|
|
37
|
+
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
38
|
+
| 1 | `failed`, `canceled`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened |
|
|
39
|
+
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
40
|
+
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
41
|
+
|
|
42
|
+
With `--format json` stdout is one JSON object (see
|
|
43
|
+
[Starting a session](sessions.md#starting-a-session) for every field):
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
|
|
47
|
+
"text":"Which file should I read?","options":["README.md","NOTES.md"],"multi_select":false,
|
|
48
|
+
"allow_freeform":false},"answer_with":"chi answer 297da360-… --question 3f1c… --option N"}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`kind` is `question` (the model's), `hook`, or `approval`, which adds the tool,
|
|
52
|
+
command, folder, rule and reason.
|
|
53
|
+
|
|
54
|
+
## Approvals
|
|
55
|
+
|
|
56
|
+
An approval is a guardrail rule with `verdict: ask`: its user said "ask me". So
|
|
57
|
+
a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
|
|
58
|
+
`--dismiss`) but not allow it: `chi answer` refuses an Allow with exit 1 and
|
|
59
|
+
`allowing a tool call is up to the user: deny it (--option Deny --text WHY),
|
|
60
|
+
and tell your user`. For an approval, the question block and `answer_with` give
|
|
61
|
+
the deny command, not `--option N` or `chi --attach`. With
|
|
62
|
+
`guardrails.parent_approvals: once` in chi's config.yml (it has no environment
|
|
63
|
+
variable), "Allow once" goes through; the wider scopes never do,
|
|
64
|
+
and neither does any allow on chi's own config, hooks or guardrail rules. See
|
|
65
|
+
[Guardrails](guardrails.md#approvals-from-a-parent-agent).
|
|
66
|
+
|
|
67
|
+
An answer typed into `chi --attach` or the REPL counts as a parent's too when
|
|
68
|
+
stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
|
|
69
|
+
`SAMAGOTCHI_PARENT_SESSION`) is set.
|
|
70
|
+
|
|
71
|
+
The worker checks it again with its own config.yml. The guardrails settings
|
|
72
|
+
have no environment variables, and a worker drops any `SAMAGOTCHI_GUARDRAILS_*`
|
|
73
|
+
it inherits; but a parent that sets `XDG_CONFIG_HOME` picks which config.yml a
|
|
74
|
+
worker it starts or wakes reads. Still, this guards a parent that follows its instructions,
|
|
75
|
+
not a security boundary: the worker's Bridge and `chi web` on localhost take
|
|
76
|
+
answers from any local process.
|
|
77
|
+
|
|
78
|
+
## Things to know
|
|
79
|
+
|
|
80
|
+
- A question waits for as long as it takes: the worker doesn't idle-exit while
|
|
81
|
+
one is open. `chi sessions list` shows such sessions as `waiting`
|
|
82
|
+
(`--format json`: `waiting_id` is the question's id);
|
|
83
|
+
`chi sessions stop ID` ends one.
|
|
84
|
+
- If the worker dies while a question waits, the question goes with it.
|
|
85
|
+
`chi answer` then exits 1 and says to send the task again
|
|
86
|
+
(`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
|
|
87
|
+
- A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
|
|
88
|
+
isn't sent: it exits 1 with the `chi answer` command for the question.
|
|
89
|
+
- An answer to a question that is no longer open (you answered it in the web
|
|
90
|
+
first) isn't sent; `chi answer` says so and waits for what comes next.
|
|
91
|
+
- `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
|
|
92
|
+
file. Agent shells usually pass `/dev/null`, which is fine.
|
|
93
|
+
|
|
94
|
+
## Instructions for the parent agent
|
|
95
|
+
|
|
96
|
+
Paste this into the parent's `CLAUDE.md` or `AGENTS.md`:
|
|
97
|
+
|
|
98
|
+
```markdown
|
|
99
|
+
## Using chi as a sub-agent
|
|
100
|
+
|
|
101
|
+
Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
|
|
102
|
+
(add `--dir <project>`). Read the JSON on stdout:
|
|
103
|
+
|
|
104
|
+
- `status: answered` (exit 0): `text` is chi's reply.
|
|
105
|
+
- `status: question` (exit 3): chi is paused, waiting for an answer. If the answer
|
|
106
|
+
is clear from your task and context, answer it yourself. Otherwise ask your
|
|
107
|
+
user, showing them the question and its options; if you can't ask (no user in
|
|
108
|
+
this run), stop and report the question and options instead of guessing. To
|
|
109
|
+
answer, run the `answer_with` command with `--option N` (1-based, repeat for
|
|
110
|
+
multi-select) and/or `--text "…"`. It returns the same JSON. If you won't
|
|
111
|
+
answer, add `--dismiss` instead: chi does nothing it asked about, finishes
|
|
112
|
+
with what it found, and the reply comes back (exit 0).
|
|
113
|
+
- `question.kind: approval`: chi wants to run something its user's guardrails
|
|
114
|
+
flagged, and allowing it is up to your user. Deny it (the `answer_with`
|
|
115
|
+
command: `--option Deny --text "why"`), and tell your user what it wanted to
|
|
116
|
+
run and why. Never allow it yourself, through `chi answer`, a piped
|
|
117
|
+
`chi --attach` or chi's web API.
|
|
118
|
+
- `status: running` (exit 4): still working. Wait again:
|
|
119
|
+
`chi send --wait --format json --timeout 500 <session_id>`.
|
|
120
|
+
- Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
|
|
121
|
+
shows the session.
|
|
122
|
+
|
|
123
|
+
Run each chi command on its own, nothing chained after it: the exit code comes
|
|
124
|
+
back with the output. Talk to chi only through these commands; never call its
|
|
125
|
+
Bridge or web API directly. Follow-ups in the same session:
|
|
126
|
+
`chi send --wait --format json --timeout 500 -m "…" <session_id>`.
|
|
127
|
+
Keep `--timeout` under your shell tool's limit (Claude Code: 600 s per call).
|
|
128
|
+
```
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_manager"
|
|
5
|
+
require_relative "bridge_client"
|
|
6
|
+
require_relative "reply_wait"
|
|
7
|
+
require_relative "parent_report"
|
|
8
|
+
require_relative "guardrails/parent_approvals"
|
|
9
|
+
require_relative "cli/command"
|
|
10
|
+
require_relative "cli/flags"
|
|
11
|
+
require_relative "cli/parent_wait"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# `chi answer`: answer the question a session's worker waits on (exit 3
|
|
15
|
+
# from chi send --wait), then wait for what comes next and print it the
|
|
16
|
+
# same way: the reply (0), the next question (3), still running (4). It is
|
|
17
|
+
# how a parent agent running chi as a sub-agent answers; the web and
|
|
18
|
+
# chi --attach answer the same question. See docs/sub-agent.md.
|
|
19
|
+
#
|
|
20
|
+
# An approval is the user's to allow: a parent may deny it, and allow it
|
|
21
|
+
# only "once" when guardrails.parent_approvals (config.yml) says so. It is
|
|
22
|
+
# checked here and again by the worker, which sees the answer marked as
|
|
23
|
+
# chi answer's. That guards an honest but eager parent, not a boundary:
|
|
24
|
+
# the Bridge and the localhost web take answers from any local process.
|
|
25
|
+
class AnswerCommand
|
|
26
|
+
include CLI::Command
|
|
27
|
+
include CLI::ParentWait
|
|
28
|
+
|
|
29
|
+
USAGE = <<~TEXT
|
|
30
|
+
Usage: chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
|
|
31
|
+
chi answer ID --question QID --dismiss [--timeout S] [--format json]
|
|
32
|
+
Answers the question session ID waits on (chi send --wait exits 3
|
|
33
|
+
with it), then waits for what comes next and prints it as chi send
|
|
34
|
+
--wait does.
|
|
35
|
+
--question QID the question's id (chi send --wait prints it); a
|
|
36
|
+
question no longer open is not answered
|
|
37
|
+
--option N|LABEL
|
|
38
|
+
an option, by number (1-based) or label; repeat
|
|
39
|
+
on a multi-select question
|
|
40
|
+
--text T free text, where the question allows it; with an
|
|
41
|
+
approval, the reason for a Deny (text alone denies)
|
|
42
|
+
--dismiss leave it unanswered: chi does nothing it asked
|
|
43
|
+
about and finishes its reply
|
|
44
|
+
--timeout S give up waiting after S seconds (exit 4)
|
|
45
|
+
--format json one JSON object on stdout (see chi send --help)
|
|
46
|
+
An approval (a guardrail's ask) can be denied here: deny it, and
|
|
47
|
+
tell your user; allowing it is the user's. With
|
|
48
|
+
guardrails.parent_approvals: once, "Allow once" is let through.
|
|
49
|
+
Exit: 0 answered, 1 failed, refused or the worker is gone, 2
|
|
50
|
+
usage or an option the question doesn't offer, 3 a question waits,
|
|
51
|
+
4 still running (--timeout), 130 Ctrl-C.
|
|
52
|
+
TEXT
|
|
53
|
+
|
|
54
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
55
|
+
f.value "--question"
|
|
56
|
+
f.value "--option", key: :options, repeat: true
|
|
57
|
+
f.value "--text"
|
|
58
|
+
f.switch "--dismiss"
|
|
59
|
+
f.value "--timeout"
|
|
60
|
+
f.value "--format"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
64
|
+
@argv = argv.dup
|
|
65
|
+
@stdout = stdout
|
|
66
|
+
@stderr = stderr
|
|
67
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def command_name = "chi answer"
|
|
73
|
+
|
|
74
|
+
def parse
|
|
75
|
+
parsed = parse_flags(FLAGS, @argv, options: [])
|
|
76
|
+
return parsed if parsed.is_a?(Integer)
|
|
77
|
+
|
|
78
|
+
options = parsed.options
|
|
79
|
+
return usage_error("give one session id") unless parsed.args.size == 1
|
|
80
|
+
return usage_error("--question QID is required (chi send --wait prints it)") if options[:question].to_s.strip.empty?
|
|
81
|
+
if options[:dismiss]
|
|
82
|
+
return usage_error("--dismiss takes no --option or --text") if !options[:options].empty? || options[:text]
|
|
83
|
+
elsif options[:options].empty? && options[:text].nil?
|
|
84
|
+
return usage_error("nothing to answer with: --option, --text or --dismiss")
|
|
85
|
+
end
|
|
86
|
+
return usage_error("--format takes text or json") if options[:format] && !FORMATS.include?(options[:format])
|
|
87
|
+
if options[:timeout]
|
|
88
|
+
options[:timeout] = Float(options[:timeout], exception: false)
|
|
89
|
+
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
90
|
+
end
|
|
91
|
+
options.merge(id: parsed.args.first)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Answer, then wait for what comes next.
|
|
95
|
+
# @return [Integer] the exit status
|
|
96
|
+
def run_parsed(options)
|
|
97
|
+
id = resolve(options[:id]) or return 1
|
|
98
|
+
qid = options[:question].strip
|
|
99
|
+
session = Session.load(id, state_dir: @state_dir)
|
|
100
|
+
pending = session.pending_question
|
|
101
|
+
# Taken before the answer goes in, with the question answered as the
|
|
102
|
+
# one already reported: the file keeps it until the turn thread
|
|
103
|
+
# wakes, and it must not come back as exit 3.
|
|
104
|
+
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
105
|
+
baseline = ReplyWait.baseline_of(session, question_id: qid)
|
|
106
|
+
|
|
107
|
+
if pending && pending[:id].to_s == qid
|
|
108
|
+
selected = selection(pending, options) or return @selection_status
|
|
109
|
+
refusal = approval_refusal(pending, selected, id)
|
|
110
|
+
return failure(refusal) if refusal
|
|
111
|
+
|
|
112
|
+
posted = post(id, qid, selected, options)
|
|
113
|
+
return posted if posted.is_a?(Integer)
|
|
114
|
+
elsif session.status != Session::STATUS_RUNNING
|
|
115
|
+
return failure("no question #{qid} waits in #{id[0, 8]}; nothing to answer")
|
|
116
|
+
else
|
|
117
|
+
not_open(qid)
|
|
118
|
+
end
|
|
119
|
+
# Answered here, or no longer open (answered elsewhere, or a newer
|
|
120
|
+
# question): what comes next either way.
|
|
121
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The labels to send. A number is an option's place (1-based); an
|
|
125
|
+
# exact label wins over a number.
|
|
126
|
+
# @return [Array<String>, nil] nil after a usage error
|
|
127
|
+
def selection(pending, options)
|
|
128
|
+
offered = Array(pending[:options]).map(&:to_s)
|
|
129
|
+
labels = options[:options].map do |given|
|
|
130
|
+
next given if offered.include?(given)
|
|
131
|
+
|
|
132
|
+
index = Integer(given, exception: false)
|
|
133
|
+
next offered[index - 1] if index&.between?(1, offered.size)
|
|
134
|
+
|
|
135
|
+
listed = offered.each_with_index.map { |label, i| "#{i + 1}. #{label}" }.join(", ")
|
|
136
|
+
@selection_status = usage_failure("no option #{given}; the options: #{listed}")
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
if labels.uniq.size > 1 && !pending[:multi_select]
|
|
140
|
+
@selection_status = usage_failure("one option only: the question is single-select")
|
|
141
|
+
return nil
|
|
142
|
+
end
|
|
143
|
+
labels.uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Why a parent may not give this answer to an approval, or nil
|
|
147
|
+
# (Guardrails::ParentApprovals, with this process's config.yml). A
|
|
148
|
+
# Deny, text alone or a dismissal always passes: each one denies. The
|
|
149
|
+
# worker checks it again with its own config.
|
|
150
|
+
def approval_refusal(pending, selected, id)
|
|
151
|
+
offered = Array(pending[:options]).map(&:to_s)
|
|
152
|
+
indices = selected.map { |label| offered.index(label) }
|
|
153
|
+
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
|
|
154
|
+
reason && Guardrails::ParentApprovals.message(reason)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Post the answer (or the dismissal) to the live worker's Bridge.
|
|
158
|
+
# @return [Integer, nil] an exit status when it didn't go in; nil to
|
|
159
|
+
# wait (it went in, or the question was no longer open: 409)
|
|
160
|
+
def post(id, qid, selected, options)
|
|
161
|
+
client = BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
162
|
+
return failure(worker_gone(id)) unless client
|
|
163
|
+
|
|
164
|
+
reply = nil
|
|
165
|
+
2.times do
|
|
166
|
+
reply = if options[:dismiss]
|
|
167
|
+
client.dismiss_question(id: qid)
|
|
168
|
+
else
|
|
169
|
+
client.answer(id: qid, selected: selected, freeform: options[:text],
|
|
170
|
+
client_id: Guardrails::ParentApprovals::CLIENT_ID)
|
|
171
|
+
end
|
|
172
|
+
break unless reply.status == 408
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
case reply.status
|
|
176
|
+
when 200 then nil
|
|
177
|
+
when 409 then not_open(qid)
|
|
178
|
+
when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
|
|
179
|
+
# The worker's own guardrails.parent_approvals refused the allow.
|
|
180
|
+
when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
|
|
181
|
+
when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
|
|
182
|
+
when 404
|
|
183
|
+
return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
|
|
184
|
+
|
|
185
|
+
cant = options[:dismiss] ? "dismiss a question" : "take an answer"
|
|
186
|
+
failure(BridgeClient.stale_worker_message(id, cant: cant))
|
|
187
|
+
else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
|
|
188
|
+
end
|
|
189
|
+
rescue Errno::ETIMEDOUT
|
|
190
|
+
failure("the worker did not take the answer in time; the question is still open: try again")
|
|
191
|
+
rescue SystemCallError, IOError, SocketError
|
|
192
|
+
failure(worker_gone(id))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The question was answered elsewhere (the web first), or another one
|
|
196
|
+
# waits now: said, then what comes next is waited for.
|
|
197
|
+
# @return [nil]
|
|
198
|
+
def not_open(qid)
|
|
199
|
+
error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def worker_gone(id)
|
|
204
|
+
"the worker is gone and the question with it; send the task again: chi send --wait -m \"…\" #{id}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [Integer] 1, after the line
|
|
208
|
+
def failure(line)
|
|
209
|
+
error_line("#{command_name}: #{line}")
|
|
210
|
+
1
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The Bridge refused the answer itself (an option not offered, two on a
|
|
214
|
+
# single-select question): a usage error, the question still open.
|
|
215
|
+
def usage_failure(line)
|
|
216
|
+
error_line("#{command_name}: #{line}")
|
|
217
|
+
CLI::Exit::USAGE
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "bridge/sse_writer"
|
|
|
18
18
|
require_relative "bridge/turn_accumulator"
|
|
19
19
|
require_relative "session"
|
|
20
20
|
require_relative "engine"
|
|
21
|
+
require_relative "guardrails/parent_approvals"
|
|
21
22
|
require_relative "session_commands"
|
|
22
23
|
require_relative "image_store"
|
|
23
24
|
require_relative "log"
|
|
@@ -496,6 +497,10 @@ module Samagotchi
|
|
|
496
497
|
qid = fetched(parsed, "id")
|
|
497
498
|
selected = fetched(parsed, "selected")
|
|
498
499
|
freeform = fetched(parsed, "freeform")
|
|
500
|
+
# chi answer's marker: a parent agent, held to this worker's
|
|
501
|
+
# guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
|
|
502
|
+
# any local process may post here, with or without it.
|
|
503
|
+
client_id = fetched(parsed, "client_id")
|
|
499
504
|
if qid.to_s.strip.empty?
|
|
500
505
|
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
|
|
501
506
|
end
|
|
@@ -504,11 +509,15 @@ module Samagotchi
|
|
|
504
509
|
return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
|
|
505
510
|
|
|
506
511
|
begin
|
|
507
|
-
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
|
|
512
|
+
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
|
|
508
513
|
[{}, 200, { status: "answered", session_id: session_id, answer: result }]
|
|
509
514
|
rescue Engine::QuestionNotPending => e
|
|
510
515
|
# Another client answered first, or the question was cancelled.
|
|
511
516
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
517
|
+
rescue QuestionDesk::Refused => e
|
|
518
|
+
Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
|
|
519
|
+
[{}, 403, { error: "parent_approval_refused",
|
|
520
|
+
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
512
521
|
rescue ArgumentError => e
|
|
513
522
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
514
523
|
end
|
|
@@ -888,6 +897,7 @@ module Samagotchi
|
|
|
888
897
|
202 => "Accepted",
|
|
889
898
|
204 => "No Content",
|
|
890
899
|
400 => "Bad Request",
|
|
900
|
+
403 => "Forbidden",
|
|
891
901
|
404 => "Not Found",
|
|
892
902
|
409 => "Conflict",
|
|
893
903
|
500 => "Internal Server Error",
|
|
@@ -108,10 +108,15 @@ module Samagotchi
|
|
|
108
108
|
|
|
109
109
|
# POST /session/:id/answer. 200 = recorded, 409 = another client answered
|
|
110
110
|
# first (or the question is gone), 400 = invalid selection, 408
|
|
111
|
-
# deadline_passed = read too late and dropped (see DEADLINE_SHARE)
|
|
111
|
+
# deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
|
|
112
|
+
# parent_approval_refused = a parent's allow (client_id cli:answer)
|
|
113
|
+
# beyond the worker's guardrails.parent_approvals.
|
|
114
|
+
# @param client_id [String, nil] who answers (chi answer marks its own)
|
|
112
115
|
# @return [Response]
|
|
113
|
-
def answer(id:, selected:, freeform: nil)
|
|
114
|
-
|
|
116
|
+
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
117
|
+
body = { id: id, selected: selected, freeform: freeform, deadline: deadline }
|
|
118
|
+
body[:client_id] = client_id if client_id
|
|
119
|
+
post("answer", body, read_body: true)
|
|
115
120
|
end
|
|
116
121
|
|
|
117
122
|
# POST /session/:id/question/dismiss: leave the question unanswered.
|