switchroom 0.19.41 → 0.19.43
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.
- package/dist/agent-scheduler/index.js +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audience classification — "who is this text FOR?" (W1-d, issue #3865).
|
|
3
|
+
*
|
|
4
|
+
* THE FAILURE THIS EXISTS FOR
|
|
5
|
+
* ---------------------------
|
|
6
|
+
* Nothing in the capture → journal → sweep pipeline recorded who a piece of
|
|
7
|
+
* text was addressed to. Capture selects the terminal assistant-prose run
|
|
8
|
+
* STRUCTURALLY (`narration-classify.mjs` `selectBackstopDelivery`, substance
|
|
9
|
+
* floor at `silent-end-scan.mjs`), so when the reply tool throws (#3861) the
|
|
10
|
+
* agent's trailing working notes are the terminal prose run — they get
|
|
11
|
+
* journaled as if they were the answer, and the outbox sweep faithfully
|
|
12
|
+
* delivers internal orchestration prose into the operator's DM hours later.
|
|
13
|
+
* That is a private-data leak, not a cosmetic one.
|
|
14
|
+
*
|
|
15
|
+
* SHARED-PURE-MODULE, ON PURPOSE
|
|
16
|
+
* ------------------------------
|
|
17
|
+
* This is a plain `.mjs` with a sibling `.d.mts` (the same shape as
|
|
18
|
+
* `narration-classify.mjs`) because BOTH halves of the pipeline must run the
|
|
19
|
+
* IDENTICAL predicate and there is no build step between them:
|
|
20
|
+
* - the Stop hooks (`silent-end-scan.mjs`, `silent-end-interrupt-stop.mjs`)
|
|
21
|
+
* run unbundled under node and can only import sibling `.mjs`;
|
|
22
|
+
* - the gateway (`outbox.ts`, `gateway/outbox-sweep.ts`) is bundled TS.
|
|
23
|
+
* A second hand-synced copy would drift, and a drift here is a leak.
|
|
24
|
+
*
|
|
25
|
+
* EVERYTHING HERE IS PURE. No fs, no env, no clock. The callers own their IO.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Audience: the text is for the human on the other end of the chat. */
|
|
29
|
+
export const AUDIENCE_USER = 'user'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Audience: the text is the agent talking to itself (working notes,
|
|
33
|
+
* orchestration prose). It may be journaled and inspected, but it must never
|
|
34
|
+
* be delivered to a chat.
|
|
35
|
+
*/
|
|
36
|
+
export const AUDIENCE_INTERNAL = 'internal'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Obligation state for the resolved chat, as seen at capture time.
|
|
40
|
+
*
|
|
41
|
+
* `'unknown'` is a first-class value and is NOT the same as `false`: an
|
|
42
|
+
* unreadable / malformed `obligations.json` means we could not establish that
|
|
43
|
+
* the user is NOT waiting on an answer, and the fail-safe direction is to
|
|
44
|
+
* assume they ARE (see `decideCaptureAudience`).
|
|
45
|
+
*/
|
|
46
|
+
/** @typedef {true | false | 'unknown'} OpenObligationState */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* THE classifier. Conservative by construction, and deliberately asymmetric.
|
|
50
|
+
*
|
|
51
|
+
* We mark `internal` ONLY on positive evidence:
|
|
52
|
+
*
|
|
53
|
+
* replyToolThrewThisTurn === true AND openInboundObligation === false
|
|
54
|
+
*
|
|
55
|
+
* Both halves are load-bearing:
|
|
56
|
+
* - "the reply tool threw" is what makes the trailing prose suspect at all.
|
|
57
|
+
* Without a throw, terminal prose after a bypassed reply tool is the
|
|
58
|
+
* ordinary #3513 backstop case and IS the answer.
|
|
59
|
+
* - "no open inbound obligation" is what says nobody is waiting. If the user
|
|
60
|
+
* asked something and we still owe them a reply, the least-bad move is to
|
|
61
|
+
* deliver the prose we have; suppressing it would leave them with silence.
|
|
62
|
+
*
|
|
63
|
+
* The asymmetry is the whole safety argument. Getting it wrong in the
|
|
64
|
+
* `internal` direction produces a SILENT NO-OP (severity 3: the user asked and
|
|
65
|
+
* got nothing, with no signal). Getting it wrong in the `user` direction
|
|
66
|
+
* reproduces today's status quo, which is bad but is not a NEW failure class.
|
|
67
|
+
* So every uncertain input resolves to `user`.
|
|
68
|
+
*
|
|
69
|
+
* The narration classifier is deliberately NOT an input here. It is a VETO
|
|
70
|
+
* only: it already decides, upstream in `selectBackstopDelivery`, whether a
|
|
71
|
+
* prose run is capturable at all. Promoting it to a positive `internal` signal
|
|
72
|
+
* would let a heuristic prose-shape match silently swallow a real answer —
|
|
73
|
+
* exactly the severity-3 error this function is built to avoid.
|
|
74
|
+
*
|
|
75
|
+
* @param {{
|
|
76
|
+
* replyToolThrewThisTurn?: boolean,
|
|
77
|
+
* openInboundObligation?: true | false | 'unknown',
|
|
78
|
+
* }} signals
|
|
79
|
+
* @returns {'user' | 'internal'}
|
|
80
|
+
*/
|
|
81
|
+
export function decideCaptureAudience(signals) {
|
|
82
|
+
const threw = signals?.replyToolThrewThisTurn === true
|
|
83
|
+
if (!threw) return AUDIENCE_USER
|
|
84
|
+
// Only a POSITIVE, known-empty obligation state clears the second gate.
|
|
85
|
+
// `'unknown'`, `true`, and `undefined` all mean "someone may be waiting".
|
|
86
|
+
if (signals?.openInboundObligation !== false) return AUDIENCE_USER
|
|
87
|
+
return AUDIENCE_INTERNAL
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the open-inbound-obligation state for a chat from the RAW text of the
|
|
92
|
+
* gateway's durable obligation snapshot (`<STATE_DIR>/obligations.json`, written
|
|
93
|
+
* by `persistObligations` on every ledger mutation — open AND close, see
|
|
94
|
+
* `obligation-ledger.ts:156`). Pure: the caller does the `readFileSync` and
|
|
95
|
+
* passes `null` when the file is absent or unreadable.
|
|
96
|
+
*
|
|
97
|
+
* The three-valued result is the point:
|
|
98
|
+
*
|
|
99
|
+
* - `snapshotTrusted === false` → `'unknown'`, WITHOUT reading the bytes. See
|
|
100
|
+
* the stale-snapshot note below (#4146).
|
|
101
|
+
* - `null` snapshot → `'unknown'`. An absent file is NOT proof that
|
|
102
|
+
* nobody is waiting: it is equally the shape of
|
|
103
|
+
* an agent running with `SWITCHROOM_OBLIGATION_
|
|
104
|
+
* LEDGER=0`, or a STATIC gateway, where
|
|
105
|
+
* obligations exist but are never persisted.
|
|
106
|
+
* Reading it as "empty" would let us suppress on
|
|
107
|
+
* an agent that simply doesn't track waiting
|
|
108
|
+
* users — a manufactured silent no-op.
|
|
109
|
+
*
|
|
110
|
+
* ## The stale-snapshot hole (#4146) and why `snapshotTrusted` exists
|
|
111
|
+
*
|
|
112
|
+
* The bullet above used to be the WHOLE argument, and it was wrong: a leftover
|
|
113
|
+
* file is not an absent file. Turn the ledger off (or run STATIC) on an agent
|
|
114
|
+
* that previously ran with it on, and persistence stops while the old
|
|
115
|
+
* `obligations.json` lingers — so an empty (or merely out-of-date) snapshot
|
|
116
|
+
* reads as positive proof that nobody is waiting, while obligations are in fact
|
|
117
|
+
* going untracked. That is the one configuration where the asymmetric default
|
|
118
|
+
* inverts, and it is a direct path to swallowing a real answer.
|
|
119
|
+
*
|
|
120
|
+
* It is closed on BOTH sides, deterministically, with no freshness heuristic:
|
|
121
|
+
*
|
|
122
|
+
* 1. THE FILE IS REMOVED when it cannot be maintained. The gateway unlinks
|
|
123
|
+
* the snapshot at boot whenever it is STATIC or the ledger is disabled
|
|
124
|
+
* (`gateway.ts`, next to the hydrate branch) — the exact site that already
|
|
125
|
+
* knows the condition. After that, "absent" genuinely does mean what the
|
|
126
|
+
* bullet above claims.
|
|
127
|
+
* 2. THE READER DOES NOT TRUST IT ANYWAY when it knows persistence is off.
|
|
128
|
+
* The hook passes `snapshotTrusted: SWITCHROOM_OBLIGATION_LEDGER !== '0'`,
|
|
129
|
+
* which covers the window before that gateway boots, an older gateway
|
|
130
|
+
* build that never cleaned up, and a gateway that is not running at all.
|
|
131
|
+
*
|
|
132
|
+
* Note the case that ISN'T staleness: with the ledger ON, the snapshot only
|
|
133
|
+
* ever mutates through the gateway, so a gateway that is merely stopped leaves
|
|
134
|
+
* a snapshot that is exactly correct rather than stale. That is why an mtime
|
|
135
|
+
* bound is the wrong instrument here — it would fail an idle-but-accurate
|
|
136
|
+
* snapshot (weakening #4140 for quiet agents) while still not proving liveness.
|
|
137
|
+
* - unparseable / wrong shape → `'unknown'` (same reasoning; a torn or
|
|
138
|
+
* forward-incompatible snapshot proves nothing).
|
|
139
|
+
* - `chatId == null` (the chat
|
|
140
|
+
* could not be resolved) and
|
|
141
|
+
* the open set is non-empty → `'unknown'`. Someone is waiting and we cannot
|
|
142
|
+
* rule out that it is this record's reader.
|
|
143
|
+
* - `chatId == null` and the
|
|
144
|
+
* open set is empty → `false`. Nobody anywhere is waiting, so the
|
|
145
|
+
* scoping question is moot.
|
|
146
|
+
* - otherwise → `true` iff some open obligation names this chat.
|
|
147
|
+
*
|
|
148
|
+
* @param {{ snapshotRaw?: string | null, chatId?: string | null, snapshotTrusted?: boolean }} args
|
|
149
|
+
* @returns {true | false | 'unknown'}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveOpenObligation(args) {
|
|
152
|
+
// #4146: an explicit "persistence is off" beats anything on disk. Only an
|
|
153
|
+
// exact `false` distrusts — absent/undefined keeps the pre-#4146 behaviour,
|
|
154
|
+
// so a caller that does not know cannot accidentally weaken the gate.
|
|
155
|
+
if (args?.snapshotTrusted === false) return 'unknown'
|
|
156
|
+
const raw = args?.snapshotRaw
|
|
157
|
+
if (typeof raw !== 'string' || raw.length === 0) return 'unknown'
|
|
158
|
+
let parsed
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(raw)
|
|
161
|
+
} catch {
|
|
162
|
+
return 'unknown'
|
|
163
|
+
}
|
|
164
|
+
if (parsed == null || typeof parsed !== 'object') return 'unknown'
|
|
165
|
+
const rows = parsed.obligations
|
|
166
|
+
if (!Array.isArray(rows)) return 'unknown'
|
|
167
|
+
const open = rows.filter(
|
|
168
|
+
(r) => r != null && typeof r === 'object' && typeof r.chatId === 'string',
|
|
169
|
+
)
|
|
170
|
+
const chatId = args?.chatId == null ? null : String(args.chatId)
|
|
171
|
+
if (chatId == null) return open.length === 0 ? false : 'unknown'
|
|
172
|
+
return open.some((r) => r.chatId === chatId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read the audience off a persisted record or journal entry.
|
|
177
|
+
*
|
|
178
|
+
* LEGACY-ROW POLICY (decided in this PR, documented here because this function
|
|
179
|
+
* IS the policy): a record with no `audience` field — every record written
|
|
180
|
+
* before this change, plus any record written by a hook that predates it during
|
|
181
|
+
* a rolling upgrade — resolves to `'user'` and delivers exactly as it does
|
|
182
|
+
* today.
|
|
183
|
+
*
|
|
184
|
+
* Rationale: a legacy record carries no evidence either way, and the
|
|
185
|
+
* fail-safe direction for no-evidence is the one whose error mode is the
|
|
186
|
+
* status quo rather than a new silent no-op (see `decideCaptureAudience`). A
|
|
187
|
+
* legacy record's window is at most `OUTBOX_MAX_AGE_MS` wide, so the legacy
|
|
188
|
+
* population is bounded and drains within 30 minutes of deploy.
|
|
189
|
+
*
|
|
190
|
+
* Any value that is not the exact `'internal'` literal (unknown strings,
|
|
191
|
+
* numbers, objects, `null`) is also `'user'` — suppression requires an
|
|
192
|
+
* affirmative, exact tag.
|
|
193
|
+
*
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @returns {'user' | 'internal'}
|
|
196
|
+
*/
|
|
197
|
+
export function resolveRecordAudience(value) {
|
|
198
|
+
return value === AUDIENCE_INTERNAL ? AUDIENCE_INTERNAL : AUDIENCE_USER
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Entry-selection predicate for the sweep. Placed here, shared, and pure so the
|
|
203
|
+
* gate is a property of the RECORD rather than of whatever send adapter happens
|
|
204
|
+
* to be wired underneath it — W1-b swaps that adapter out, and this gate must
|
|
205
|
+
* survive the swap untouched.
|
|
206
|
+
*
|
|
207
|
+
* `gateEnabled` is the kill-switch seam (the sweep reads
|
|
208
|
+
* `SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE !== '0'`). It exists so the revert-check
|
|
209
|
+
* can run: with the gate off, the leak reproduces and the zero-send assertion
|
|
210
|
+
* must fail. A test that still passes with the gate off is not testing the gate.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ audience?: unknown }} record
|
|
213
|
+
* @param {{ gateEnabled?: boolean }} [opts]
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function shouldSuppressForAudience(record, opts) {
|
|
217
|
+
if (opts?.gateEnabled === false) return false
|
|
218
|
+
return resolveRecordAudience(record?.audience) === AUDIENCE_INTERNAL
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* ── W1-d follow-up (#4141): the FOREGROUND reply-throw case ──────────────────
|
|
223
|
+
*
|
|
224
|
+
* `decideCaptureAudience` can only mark `internal` when the reply tool threw
|
|
225
|
+
* AND nobody is waiting. On a FOREGROUND Telegram turn the inbound obligation
|
|
226
|
+
* is still OPEN at capture time (it only closes on a substantive DELIVERED
|
|
227
|
+
* reply — `gateway/obligation-ledger.ts:153-157`), so the record classifies
|
|
228
|
+
* `user` and the trailing prose still delivers, presented as if it were the
|
|
229
|
+
* answer. That is the residual leak #4141 tracks.
|
|
230
|
+
*
|
|
231
|
+
* WHY THIS IS FRAMING AND NOT SUPPRESSION
|
|
232
|
+
* ---------------------------------------
|
|
233
|
+
* There is no deterministic discriminator here. When the reply tool throws, a
|
|
234
|
+
* model that has been told "every turn MUST end with a reply tool call" very
|
|
235
|
+
* plausibly re-writes its ANSWER as plain prose — and that prose is selected by
|
|
236
|
+
* exactly the same structural rule as working notes are. Position does not
|
|
237
|
+
* separate them (both follow the errored `tool_result`), and wording heuristics
|
|
238
|
+
* are provably incomplete (`narration-classify.mjs` says so in its own header).
|
|
239
|
+
*
|
|
240
|
+
* So suppressing here would trade a VISIBLE wrong-content failure for an
|
|
241
|
+
* INVISIBLE no-answer failure, against a human who is provably still waiting.
|
|
242
|
+
* That is strictly worse and it is the exact severity-3 class #3865/#4140 exist
|
|
243
|
+
* to avoid. Replacing the prose with a fixed notice has the same defect in
|
|
244
|
+
* milder form: it destroys content that may be the answer.
|
|
245
|
+
*
|
|
246
|
+
* What we CAN assert deterministically is the provenance: the reply tool threw
|
|
247
|
+
* this turn, so this text was never delivered as an answer by the agent — the
|
|
248
|
+
* safety net scraped it off the end of the turn. Stating that, verbatim, in
|
|
249
|
+
* front of the text restores exactly the context that #3865 says late delivery
|
|
250
|
+
* strips ("delivery hours later strips the context that would have made the
|
|
251
|
+
* difference obvious"). The reader can then tell notes from an answer, which is
|
|
252
|
+
* something no classifier in this pipeline can do.
|
|
253
|
+
*
|
|
254
|
+
* Properties, deliberately:
|
|
255
|
+
* - it can NEVER produce silence (something is always delivered);
|
|
256
|
+
* - it triggers on a hard structural fact (`is_error: true` on a reply-tool
|
|
257
|
+
* `tool_result`), never on prose shape or model discipline;
|
|
258
|
+
* - a missing / non-`true` signal changes nothing at all.
|
|
259
|
+
*/
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The fixed provenance banner. Plain text in the established sweep-prefix
|
|
263
|
+
* idiom (`(delayed) `, `(from background task) `) — no markdown specials, so
|
|
264
|
+
* it cannot parse-reject a rich send, and no emoji.
|
|
265
|
+
*/
|
|
266
|
+
export const REPLY_THROW_PROVENANCE_NOTICE =
|
|
267
|
+
'(my reply tool errored this turn, so this was never sent as an answer — ' +
|
|
268
|
+
'the safety net captured the last thing I wrote, which may be working notes)'
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Should this record's delivered text carry the provenance banner?
|
|
272
|
+
*
|
|
273
|
+
* POSITIVE evidence only: an exact `true` on the record's persisted
|
|
274
|
+
* `replyToolThrewThisTurn`. Missing, `undefined`, `'true'`, `1` — anything that
|
|
275
|
+
* is not the boolean — changes nothing, so a legacy record (written before the
|
|
276
|
+
* field existed) delivers byte-for-byte as it does today.
|
|
277
|
+
*
|
|
278
|
+
* `frameEnabled === false` is the kill switch, and it is the seam the
|
|
279
|
+
* revert-check flips.
|
|
280
|
+
*
|
|
281
|
+
* @param {{ replyToolThrewThisTurn?: unknown }} record
|
|
282
|
+
* @param {{ frameEnabled?: boolean }} [opts]
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
export function shouldFrameReplyThrow(record, opts) {
|
|
286
|
+
if (opts?.frameEnabled === false) return false
|
|
287
|
+
return record?.replyToolThrewThisTurn === true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Compose the banner onto the delivered body. Pure; the caller owns the
|
|
292
|
+
* `(delayed) ` / `(from background task) ` prefixes, which stay OUTSIDE (they
|
|
293
|
+
* describe the delivery, this describes the text).
|
|
294
|
+
*
|
|
295
|
+
* @param {string} text
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
export function applyReplyThrowFraming(text) {
|
|
299
|
+
const body = typeof text === 'string' ? text : ''
|
|
300
|
+
// Empty body: the sweep's send adapter early-returns on empty text, and a
|
|
301
|
+
// banner with nothing under it would be a message about nothing. Leave it.
|
|
302
|
+
if (body.trim().length === 0) return body
|
|
303
|
+
return `${REPLY_THROW_PROVENANCE_NOTICE}\n\n${body}`
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Structured telemetry for a framed delivery — the observability half of the
|
|
308
|
+
* rule, mirroring {@link formatInternalSuppression}. Framing changes what a
|
|
309
|
+
* human sees, so it must never be inferable only from the absence of a log
|
|
310
|
+
* line. Worded to match NONE of `GATEWAY_SIGNATURES` in
|
|
311
|
+
* `src/fleet-health/detect.ts`: a framed delivery IS a delivery, and must not
|
|
312
|
+
* page anyone.
|
|
313
|
+
*
|
|
314
|
+
* @param {{
|
|
315
|
+
* turnNonce: string,
|
|
316
|
+
* turnId?: string | null,
|
|
317
|
+
* chatId?: string | null,
|
|
318
|
+
* textSha256?: string,
|
|
319
|
+
* source?: string,
|
|
320
|
+
* }} s
|
|
321
|
+
* @returns {string}
|
|
322
|
+
*/
|
|
323
|
+
export function formatReplyThrowFraming(s) {
|
|
324
|
+
return (
|
|
325
|
+
`telegram gateway: outbox provenance framing nonce=${s.turnNonce} ` +
|
|
326
|
+
`turnId=${s.turnId ?? 'unknown'} chatId=${s.chatId ?? 'unresolved'} ` +
|
|
327
|
+
`sha=${(s.textSha256 ?? '').slice(0, 12)} source=${s.source ?? 'unknown'} ` +
|
|
328
|
+
`audience=user replyToolThrew=true — captured prose delivered with its ` +
|
|
329
|
+
`provenance stated, not suppressed\n`
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The structured telemetry line emitted when the sweep suppresses an
|
|
335
|
+
* `internal` record. Mirrors `formatOrphanEscalation` (#4104): an exported pure
|
|
336
|
+
* formatter plus an injected `writeLog`, so the line is greppable, unit-
|
|
337
|
+
* assertable, and CANNOT accidentally become a chat send.
|
|
338
|
+
*
|
|
339
|
+
* It carries `turnId=` in the gateway's `<chat>:<thread>#<message>` shape so
|
|
340
|
+
* `extractTurnId` in `src/fleet-health/detect.ts` can join it back to the turn,
|
|
341
|
+
* and it is worded so it matches NONE of that file's `GATEWAY_SIGNATURES`:
|
|
342
|
+
* suppression-by-design is telemetry, not a delivery failure, and must not page
|
|
343
|
+
* anyone.
|
|
344
|
+
*
|
|
345
|
+
* @param {{
|
|
346
|
+
* turnNonce: string,
|
|
347
|
+
* turnId?: string | null,
|
|
348
|
+
* chatId?: string | null,
|
|
349
|
+
* textSha256?: string,
|
|
350
|
+
* ageMs?: number,
|
|
351
|
+
* source?: string,
|
|
352
|
+
* pastWindow?: boolean,
|
|
353
|
+
* }} s
|
|
354
|
+
* @returns {string}
|
|
355
|
+
*/
|
|
356
|
+
export function formatInternalSuppression(s) {
|
|
357
|
+
return (
|
|
358
|
+
`telegram gateway: outbox audience suppression nonce=${s.turnNonce} ` +
|
|
359
|
+
`turnId=${s.turnId ?? 'unknown'} chatId=${s.chatId ?? 'unresolved'} ` +
|
|
360
|
+
`sha=${(s.textSha256 ?? '').slice(0, 12)} ageMs=${s.ageMs ?? 0} ` +
|
|
361
|
+
`source=${s.source ?? 'unknown'} pastWindow=${s.pastWindow === true} ` +
|
|
362
|
+
`audience=internal — internal prose withheld from chat, journaled terminally\n`
|
|
363
|
+
)
|
|
364
|
+
}
|
|
@@ -69,6 +69,11 @@ import {
|
|
|
69
69
|
isCapturedProseDeliveryEnabledEnv,
|
|
70
70
|
isGatewayHeartbeatFresh,
|
|
71
71
|
} from './silent-end-scan.mjs'
|
|
72
|
+
import {
|
|
73
|
+
decideCaptureAudience,
|
|
74
|
+
resolveOpenObligation,
|
|
75
|
+
AUDIENCE_INTERNAL,
|
|
76
|
+
} from './audience-classify.mjs'
|
|
72
77
|
|
|
73
78
|
// MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
|
|
74
79
|
// (this hook is a standalone .mjs and can't import the TS module).
|
|
@@ -114,6 +119,16 @@ function buildNextState(base, decision, retryCount) {
|
|
|
114
119
|
} else {
|
|
115
120
|
delete next.pendingText
|
|
116
121
|
}
|
|
122
|
+
// #4141: carry THIS turn's reply-throw signal through to the captured-prose
|
|
123
|
+
// bridge, which is the delivering machine on the ELECTED path and has no
|
|
124
|
+
// transcript of its own. Same stale-carryover discipline as `pendingText` /
|
|
125
|
+
// `turnId`: explicitly DELETED when this turn's scan saw no throw, so a
|
|
126
|
+
// spread of a prior turn's file can never mislabel a clean turn.
|
|
127
|
+
if (decision.replyToolThrewThisTurn === true) {
|
|
128
|
+
next.replyToolThrewThisTurn = true
|
|
129
|
+
} else {
|
|
130
|
+
delete next.replyToolThrewThisTurn
|
|
131
|
+
}
|
|
117
132
|
return next
|
|
118
133
|
}
|
|
119
134
|
|
|
@@ -156,13 +171,64 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
|
|
|
156
171
|
return false
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
/**
|
|
175
|
+
* W1-d (#3865): classify WHO the captured prose is for, at capture time — the
|
|
176
|
+
* only point in the pipeline that can still see the turn.
|
|
177
|
+
*
|
|
178
|
+
* The obligation snapshot is read here (impure) and the verdict is computed by
|
|
179
|
+
* the shared pure classifier, so the hook and the gateway run the identical
|
|
180
|
+
* predicate. Any failure resolves to `'user'` via `resolveOpenObligation`'s
|
|
181
|
+
* `'unknown'` → the record delivers exactly as it does today.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} stateDir
|
|
184
|
+
* @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
|
|
185
|
+
* @returns {'user' | 'internal'}
|
|
186
|
+
*/
|
|
187
|
+
function classifyCaptureAudience(stateDir, capture) {
|
|
188
|
+
let snapshotRaw = null
|
|
189
|
+
try {
|
|
190
|
+
const p = join(stateDir, 'obligations.json')
|
|
191
|
+
if (existsSync(p)) snapshotRaw = readFileSync(p, 'utf8')
|
|
192
|
+
} catch {
|
|
193
|
+
/* unreadable ⇒ null ⇒ 'unknown' ⇒ 'user' (fail-safe toward delivering) */
|
|
194
|
+
}
|
|
195
|
+
// #4146: with the ledger disabled, obligations are going UNTRACKED and any
|
|
196
|
+
// file on disk is a leftover, not a fact. Distrust it outright rather than
|
|
197
|
+
// read a stale empty set as positive proof that nobody is waiting — that is
|
|
198
|
+
// the one configuration in which the asymmetric default would invert. (The
|
|
199
|
+
// gateway also unlinks the snapshot at boot in this mode; this is the
|
|
200
|
+
// reader-side half, covering the window before it does.)
|
|
201
|
+
// STATIC is the second persistence-off mode (`gateway.ts:1343` gates the same
|
|
202
|
+
// `onChange` wiring on it at `:2810`), so it must distrust the file for the
|
|
203
|
+
// same reason. Writer-side cleanup already covers it at boot; this leg covers
|
|
204
|
+
// the window the writer cannot — an unlink that failed (EACCES) or a gateway
|
|
205
|
+
// that has not restarted since the mode changed. Honest caveat:
|
|
206
|
+
// `TELEGRAM_ACCESS_MODE` is supplied by the host, not by this repo, so if it
|
|
207
|
+
// is not exported into the hook's environment this leg degrades to a no-op and
|
|
208
|
+
// the writer-side unlink remains the cover. It can only ever move the verdict
|
|
209
|
+
// toward DELIVER, so a degraded read is never worse than today.
|
|
210
|
+
const snapshotTrusted =
|
|
211
|
+
process.env.SWITCHROOM_OBLIGATION_LEDGER !== '0' &&
|
|
212
|
+
process.env.TELEGRAM_ACCESS_MODE !== 'static'
|
|
213
|
+
return decideCaptureAudience({
|
|
214
|
+
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
215
|
+
openInboundObligation: resolveOpenObligation({
|
|
216
|
+
snapshotRaw,
|
|
217
|
+
snapshotTrusted,
|
|
218
|
+
// Same chat the sweep will route to: the envelope chat when present,
|
|
219
|
+
// else this session's origin chat (`resolveOutboxChat`'s F2 fallback).
|
|
220
|
+
chatId: capture.chatId ?? capture.originChatId ?? null,
|
|
221
|
+
}),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
159
225
|
/**
|
|
160
226
|
* Write the durable outbox record for a captured undelivered final answer
|
|
161
227
|
* (atomic tmp+rename), mirroring `writeOutboxRecordAtomic` in `../outbox.ts`.
|
|
162
228
|
* The gateway heartbeat sweep is the single deliverer. Best-effort; never
|
|
163
229
|
* throws. Returns true when a record exists on disk for the nonce afterward.
|
|
164
230
|
*/
|
|
165
|
-
function writeOutboxRecord(stateDir, capture) {
|
|
231
|
+
function writeOutboxRecord(stateDir, capture, audience) {
|
|
166
232
|
const outboxDir = join(stateDir, 'outbox')
|
|
167
233
|
try {
|
|
168
234
|
mkdirSync(outboxDir, { recursive: true })
|
|
@@ -187,6 +253,18 @@ function writeOutboxRecord(stateDir, capture) {
|
|
|
187
253
|
// journal alone. Driven by the SAME boolean that gates capture-vs-election
|
|
188
254
|
// in main() — fix and telemetry cannot drift.
|
|
189
255
|
replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
|
|
256
|
+
// W1-d (#3865): WHO this text is for. Always stamped explicitly (never
|
|
257
|
+
// left implicit) so a post-change record is self-describing and the
|
|
258
|
+
// sweep's gate never has to infer. `'user'` is byte-for-byte the
|
|
259
|
+
// pre-change behaviour.
|
|
260
|
+
audience: audience === AUDIENCE_INTERNAL ? AUDIENCE_INTERNAL : 'user',
|
|
261
|
+
// W1-d follow-up (#4141): the RAW structural signal, persisted alongside
|
|
262
|
+
// the verdict it fed. `audience` collapses it with the obligation state
|
|
263
|
+
// and loses it; the sweep needs it on its own to decide provenance
|
|
264
|
+
// framing for the `'user'` records the audience gate deliberately does
|
|
265
|
+
// not catch (the foreground case). Stamped here because this is the last
|
|
266
|
+
// point in the pipeline that can still see the transcript.
|
|
267
|
+
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
190
268
|
}
|
|
191
269
|
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
192
270
|
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
@@ -267,11 +345,16 @@ function main() {
|
|
|
267
345
|
`deferring to single-writer election (#3510)\n`,
|
|
268
346
|
)
|
|
269
347
|
} else {
|
|
270
|
-
|
|
348
|
+
const audience = classifyCaptureAudience(stateDir, capture)
|
|
349
|
+
writeOutboxRecord(stateDir, capture, audience)
|
|
271
350
|
process.stderr.write(
|
|
272
351
|
`[silent-end-interrupt] captured undelivered final answer to outbox ` +
|
|
273
352
|
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
|
|
274
|
-
`replyAlreadyDeliveredThisTurn=false
|
|
353
|
+
`replyAlreadyDeliveredThisTurn=false audience=${audience} ` +
|
|
354
|
+
`replyToolThrewThisTurn=${capture.replyToolThrewThisTurn === true}) — ` +
|
|
355
|
+
`${audience === AUDIENCE_INTERNAL
|
|
356
|
+
? 'internal prose: journaled for inspection, sweep will NOT deliver'
|
|
357
|
+
: 'sweep will deliver'}; allowing stop\n`,
|
|
275
358
|
)
|
|
276
359
|
process.exit(0)
|
|
277
360
|
}
|
|
@@ -248,9 +248,18 @@ function buildTurnId(chatId, threadId, messageId) {
|
|
|
248
248
|
* reply tool (Option A transcript-prose bridge). Only populated when it
|
|
249
249
|
* clears the substance floor, so the gateway never re-delivers a short
|
|
250
250
|
* trailing pleasantry. Omitted entirely otherwise.
|
|
251
|
+
* @param {boolean} [replyToolThrewThisTurn] W1-d follow-up (#4141): one of this
|
|
252
|
+
* turn's reply-tool calls came back `is_error: true`. Carried on the BLOCK
|
|
253
|
+
* result so the single-writer election can persist it onto the silent-end
|
|
254
|
+
* state file — the captured-prose bridge (the machine that actually delivers
|
|
255
|
+
* on the ELECTED path, where no outbox record is ever written) has no other
|
|
256
|
+
* way to learn it. A raw SIGNAL, never a verdict: downstream it only ever
|
|
257
|
+
* adds a provenance banner. Omitted rather than `false` when absent, so a
|
|
258
|
+
* spread of a prior state file cannot be read as positive evidence.
|
|
251
259
|
*/
|
|
252
|
-
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse) {
|
|
260
|
+
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
|
|
253
261
|
const block = { decided: 'block', reason }
|
|
262
|
+
if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
|
|
254
263
|
// Single-writer election input (#duplicate-message fix): does ANY
|
|
255
264
|
// non-empty, non-silent trailing text block exist after the last
|
|
256
265
|
// delivery event? The zero-reply election allows only when this is
|
|
@@ -381,6 +390,15 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
381
390
|
// whether emitted as plain text or as a reply-tool payload) or
|
|
382
391
|
// plain undelivered text.
|
|
383
392
|
const blocks = []
|
|
393
|
+
// W1-d follow-up (#4141): the same reply-throw signal `scanForOutboxCapture`
|
|
394
|
+
// already derives, computed here too because the ELECTED path never reaches
|
|
395
|
+
// that scanner's capture branch (a thrown-but-qualifying reply sets
|
|
396
|
+
// `replyAlreadyDeliveredThisTurn`, so the hook defers to the election and
|
|
397
|
+
// writes NO outbox record). Collected as two id sets whose intersection is
|
|
398
|
+
// the signal. Read-only: nothing below branches on it, so no verdict of this
|
|
399
|
+
// scanner changes.
|
|
400
|
+
const replyToolUseIds = new Set()
|
|
401
|
+
const erroredToolUseIds = new Set()
|
|
384
402
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
385
403
|
const line = lines[i]
|
|
386
404
|
if (!line || line[0] !== '{') continue
|
|
@@ -390,6 +408,20 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
390
408
|
// be in a separate transcript file, but `isSidechain:true` is the
|
|
391
409
|
// documented marker if they leak).
|
|
392
410
|
if (obj?.isSidechain === true) continue
|
|
411
|
+
if (obj?.type === 'user') {
|
|
412
|
+
// Tool RESULT lines: `user`-type, content array carrying
|
|
413
|
+
// `{type:'tool_result', tool_use_id, is_error}`. Same shape read by
|
|
414
|
+
// `scanForOutboxCapture` (#3865).
|
|
415
|
+
const rc = obj?.message?.content
|
|
416
|
+
if (!Array.isArray(rc)) continue
|
|
417
|
+
for (const r of rc) {
|
|
418
|
+
if (r?.type !== 'tool_result') continue
|
|
419
|
+
if (r?.is_error !== true) continue
|
|
420
|
+
if (typeof r.tool_use_id !== 'string') continue
|
|
421
|
+
erroredToolUseIds.add(r.tool_use_id)
|
|
422
|
+
}
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
393
425
|
if (obj?.type !== 'assistant') continue
|
|
394
426
|
const content = obj?.message?.content
|
|
395
427
|
if (!Array.isArray(content)) continue
|
|
@@ -449,6 +481,9 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
449
481
|
}
|
|
450
482
|
continue
|
|
451
483
|
}
|
|
484
|
+
// #4141: EVERY reply-tool call's id, qualifying or not — an interim ack
|
|
485
|
+
// that threw is still a reply-tool throw.
|
|
486
|
+
if (typeof c.id === 'string') replyToolUseIds.add(c.id)
|
|
452
487
|
const input = c.input ?? {}
|
|
453
488
|
const text = String(input.text ?? '')
|
|
454
489
|
// Silent-marker carve-out: the operator explicitly signaled
|
|
@@ -555,6 +590,10 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
555
590
|
// delivers the joined prose instead of dropping. A pure narration run still
|
|
556
591
|
// yields `undefined` (the #3228 Finding 2 guard, now structural).
|
|
557
592
|
const zeroReplyPendingText = backstopText
|
|
593
|
+
// #4141 — one of this turn's reply-tool calls errored. Derived here, consumed
|
|
594
|
+
// only by `buildBlockResult` below (and thence the elected state file); it
|
|
595
|
+
// gates nothing in this scanner.
|
|
596
|
+
const replyToolThrewThisTurn = [...replyToolUseIds].some((id) => erroredToolUseIds.has(id))
|
|
558
597
|
|
|
559
598
|
if (lastAllowBlockIdx === -1) {
|
|
560
599
|
// No qualifying delivery/silence event anywhere in the turn.
|
|
@@ -567,7 +606,9 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
567
606
|
if (envelope.source === 'cron') {
|
|
568
607
|
return { decided: 'allow', reason: 'cron-source' }
|
|
569
608
|
}
|
|
570
|
-
return buildBlockResult(
|
|
609
|
+
return buildBlockResult(
|
|
610
|
+
envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse, replyToolThrewThisTurn,
|
|
611
|
+
)
|
|
571
612
|
}
|
|
572
613
|
|
|
573
614
|
if (sawUndeliveredTextAfterAllow) {
|
|
@@ -576,7 +617,9 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
576
617
|
// sent through a delivery tool. This is the "at least once" bug:
|
|
577
618
|
// an early ack (or any qualifying reply) must not amnesty
|
|
578
619
|
// everything written afterward.
|
|
579
|
-
return buildBlockResult(
|
|
620
|
+
return buildBlockResult(
|
|
621
|
+
envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse, replyToolThrewThisTurn,
|
|
622
|
+
)
|
|
580
623
|
}
|
|
581
624
|
|
|
582
625
|
return { decided: 'allow', reason: lastAllowReason }
|
|
@@ -917,7 +960,7 @@ function resolveSessionOriginChat(lines) {
|
|
|
917
960
|
*
|
|
918
961
|
* @param {string} jsonl
|
|
919
962
|
* @param {number} [now]
|
|
920
|
-
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
|
|
963
|
+
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null, replyToolThrewThisTurn: boolean }}
|
|
921
964
|
*/
|
|
922
965
|
export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
923
966
|
const lines = jsonl.split('\n')
|
|
@@ -925,12 +968,34 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
925
968
|
const { envelope } = anchor
|
|
926
969
|
|
|
927
970
|
const blocks = []
|
|
971
|
+
// W1-d (#3865): ids of reply-tool `tool_use` blocks seen this turn, and the
|
|
972
|
+
// ids whose `tool_result` came back `is_error: true`. Their intersection is
|
|
973
|
+
// the "did the reply tool throw this turn" signal.
|
|
974
|
+
const replyToolUseIds = new Set()
|
|
975
|
+
const erroredToolUseIds = new Set()
|
|
928
976
|
for (let i = anchor.startIdx + 1; i < lines.length; i++) {
|
|
929
977
|
const line = lines[i]
|
|
930
978
|
if (!line || line[0] !== '{') continue
|
|
931
979
|
let obj
|
|
932
980
|
try { obj = JSON.parse(line) } catch { continue }
|
|
933
981
|
if (obj?.isSidechain === true) continue
|
|
982
|
+
// W1-d (#3865): tool RESULT lines are how a reply-tool THROW becomes
|
|
983
|
+
// visible. Claude Code writes the result of a `tool_use` as a `user`-type
|
|
984
|
+
// line whose content array carries `{type:'tool_result', tool_use_id,
|
|
985
|
+
// is_error}`. Nothing in this scanner read those before, which is exactly
|
|
986
|
+
// why an errored reply was indistinguishable from a delivered one — the
|
|
987
|
+
// transcript shape is otherwise identical.
|
|
988
|
+
if (obj?.type === 'user') {
|
|
989
|
+
const rc = obj?.message?.content
|
|
990
|
+
if (!Array.isArray(rc)) continue
|
|
991
|
+
for (const r of rc) {
|
|
992
|
+
if (r?.type !== 'tool_result') continue
|
|
993
|
+
if (r?.is_error !== true) continue
|
|
994
|
+
if (typeof r.tool_use_id !== 'string') continue
|
|
995
|
+
erroredToolUseIds.add(r.tool_use_id)
|
|
996
|
+
}
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
934
999
|
if (obj?.type !== 'assistant') continue
|
|
935
1000
|
const content = obj?.message?.content
|
|
936
1001
|
if (!Array.isArray(content)) continue
|
|
@@ -971,6 +1036,12 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
971
1036
|
}
|
|
972
1037
|
continue
|
|
973
1038
|
}
|
|
1039
|
+
// W1-d (#3865): remember EVERY reply-tool call's id, not just the
|
|
1040
|
+
// qualifying-final-answer ones. An interim ack that threw is still a
|
|
1041
|
+
// reply-tool throw, and the #3861 shape (reply raises, agent then writes
|
|
1042
|
+
// trailing working notes) does not require the errored call to have been
|
|
1043
|
+
// a qualifying final answer.
|
|
1044
|
+
if (typeof c.id === 'string') replyToolUseIds.add(c.id)
|
|
974
1045
|
const input = c.input ?? {}
|
|
975
1046
|
const text = String(input.text ?? '')
|
|
976
1047
|
if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
|
|
@@ -1081,5 +1152,10 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
1081
1152
|
originThreadId: origin.threadId,
|
|
1082
1153
|
replyAlreadyDeliveredThisTurn,
|
|
1083
1154
|
deliveredReplySha256,
|
|
1155
|
+
// W1-d (#3865): one of this turn's reply-tool calls came back an error.
|
|
1156
|
+
// Reported as a raw SIGNAL, not a verdict — `decideCaptureAudience` in
|
|
1157
|
+
// `audience-classify.mjs` combines it with the obligation state (which
|
|
1158
|
+
// this pure transcript scanner cannot see) to decide the audience.
|
|
1159
|
+
replyToolThrewThisTurn: [...replyToolUseIds].some((id) => erroredToolUseIds.has(id)),
|
|
1084
1160
|
}
|
|
1085
1161
|
}
|