switchroom 0.19.42 → 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 +1 -1
- package/dist/auth-broker/index.js +32 -7
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +1187 -669
- package/dist/host-control/main.js +205 -28
- package/dist/vault/approvals/kernel-server.js +32 -7
- package/dist/vault/broker/server.js +32 -7
- 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 +1225 -614
- 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/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- 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/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 +5 -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/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- 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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4065 — the rollout narration card's EDIT relay, with a truthful reply.
|
|
3
|
+
*
|
|
4
|
+
* hostd owns the rollout card's lifecycle (post once, then edit in place
|
|
5
|
+
* through the phases). Before #4065 this handler was pure fire-and-forget:
|
|
6
|
+
* every edit failure was swallowed here and hostd never learned about it. That
|
|
7
|
+
* is invisible in the steady state (hostd posted the card seconds earlier, so
|
|
8
|
+
* it is virtually always editable) but NOT after a hostd self-bump: the
|
|
9
|
+
* resumed narrator is SEEDED with the carried `narration_message_id` and takes
|
|
10
|
+
* the edit branch forever. If that carried id is gone — operator deleted the
|
|
11
|
+
* card, or the message is no longer editable — the roll edits into the void
|
|
12
|
+
* and the operator is left staring at a stale card that never reaches a
|
|
13
|
+
* terminal ✅/❌.
|
|
14
|
+
*
|
|
15
|
+
* So the handler now REPLIES with a `rollout_status_edited` outcome and, when
|
|
16
|
+
* the failure means the target message no longer exists, says so (`gone`).
|
|
17
|
+
* hostd's narrator turns exactly one `gone` into one fresh post and escalates
|
|
18
|
+
* anything past that to telemetry — never a chat card asking the operator to
|
|
19
|
+
* do something (the PR #4104 rule).
|
|
20
|
+
*
|
|
21
|
+
* Still fire-and-forget toward the ROLL: the reply is advisory, hostd never
|
|
22
|
+
* blocks on it, and a gateway that predates this reply simply times out
|
|
23
|
+
* hostd-side and behaves exactly as before.
|
|
24
|
+
*
|
|
25
|
+
* Why this call opts out of TWO swallows
|
|
26
|
+
* --------------------------------------
|
|
27
|
+
* This handler infers edit success from the ABSENCE of a throw. That inference
|
|
28
|
+
* is only sound if every "the edit did not land" path actually throws — and
|
|
29
|
+
* the production stack has two layers that RESOLVE instead. Both are opted out
|
|
30
|
+
* of explicitly at the `robustApiCall` call site in gateway.ts, because either
|
|
31
|
+
* one alone is enough to make `gone` — the entire reason this module exists —
|
|
32
|
+
* unreachable in production.
|
|
33
|
+
*
|
|
34
|
+
* 1. `priorityClass: 'critical'` — never inherit the untagged default
|
|
35
|
+
* (`UNTAGGED_SEND_CLASS`, today `'critical'`). A SHED send does not throw:
|
|
36
|
+
* the send gate returns `SEND_GATE_SHED` without ever calling Telegram. If
|
|
37
|
+
* a future change to that default made this call sheddable, the handler
|
|
38
|
+
* would reply `ok:true` for an edit that never happened.
|
|
39
|
+
*
|
|
40
|
+
* 2. `rethrowBenign400: true` — the retry policy's benign-400 swallow
|
|
41
|
+
* (`classifyBenignTelegram400`, retry-api-call.ts) treats
|
|
42
|
+
* `400 "message to edit not found"` as a non-event and resolves
|
|
43
|
+
* `undefined`. That description is EXACTLY the deleted-card case, i.e. the
|
|
44
|
+
* primary trigger for `gone`. Without the opt-out the seeded-resume
|
|
45
|
+
* scenario this module was written for — carried `narration_message_id`,
|
|
46
|
+
* operator deleted the card — threw nothing at all, so the handler replied
|
|
47
|
+
* `ok:true` and hostd recorded a frozen card as live. That is WORSE than
|
|
48
|
+
* pre-fix: hostd positively believes the card is fine.
|
|
49
|
+
*
|
|
50
|
+
* Both failures are silent by construction — green tests over an inert
|
|
51
|
+
* feature — so neither is pinned by inspection alone.
|
|
52
|
+
* `tests/rollout-status-edit-retry-policy.test.ts` drives the REAL retry
|
|
53
|
+
* policy + send gate with a REAL `GrammyError` and asserts a deleted card
|
|
54
|
+
* produces a re-post; `tests/rollout-narration-edit-socket.test.ts` pins the
|
|
55
|
+
* transport that carries the reply back to hostd.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import type {
|
|
59
|
+
RolloutStatusEditMessage,
|
|
60
|
+
RolloutStatusEditedEvent,
|
|
61
|
+
} from './ipc-protocol.js'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The reply channel — structurally satisfied by `IpcClient` (whose `send`
|
|
65
|
+
* accepts the wider `GatewayToClient` union). Kept narrow here so this module
|
|
66
|
+
* doesn't depend on the IPC server.
|
|
67
|
+
*/
|
|
68
|
+
export interface RolloutStatusEditReplySink {
|
|
69
|
+
send(msg: RolloutStatusEditedEvent): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classification of an edit failure, from the perspective of "is the card the
|
|
74
|
+
* operator can see still there?".
|
|
75
|
+
*
|
|
76
|
+
* - `not-modified` — Telegram rejected the edit because the body is already
|
|
77
|
+
* byte-identical. The card IS live and correct; this is a success.
|
|
78
|
+
* - `gone` — the target message no longer exists / can no longer be
|
|
79
|
+
* edited. Only this class may trigger a re-post.
|
|
80
|
+
* - `transient` — anything else (429 past retries, network, chat errors).
|
|
81
|
+
* Re-posting would duplicate a card that is probably still fine.
|
|
82
|
+
*/
|
|
83
|
+
export type RolloutEditFailureClass = 'not-modified' | 'gone' | 'transient'
|
|
84
|
+
|
|
85
|
+
/** Telegram descriptions that mean "the message you asked me to edit is gone". */
|
|
86
|
+
const GONE_PATTERNS = [
|
|
87
|
+
/message to edit not found/i,
|
|
88
|
+
/message can't be edited/i,
|
|
89
|
+
/message_id_invalid/i,
|
|
90
|
+
/message to be edited not found/i,
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
/** True IFF the error means the edit body already equals the live message. */
|
|
94
|
+
const NOT_MODIFIED = /message is not modified/i
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Classify a thrown Telegram edit error. Reads `description` (grammy's
|
|
98
|
+
* `GrammyError`) and falls back to the message string, so a plain `Error`
|
|
99
|
+
* carrying the same text classifies identically.
|
|
100
|
+
*/
|
|
101
|
+
export function classifyRolloutEditError(err: unknown): RolloutEditFailureClass {
|
|
102
|
+
const e = err as { description?: unknown; message?: unknown } | null
|
|
103
|
+
const text = [
|
|
104
|
+
typeof e?.description === 'string' ? e.description : '',
|
|
105
|
+
typeof e?.message === 'string' ? e.message : '',
|
|
106
|
+
].join(' ')
|
|
107
|
+
if (NOT_MODIFIED.test(text)) return 'not-modified'
|
|
108
|
+
if (GONE_PATTERNS.some((re) => re.test(text))) return 'gone'
|
|
109
|
+
return 'transient'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RolloutStatusEditDeps {
|
|
113
|
+
/** The gateway's own agent name (`SWITCHROOM_AGENT_NAME`), or undefined. */
|
|
114
|
+
selfAgentName: string | undefined
|
|
115
|
+
/** Operator chat (`allowFrom[0]`), or undefined when access is unconfigured. */
|
|
116
|
+
operatorChatId: () => string | number | undefined
|
|
117
|
+
/**
|
|
118
|
+
* Perform the edit.
|
|
119
|
+
*
|
|
120
|
+
* CONTRACT — resolve IFF the edit landed, reject otherwise. This handler has
|
|
121
|
+
* no other channel: it reads success from the absence of a throw. So the
|
|
122
|
+
* implementation must be wrapped in the retry policy AND must opt out of
|
|
123
|
+
* every layer that turns a failed edit into a resolved promise —
|
|
124
|
+
* `priorityClass: 'critical'` (no shed) and `rethrowBenign400: true` (no
|
|
125
|
+
* benign-400 swallow). See the "TWO swallows" note at the top of this file.
|
|
126
|
+
*/
|
|
127
|
+
editMessage: (chatId: string | number, messageId: number, text: string) => Promise<unknown>
|
|
128
|
+
log: (line: string) => void
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Handle one `rollout_status_edit` and reply with its outcome. Never throws:
|
|
133
|
+
* every failure path resolves after sending (best-effort) a
|
|
134
|
+
* `rollout_status_edited` event.
|
|
135
|
+
*/
|
|
136
|
+
export async function handleRolloutStatusEdit(
|
|
137
|
+
deps: RolloutStatusEditDeps,
|
|
138
|
+
client: RolloutStatusEditReplySink,
|
|
139
|
+
msg: RolloutStatusEditMessage,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const reply = (ok: boolean, extra: { gone?: boolean; reason?: string } = {}): void => {
|
|
142
|
+
try {
|
|
143
|
+
client.send({ type: 'rollout_status_edited', requestId: msg.requestId, ok, ...extra })
|
|
144
|
+
} catch {
|
|
145
|
+
/* best effort — hostd falls back to its bounded reply timeout */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const self = deps.selfAgentName
|
|
149
|
+
if (self && msg.agentName !== self) {
|
|
150
|
+
deps.log(`rollout_status_edit rejected — agent mismatch (${msg.agentName} != ${self})`)
|
|
151
|
+
reply(false, { gone: false, reason: 'agent mismatch' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const operator = deps.operatorChatId()
|
|
155
|
+
if (operator === undefined) {
|
|
156
|
+
deps.log('rollout_status_edit — no operator chat (allowFrom empty)')
|
|
157
|
+
reply(false, { gone: false, reason: 'no operator chat' })
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await deps.editMessage(operator, msg.messageId, msg.text)
|
|
162
|
+
reply(true)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const cls = classifyRolloutEditError(err)
|
|
165
|
+
if (cls === 'not-modified') {
|
|
166
|
+
// The live card already carries this exact body — a success, not a loss.
|
|
167
|
+
reply(true)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
deps.log(
|
|
171
|
+
`rollout_status_edit failed (${cls}) request=${msg.requestId} message_id=${msg.messageId}: ${(err as Error).message}`,
|
|
172
|
+
)
|
|
173
|
+
reply(false, { gone: cls === 'gone', reason: (err as Error).message })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -2743,6 +2743,11 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2743
2743
|
registryKey: turn.registryKey ?? null,
|
|
2744
2744
|
originTurnId: turn.turnId,
|
|
2745
2745
|
text: proseDecision.text,
|
|
2746
|
+
// #4141 — the Stop hook saw this turn's reply tool throw, so the
|
|
2747
|
+
// prose is what the model wrote AFTER its delivery attempt failed,
|
|
2748
|
+
// not what it chose to send. Pass the raw signal through so the
|
|
2749
|
+
// send states that provenance. It never gates delivery.
|
|
2750
|
+
replyToolThrewThisTurn: proseDecision.replyToolThrewThisTurn === true,
|
|
2746
2751
|
// For the honest "(waited Ns)" clause if the exhaustion-boundary
|
|
2747
2752
|
// apology fallback fires (#3228).
|
|
2748
2753
|
turnDurationMs,
|
|
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from 'node:f
|
|
|
23
23
|
import { join } from 'node:path'
|
|
24
24
|
import { homedir } from 'node:os'
|
|
25
25
|
import { readAndFilter, defaultAuditLogPath, type AuditEntry } from '../../src/host-control/audit-reader.js'
|
|
26
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
26
27
|
|
|
27
28
|
/** Default lookback window: 10 minutes is enough to catch the boot that
|
|
28
29
|
* follows a normal update_apply but small enough that an audit row from
|
|
@@ -98,9 +99,9 @@ function shortSha(s: string): string {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
|
-
* Pure renderer. Returns the
|
|
102
|
-
*
|
|
103
|
-
* (entry too stale, schema invalid, etc.).
|
|
102
|
+
* Pure renderer. Returns the line(s) to append to the boot card body —
|
|
103
|
+
* markdown-safe (no unescaped entity markers of its own). `null` means
|
|
104
|
+
* nothing to surface (entry too stale, schema invalid, etc.).
|
|
104
105
|
*/
|
|
105
106
|
export function renderUpdateOutcomeLine(entry: AuditEntry): string {
|
|
106
107
|
const success = entry.exit_code === 0 && entry.result !== 'error' && entry.result !== 'denied'
|
|
@@ -117,7 +118,13 @@ export function renderUpdateOutcomeLine(entry: AuditEntry): string {
|
|
|
117
118
|
const opStep = entry.op
|
|
118
119
|
const hint = recoveryHint(entry.install_context?.install_type)
|
|
119
120
|
// Single line is reader-friendly when short; multi-line when stderr is present.
|
|
120
|
-
|
|
121
|
+
// The recovery line is NESTED under the failure line with the repo's one
|
|
122
|
+
// indent idiom (#4115): a literal ASCII-space indent is left-trimmed off a
|
|
123
|
+
// content line by Telegram's parser and renders FLAT on a phone (#3668).
|
|
124
|
+
const lines = [
|
|
125
|
+
`❌ update failed at ${opStep}: ${stderrTail || '(no stderr captured)'}`,
|
|
126
|
+
`${NESTED_PREFIX}Recovery: ${hint}`,
|
|
127
|
+
]
|
|
121
128
|
return lines.join('\n')
|
|
122
129
|
}
|
|
123
130
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type Audience = 'user' | 'internal'
|
|
2
|
+
|
|
3
|
+
export const AUDIENCE_USER: 'user'
|
|
4
|
+
export const AUDIENCE_INTERNAL: 'internal'
|
|
5
|
+
|
|
6
|
+
export function decideCaptureAudience(signals: {
|
|
7
|
+
replyToolThrewThisTurn?: boolean
|
|
8
|
+
openInboundObligation?: true | false | 'unknown'
|
|
9
|
+
}): Audience
|
|
10
|
+
|
|
11
|
+
export function resolveOpenObligation(args: {
|
|
12
|
+
snapshotRaw?: string | null
|
|
13
|
+
chatId?: string | null
|
|
14
|
+
/** #4146: `false` ⇒ persistence is off, so the file proves nothing. */
|
|
15
|
+
snapshotTrusted?: boolean
|
|
16
|
+
}): true | false | 'unknown'
|
|
17
|
+
|
|
18
|
+
export function resolveRecordAudience(value: unknown): Audience
|
|
19
|
+
|
|
20
|
+
export function shouldSuppressForAudience(
|
|
21
|
+
record: { audience?: unknown },
|
|
22
|
+
opts?: { gateEnabled?: boolean },
|
|
23
|
+
): boolean
|
|
24
|
+
|
|
25
|
+
export const REPLY_THROW_PROVENANCE_NOTICE: string
|
|
26
|
+
|
|
27
|
+
export function shouldFrameReplyThrow(
|
|
28
|
+
record: { replyToolThrewThisTurn?: unknown },
|
|
29
|
+
opts?: { frameEnabled?: boolean },
|
|
30
|
+
): boolean
|
|
31
|
+
|
|
32
|
+
export function applyReplyThrowFraming(text: string): string
|
|
33
|
+
|
|
34
|
+
export function formatReplyThrowFraming(s: {
|
|
35
|
+
turnNonce: string
|
|
36
|
+
turnId?: string | null
|
|
37
|
+
chatId?: string | null
|
|
38
|
+
textSha256?: string
|
|
39
|
+
source?: string
|
|
40
|
+
}): string
|
|
41
|
+
|
|
42
|
+
export function formatInternalSuppression(s: {
|
|
43
|
+
turnNonce: string
|
|
44
|
+
turnId?: string | null
|
|
45
|
+
chatId?: string | null
|
|
46
|
+
textSha256?: string
|
|
47
|
+
ageMs?: number
|
|
48
|
+
source?: string
|
|
49
|
+
pastWindow?: boolean
|
|
50
|
+
}): string
|
|
@@ -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
|
+
}
|