samagotchi 0.85.0 → 0.87.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +51 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +112 -39
- data/docs/hooks.md +19 -3
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +39 -25
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +4 -4
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +122 -30
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +39 -25
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +14 -5
- data/lib/samagotchi/send_command.rb +103 -12
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +45 -8
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +20 -11
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +16 -7
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +27 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
data/docs/sub-agent.md
CHANGED
|
@@ -59,7 +59,7 @@ Exit codes, both commands:
|
|
|
59
59
|
| 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
|
|
60
60
|
| 3 | `question` | chi waits for an answer: `question`, `answer_with` |
|
|
61
61
|
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
62
|
-
| 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
|
|
62
|
+
| 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the `--queue` turn waited for died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
|
|
63
63
|
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
64
64
|
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
65
65
|
|
|
@@ -98,10 +98,12 @@ question and starts a new turn. It can't be dismissed.
|
|
|
98
98
|
|
|
99
99
|
A parent may Continue by default: Continue grants no permission (every tool
|
|
100
100
|
call still meets the guardrails), it only spends time and tokens. With
|
|
101
|
-
`
|
|
102
|
-
environment variable
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
`guardrails.parent_answers: hard` in chi's config.yml (config.yml only, no
|
|
102
|
+
environment variable; a session's own `parent_answers` may tighten it, see
|
|
103
|
+
[Guardrails](guardrails.md#a-sessions-own-level)) a parent may only Stop: `chi answer` and the worker
|
|
104
|
+
refuse a Continue (exit 1, `a parent agent may only stop this turn here`), the
|
|
105
|
+
question block's command is the Stop, and the user continues it from the web or
|
|
106
|
+
the terminal. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
|
|
105
107
|
|
|
106
108
|
A chi parent's `delegate_result` gets a child's step-limit question back as a
|
|
107
109
|
`question` with these commands, for its model to decide: continue it (the
|
|
@@ -122,23 +124,27 @@ sees only how each was answered. See
|
|
|
122
124
|
parents (Claude Code, Codex, a script), and for a chi parent that can't host
|
|
123
125
|
the relay (a `--no-shared` REPL, `--non-interactive`).
|
|
124
126
|
|
|
125
|
-
An approval is a guardrail rule with `verdict: ask`: its user said "ask me".
|
|
126
|
-
|
|
127
|
-
`--dismiss`)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
the
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
127
|
+
An approval is a guardrail rule with `verdict: ask`: its user said "ask me". A
|
|
128
|
+
parent may always deny it (`--option Deny --text "why"`, `--text` alone, or
|
|
129
|
+
`--dismiss`). By default (`guardrails.parent_answers: soft` in chi's
|
|
130
|
+
config.yml, which has no environment variable, unless the session's own
|
|
131
|
+
`parent_answers` is stricter) it may also give "Allow once";
|
|
132
|
+
the wider scopes never go, and neither does any allow on chi's own config,
|
|
133
|
+
hooks or guardrail rules: `chi answer` refuses those with exit 1 and a line
|
|
134
|
+
like `only Allow once (guardrails.parent_answers: soft) can be given here: deny
|
|
135
|
+
it (--option Deny --text WHY), and tell your user`. With `standard` or `hard`
|
|
136
|
+
no allow goes (`allowing a tool call is up to the user: …`). The question
|
|
137
|
+
block lists what the parent may give: the Allow once command when it may, and
|
|
138
|
+
the deny command (`answer_with` is the deny), never `chi --attach`. The block
|
|
139
|
+
also offers to leave it open: tell your user it waits in chi web (the bell and
|
|
140
|
+
badge show it there), and `chi send --wait --format json --turn <turn_id> ID`
|
|
141
|
+
waits until they answer it. A plugin's question names the options a parent may
|
|
142
|
+
pick when the strategy leaves some out (`allowed_options` in the JSON). See
|
|
143
|
+
[Guardrails](guardrails.md#answers-from-a-parent-agent).
|
|
138
144
|
|
|
139
145
|
Start a child in the folder it should change (`chi send --new --dir PATH`):
|
|
140
146
|
writes and mutating git outside its own repo ask (`write-outside-repo`,
|
|
141
|
-
`git-outside-repo`), which a parent can
|
|
147
|
+
`git-outside-repo`), which a parent can at most allow once per call.
|
|
142
148
|
|
|
143
149
|
An answer typed into `chi --attach` or the REPL counts as a parent's too when
|
|
144
150
|
stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
|
|
@@ -208,12 +214,20 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
|
|
|
208
214
|
JSON. If you won't answer, add `--dismiss` instead: chi does nothing it asked
|
|
209
215
|
about, finishes with what it found, and the reply comes back (exit 0).
|
|
210
216
|
- `question.kind: approval`: chi wants to run something its user's guardrails
|
|
211
|
-
flagged
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
flagged. What you may give follows chi's `guardrails.parent_answers` (or the
|
|
218
|
+
session's own, stricter `parent_answers`), and the
|
|
219
|
+
question says it: when its `allowed_options` (and the text block's `allow:`
|
|
220
|
+
line) list "Allow once" (`soft`, the default), you may allow that one call if
|
|
221
|
+
your task clearly needs it; never a wider scope (this session, a repo, a
|
|
222
|
+
rule), and never a call on chi's own config, which lists only Deny. Otherwise
|
|
223
|
+
(`standard`, `hard`, or anything wider) allowing it is up to your user: deny
|
|
224
|
+
it (the `answer_with` command: `--option Deny --text "why"`), and tell your
|
|
225
|
+
user what it wanted to run and why. Or leave it open: tell your user it waits
|
|
226
|
+
in chi web (the session's short id), then
|
|
227
|
+
`chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
|
|
228
|
+
waits until they answer it there. Never give an answer the question doesn't
|
|
229
|
+
offer you by another route (a piped `chi --attach`, `chi send -m '/continue
|
|
230
|
+
yes'`, chi's web API).
|
|
217
231
|
- `status: running` (exit 4): still working. Wait for that same turn again:
|
|
218
232
|
`chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`.
|
|
219
233
|
- `question.kind: continue`: chi ran out of steps before it answered. If it is
|
data/docs/testing.md
CHANGED
|
@@ -47,7 +47,8 @@ Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both term
|
|
|
47
47
|
(`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
|
|
48
48
|
(`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
|
|
49
49
|
either router adds its row there; a row where a UI differs on purpose (or a found divergence) carries a `web` or
|
|
50
|
-
`repl` override and a `note`.
|
|
50
|
+
`repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
|
|
51
|
+
`CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
|
|
51
52
|
|
|
52
53
|
A spec that waits for something uses `wait_until(timeout:)`, or `wait_until!(what, timeout:)` where no check
|
|
53
54
|
follows (stubs, helper threads: it raises "timed out after N s waiting for <what>") — never a loop with no deadline.
|
|
@@ -77,7 +78,11 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
|
|
|
77
78
|
`Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
|
|
78
79
|
member added to the Host fails it until the kit has it.
|
|
79
80
|
- `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
|
|
80
|
-
commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
|
|
81
|
+
commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
|
|
82
|
+
(`Testing::Install`).
|
|
83
|
+
- `spec/samagotchi/bundle_command/check_spec.rb`: `chi bundle check` passes every shipped bundle with no errors (one
|
|
84
|
+
example per bundle), so a shipped bundle that wouldn't install or load cleanly for a user fails here too. Run it
|
|
85
|
+
by hand on one with `ruby -Ilib bin/chi bundle check lib/samagotchi/bundles/NAME`.
|
|
81
86
|
- `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
|
|
82
87
|
reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
|
|
83
88
|
bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
|
|
@@ -6,8 +6,7 @@ require_relative "session_manager"
|
|
|
6
6
|
require_relative "bridge_client"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
8
|
require_relative "parent_report"
|
|
9
|
-
require_relative "guardrails/
|
|
10
|
-
require_relative "guardrails/parent_continue"
|
|
9
|
+
require_relative "guardrails/parent_policy"
|
|
11
10
|
require_relative "cli/command"
|
|
12
11
|
require_relative "cli/flags"
|
|
13
12
|
require_relative "cli/parent_wait"
|
|
@@ -19,10 +18,10 @@ module Samagotchi
|
|
|
19
18
|
# how a parent agent running chi as a sub-agent answers; the web and
|
|
20
19
|
# chi --attach answer the same question. See docs/sub-agent.md.
|
|
21
20
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
21
|
+
# What a parent may pick is Guardrails::ParentPolicy's: by each option's
|
|
22
|
+
# effect and this process's config.yml. It is checked here and again by
|
|
23
|
+
# the worker, which sees the answer marked as chi answer's. That guards
|
|
24
|
+
# an honest but eager parent, not a boundary:
|
|
26
25
|
# the Bridge and the localhost web take answers from any local process.
|
|
27
26
|
class AnswerCommand
|
|
28
27
|
include CLI::Command
|
|
@@ -46,9 +45,11 @@ module Samagotchi
|
|
|
46
45
|
about and finishes its reply
|
|
47
46
|
--timeout S give up waiting after S seconds (exit 4)
|
|
48
47
|
--format json one JSON object on stdout (see chi send --help)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
What a parent may pick follows guardrails.parent_answers in
|
|
49
|
+
chi's config.yml: soft (the default) lets "Allow once" and
|
|
50
|
+
Continue through, standard Continue but no allow, hard only a
|
|
51
|
+
deny, a Stop or a plain answer. A wider allow, and any allow on
|
|
52
|
+
chi's own config, is the user's: deny it, and tell your user.
|
|
52
53
|
A step-limit question (kind continue): --option Continue [--text
|
|
53
54
|
STEER] (the text joins the continued turn as a steer), or
|
|
54
55
|
--option Stop [--text WHY] (exit 0, status not_continued); it
|
|
@@ -121,7 +122,7 @@ module Samagotchi
|
|
|
121
122
|
return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
|
|
122
123
|
|
|
123
124
|
selected = selection(pending, options) or return @selection_status
|
|
124
|
-
refusal =
|
|
125
|
+
refusal = parent_refusal(pending, selected, session)
|
|
125
126
|
return failure(refusal) if refusal
|
|
126
127
|
|
|
127
128
|
posted = post(id, qid, selected, options)
|
|
@@ -158,21 +159,16 @@ module Samagotchi
|
|
|
158
159
|
labels.uniq
|
|
159
160
|
end
|
|
160
161
|
|
|
161
|
-
# Why a parent may not give this answer
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
# worker checks it again with its own
|
|
165
|
-
def
|
|
162
|
+
# Why a parent may not give this answer, or nil (Guardrails::ParentPolicy,
|
|
163
|
+
# with this process's config.yml tightened by the session file's own
|
|
164
|
+
# parent_answers, and the question's own tags). A deny, text alone or a
|
|
165
|
+
# dismissal always passes. The worker checks it again with its own.
|
|
166
|
+
def parent_refusal(pending, selected, session)
|
|
166
167
|
offered = Array(pending.options).map(&:to_s)
|
|
167
168
|
indices = selected.map { |label| offered.index(label) }
|
|
168
|
-
|
|
169
|
-
reason
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
# Why a parent may not answer Continue (turn.parent_continue: false in
|
|
173
|
-
# this process's config.yml), or nil; the worker checks it again.
|
|
174
|
-
def continue_refusal(pending, selected)
|
|
175
|
-
Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
|
|
169
|
+
strategy = Guardrails::ParentPolicy.for_session(session)
|
|
170
|
+
reason = Guardrails::ParentPolicy.refusal(pending, indices, strategy: strategy)
|
|
171
|
+
reason && Guardrails::ParentPolicy.message(reason, pending: pending, strategy: strategy)
|
|
176
172
|
end
|
|
177
173
|
|
|
178
174
|
# A step-limit question can't be left unanswered: the turn waits on it.
|
|
@@ -206,8 +202,8 @@ module Samagotchi
|
|
|
206
202
|
|
|
207
203
|
not_open(qid)
|
|
208
204
|
when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
|
|
209
|
-
# The worker's own
|
|
210
|
-
when 403 then failure(reply.json&.dig("detail") || "the worker refused
|
|
205
|
+
# The worker's own strategy refused the option (ParentPolicy).
|
|
206
|
+
when 403 then failure(reply.json&.dig("detail") || "the worker refused that option: deny it, and tell your user")
|
|
211
207
|
when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
|
|
212
208
|
when 404
|
|
213
209
|
return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../context_usage"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class Bridge
|
|
7
|
+
# What an SSE client gets for an engine event (SSEWriter#write_event, live
|
|
8
|
+
# and replayed alike). A :generation_chunk carries chi's own counts
|
|
9
|
+
# (`counts`, ContextUsage.normalize of the provider's payload) instead of
|
|
10
|
+
# the provider's raw `payload`, and no raw `content` (the chat loop's is
|
|
11
|
+
# reasoning + content again, the native loop's the raw stream with its
|
|
12
|
+
# tool-call bytes and thought tags): clients read `text`, `thinking`,
|
|
13
|
+
# `tool_call` and `counts`, never a provider's payload shape. `?raw=1` on
|
|
14
|
+
# the worker's stream skips this (debugging).
|
|
15
|
+
#
|
|
16
|
+
# Pure: the event is the one hash SessionObserver hands every observer
|
|
17
|
+
# (the ring, TurnAccumulator, the other writers, SessionMetrics, hooks),
|
|
18
|
+
# so it is never mutated; keys are added or removed on a copy, and any
|
|
19
|
+
# key not named here (event_seq included) passes through.
|
|
20
|
+
module ClientEvent
|
|
21
|
+
# The counts a client gets: ctx_pct stays out, as normalize computes it
|
|
22
|
+
# without the session's LLM context budget (the client counts
|
|
23
|
+
# total_tokens against the window or budget itself).
|
|
24
|
+
COUNT_KEYS = %i[prompt_tokens completion_tokens total_tokens context_window_tokens].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param event [Hash] the engine's event (symbol keys; its payload has
|
|
29
|
+
# string keys)
|
|
30
|
+
# @return [Hash] the event as clients get it: a copy for a
|
|
31
|
+
# :generation_chunk, the same object for every other event
|
|
32
|
+
def project(event)
|
|
33
|
+
return event unless event.is_a?(Hash) && event[:type].to_s == "generation_chunk"
|
|
34
|
+
|
|
35
|
+
projected = event.except(:payload, :content)
|
|
36
|
+
found = counts(event[:payload])
|
|
37
|
+
found ? projected.merge(counts: found) : projected
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The payload's normalized counts, nil keys left out; nil when the
|
|
41
|
+
# payload reports none (a missing key means unknown, never 0).
|
|
42
|
+
# @return [Hash, nil]
|
|
43
|
+
def counts(payload)
|
|
44
|
+
usage = ContextUsage.normalize(payload)
|
|
45
|
+
found = usage&.slice(*COUNT_KEYS)&.compact
|
|
46
|
+
found.nil? || found.empty? ? nil : found
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require_relative "client_event"
|
|
5
6
|
require_relative "event_id"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -58,10 +59,12 @@ module Samagotchi
|
|
|
58
59
|
# @param bridge [Samagotchi::Bridge, nil] for stop signalling
|
|
59
60
|
# @param max_queue [Integer] bounded-queue capacity
|
|
60
61
|
# @param heartbeat_interval [Float] idle-seconds between `: ping` frames
|
|
62
|
+
# @param raw [Boolean] write events as the engine emitted them, not as
|
|
63
|
+
# clients get them (ClientEvent): `?raw=1`, for debugging
|
|
61
64
|
def initialize(engine:, ring:, session_id:, last_event_id: nil, epoch: nil,
|
|
62
65
|
frame_provider: nil, join_with_snapshot: false, bridge: nil,
|
|
63
66
|
max_queue: DEFAULT_MAX_QUEUE,
|
|
64
|
-
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
|
|
67
|
+
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, raw: false)
|
|
65
68
|
@engine = engine
|
|
66
69
|
@ring = ring
|
|
67
70
|
@session_id = session_id
|
|
@@ -72,6 +75,7 @@ module Samagotchi
|
|
|
72
75
|
@bridge = bridge
|
|
73
76
|
@max_queue = max_queue
|
|
74
77
|
@heartbeat_interval = heartbeat_interval
|
|
78
|
+
@raw = raw
|
|
75
79
|
|
|
76
80
|
@queue = BoundedQueue.new(capacity: @max_queue)
|
|
77
81
|
cursor_seq, cursor_epoch = EventId.parse(@last_event_id)
|
|
@@ -203,7 +207,7 @@ module Samagotchi
|
|
|
203
207
|
seq = event[:event_seq]
|
|
204
208
|
return if seq && seq <= @high_water
|
|
205
209
|
|
|
206
|
-
write_frame(io, seq: seq, data: event)
|
|
210
|
+
write_frame(io, seq: seq, data: @raw ? event : ClientEvent.project(event))
|
|
207
211
|
@high_water = seq if seq && seq > @high_water
|
|
208
212
|
end
|
|
209
213
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -19,7 +19,6 @@ require_relative "bridge/sse_writer"
|
|
|
19
19
|
require_relative "bridge/turn_accumulator"
|
|
20
20
|
require_relative "session"
|
|
21
21
|
require_relative "engine"
|
|
22
|
-
require_relative "guardrails/parent_approvals"
|
|
23
22
|
require_relative "session_commands"
|
|
24
23
|
require_relative "queued_command"
|
|
25
24
|
require_relative "steer"
|
|
@@ -309,10 +308,11 @@ module Samagotchi
|
|
|
309
308
|
# @param after_file [String, nil] the input file it came in (its
|
|
310
309
|
# basename): it runs once that file is taken (Worker#on_command)
|
|
311
310
|
# @return [String] its command_id
|
|
312
|
-
def queue_command(line, client_id: nil, card: false, after_file: nil)
|
|
311
|
+
def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
|
|
313
312
|
# A card's action (the step-limit question's answer): the UIs leave
|
|
314
|
-
# its line out.
|
|
315
|
-
|
|
313
|
+
# its line out. +enqueued_id+: a file-delivered command's input, whose
|
|
314
|
+
# sender's wait its command_ran settles.
|
|
315
|
+
command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card).with(enqueued_id: enqueued_id)
|
|
316
316
|
@engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
|
|
317
317
|
command.command_id
|
|
318
318
|
end
|
|
@@ -420,7 +420,7 @@ module Samagotchi
|
|
|
420
420
|
cursor = reconnect_cursor(headers, request[:query])
|
|
421
421
|
Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
|
|
422
422
|
serve_sse(io, route.last, last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
|
|
423
|
-
client_id: stream_client_id(request[:query]))
|
|
423
|
+
client_id: stream_client_id(request[:query]), raw: raw_requested?(request[:query]))
|
|
424
424
|
break # SSE owns the connection until the client disconnects.
|
|
425
425
|
else
|
|
426
426
|
payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
|
|
@@ -447,7 +447,7 @@ module Samagotchi
|
|
|
447
447
|
|
|
448
448
|
# Serve an SSE stream. Owns the connection until the client disconnects.
|
|
449
449
|
# The connection thread IS the writer thread: serve! blocks until then.
|
|
450
|
-
def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil)
|
|
450
|
+
def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil, raw: false)
|
|
451
451
|
unless own_session?(session_id)
|
|
452
452
|
write_json(io, 404, {}, { error: "unknown_session" })
|
|
453
453
|
return
|
|
@@ -463,7 +463,8 @@ module Samagotchi
|
|
|
463
463
|
# A reconnect (with a cursor) replays; only a fresh join snapshots.
|
|
464
464
|
join_with_snapshot: snapshot && last_event_id.nil?,
|
|
465
465
|
bridge: self,
|
|
466
|
-
heartbeat_interval: @heartbeat_interval
|
|
466
|
+
heartbeat_interval: @heartbeat_interval,
|
|
467
|
+
raw: raw
|
|
467
468
|
)
|
|
468
469
|
@mutex.synchronize do
|
|
469
470
|
@open_streams += 1
|
|
@@ -649,7 +650,7 @@ module Samagotchi
|
|
|
649
650
|
selected = fetched(parsed, "selected")
|
|
650
651
|
freeform = fetched(parsed, "freeform")
|
|
651
652
|
# chi answer's marker: a parent agent, held to this worker's
|
|
652
|
-
#
|
|
653
|
+
# Guardrails::ParentPolicy (QuestionDesk::Refused). Not a boundary:
|
|
653
654
|
# any local process may post here, with or without it.
|
|
654
655
|
client_id = fetched(parsed, "client_id")
|
|
655
656
|
if qid.to_s.strip.empty?
|
|
@@ -667,9 +668,8 @@ module Samagotchi
|
|
|
667
668
|
# Another client answered first, or the question was cancelled.
|
|
668
669
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
669
670
|
rescue QuestionDesk::Refused => e
|
|
670
|
-
Log.info(:bridge, "
|
|
671
|
-
[{}, 403, { error: e.reason
|
|
672
|
-
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
671
|
+
Log.info(:bridge, "parent_answer_refused", sid: session_id, id: qid, reason: e.reason)
|
|
672
|
+
[{}, 403, { error: "parent_answer_refused", reason: e.reason.to_s, detail: e.detail }]
|
|
673
673
|
rescue ArgumentError => e
|
|
674
674
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
675
675
|
end
|
|
@@ -1180,6 +1180,17 @@ module Samagotchi
|
|
|
1180
1180
|
false
|
|
1181
1181
|
end
|
|
1182
1182
|
|
|
1183
|
+
# `?raw=1`: events as the engine emitted them (a chunk's provider
|
|
1184
|
+
# payload), not as clients get them (ClientEvent). For debugging on the
|
|
1185
|
+
# worker's port: chi web never passes it on.
|
|
1186
|
+
def raw_requested?(query)
|
|
1187
|
+
return false unless query
|
|
1188
|
+
|
|
1189
|
+
URI.decode_www_form(query).to_h["raw"].to_s == "1"
|
|
1190
|
+
rescue StandardError
|
|
1191
|
+
false
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1183
1194
|
# `?client_id=`: whose stream it is (see #open_streams_except).
|
|
1184
1195
|
def stream_client_id(query)
|
|
1185
1196
|
return nil unless query
|
|
@@ -114,8 +114,8 @@ module Samagotchi
|
|
|
114
114
|
# POST /session/:id/answer. 200 = recorded, 409 = another client answered
|
|
115
115
|
# first (or the question is gone), 400 = invalid selection, 408
|
|
116
116
|
# deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
|
|
117
|
-
#
|
|
118
|
-
# beyond the worker's guardrails.
|
|
117
|
+
# parent_answer_refused (with its reason) = a parent's pick (client_id
|
|
118
|
+
# cli:answer) beyond the worker's guardrails.parent_answers.
|
|
119
119
|
# @param client_id [String, nil] who answers (chi answer marks its own)
|
|
120
120
|
# @return [Response]
|
|
121
121
|
def answer(id:, selected:, freeform: nil, client_id: nil)
|