switchroom 0.21.7 → 0.21.9
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/bin/tmp-reaper.sh +234 -0
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +2 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +3520 -2782
- package/dist/host-control/main.js +177 -13
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +6 -5
- package/profiles/_base/start.sh.hbs +115 -0
- package/profiles/_shared/local-time.md.hbs +6 -0
- package/profiles/default/CLAUDE.md.hbs +0 -12
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +1164 -547
- package/telegram-plugin/format.ts +12 -4
- package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
- package/telegram-plugin/gateway/approval-hold.ts +32 -1
- package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
- package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
- package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
- package/telegram-plugin/gateway/gateway.ts +12 -10
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
- package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
- package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +73 -14
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
- package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
- package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
- package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
- package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/parse.test.ts +30 -5
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single registry of "this inbound carries an approval OUTCOME".
|
|
3
|
+
*
|
|
4
|
+
* An approval outcome is a synthetic inbound that reports the RESULT of an
|
|
5
|
+
* operator decision the agent is blocked on: a vault grant approved/denied,
|
|
6
|
+
* a secret provided/declined, a mental-model proposal applied, a button tap.
|
|
7
|
+
* It is structurally different from an ordinary chat message in one way that
|
|
8
|
+
* matters to the inbound buffer: **the operator cannot resend it.** If a
|
|
9
|
+
* `vault_grant_approved` is dropped, telling the user "please resend that
|
|
10
|
+
* message" is meaningless — the tap already happened, the grant already
|
|
11
|
+
* exists, and the agent waits forever for a wake-up that will never come.
|
|
12
|
+
*
|
|
13
|
+
* That asymmetry is why cap eviction in `pending-inbound-buffer.ts` may not
|
|
14
|
+
* pick its victim blindly. An ordinary user message is resendable; an
|
|
15
|
+
* approval outcome is not. So the buffer evicts the oldest NON-outcome first
|
|
16
|
+
* and only touches an outcome when there is genuinely nothing else to drop.
|
|
17
|
+
*
|
|
18
|
+
* Membership is deliberately a closed literal set rather than a prefix/regex
|
|
19
|
+
* match on `meta.source`: a new source string must be classified by a human
|
|
20
|
+
* (or an agent reading this comment), not silently inherit protection from a
|
|
21
|
+
* naming coincidence. The set is exhaustive against `telegram-plugin/gateway/`
|
|
22
|
+
* as of this commit.
|
|
23
|
+
*
|
|
24
|
+
* No imports beyond the inbound type. The set, the predicate and the notice
|
|
25
|
+
* builder are pure, so the buffer hot path can import them freely;
|
|
26
|
+
* `createApprovalOutcomeDropNotifier` is the one stateful export (the gateway's
|
|
27
|
+
* re-entrancy latch) and lives here rather than inline in `gateway.ts` so the
|
|
28
|
+
* production wiring is the thing tests drive, not a copy of it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `meta.source` values that identify an inbound as an approval outcome.
|
|
35
|
+
*
|
|
36
|
+
* Grepped exhaustively from `telegram-plugin/gateway/` (both quote styles —
|
|
37
|
+
* the `mental_model_proposal_*` builders use double quotes, which is how an
|
|
38
|
+
* earlier single-quote-only survey missed them).
|
|
39
|
+
*
|
|
40
|
+
* Deliberately EXCLUDED, with reasons:
|
|
41
|
+
* - `missed_approval_retry` — carries no verdict. It ASKS the agent to
|
|
42
|
+
* re-attempt actions that timed out (`missed-approvals-card.ts:117-161`),
|
|
43
|
+
* and the operator can tap "retry" again. Resendable ⇒ ordinary.
|
|
44
|
+
* - `obligation_represent` / `subagent_handback` / `subagent_progress` /
|
|
45
|
+
* `resume_*` / `warmup` / `reaction` / `bridge_dead_restart` — internal
|
|
46
|
+
* nudges and lifecycle wake-ups, all regenerated by their own sweeps.
|
|
47
|
+
*
|
|
48
|
+
* `eval_case_applied` / `eval_case_rejected` / `eval_case_apply_failed` are
|
|
49
|
+
* listed AHEAD of the branch that introduces them. Listing an unused source
|
|
50
|
+
* is inert (nothing ever carries it), and it removes any ordering constraint
|
|
51
|
+
* between the two changes — the alternative is a window where eval-case
|
|
52
|
+
* verdicts are evictable.
|
|
53
|
+
*
|
|
54
|
+
* `eval_case_suppressed` is protected for the SAME reason, and it is the one
|
|
55
|
+
* member that is not a verdict card at all. It is the gateway's own notice
|
|
56
|
+
* that a re-proposed eval case was dropped against a live dismissal — the
|
|
57
|
+
* agent's turn is blocked on it and NO sweep regenerates it (it fires exactly
|
|
58
|
+
* once, at the moment of suppression, and the suppression path posts no card).
|
|
59
|
+
* Unprotected it would be tier-1's preferred victim, so a cap overflow would
|
|
60
|
+
* restore the silent block the notice exists to close.
|
|
61
|
+
*/
|
|
62
|
+
export const APPROVAL_OUTCOME_SOURCES: ReadonlySet<string> = Object.freeze(
|
|
63
|
+
new Set([
|
|
64
|
+
// vault grant decisions (#1150 — the original dropped-wake-up class)
|
|
65
|
+
'vault_grant_approved',
|
|
66
|
+
'vault_grant_denied',
|
|
67
|
+
'vault_grant_timeout',
|
|
68
|
+
// vault save decisions
|
|
69
|
+
'vault_save_completed',
|
|
70
|
+
'vault_save_discarded',
|
|
71
|
+
'vault_save_failed',
|
|
72
|
+
'vault_save_timeout',
|
|
73
|
+
// secret-request decisions
|
|
74
|
+
'secret_provided',
|
|
75
|
+
'secret_declined',
|
|
76
|
+
'secret_provide_failed',
|
|
77
|
+
'secret_request_timeout',
|
|
78
|
+
// mental-model proposal decisions
|
|
79
|
+
'mental_model_proposal_applied',
|
|
80
|
+
'mental_model_proposal_denied',
|
|
81
|
+
'mental_model_proposal_failed',
|
|
82
|
+
'mental_model_propose_timeout',
|
|
83
|
+
// skill proposal decision
|
|
84
|
+
'skill_proposal_apply',
|
|
85
|
+
// eval-case proposal decisions (forward-listed; see doc comment)
|
|
86
|
+
'eval_case_applied',
|
|
87
|
+
'eval_case_rejected',
|
|
88
|
+
'eval_case_apply_failed',
|
|
89
|
+
// #4664's suppressed-proposal notice. Not itself a tap, but it reports the
|
|
90
|
+
// standing effect of one the operator ALREADY made (the Dismiss that
|
|
91
|
+
// recorded the rejection), and it is the only thing that ends the agent's
|
|
92
|
+
// wait — see the doc comment above.
|
|
93
|
+
'eval_case_suppressed',
|
|
94
|
+
]),
|
|
95
|
+
) as ReadonlySet<string>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `meta.source` stamped on the synthetic notice the gateway pushes when an
|
|
99
|
+
* approval outcome had to be dropped anyway (every buffered entry was an
|
|
100
|
+
* outcome). Deliberately NOT a member of `APPROVAL_OUTCOME_SOURCES`: the
|
|
101
|
+
* notice must be the PREFERRED victim of the next overflow, which is what
|
|
102
|
+
* structurally bounds the notice→evict→notice recursion.
|
|
103
|
+
*/
|
|
104
|
+
export const APPROVAL_OUTCOME_DROPPED_SOURCE = 'approval_outcome_dropped'
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when `msg` reports the result of an operator decision.
|
|
108
|
+
*
|
|
109
|
+
* TWO conditions, both required — a button tap has NO `meta.source` at all.
|
|
110
|
+
* `agent-button-callback-handler.ts:123-133` stamps only
|
|
111
|
+
* `meta.button_callback = 'true'` (plus `button_callback_data` / `button_text`),
|
|
112
|
+
* so a source-only check silently leaves every agent-card tap evictable.
|
|
113
|
+
*/
|
|
114
|
+
export function isApprovalOutcome(msg: InboundMessage): boolean {
|
|
115
|
+
const meta = msg.meta
|
|
116
|
+
if (meta == null) return false
|
|
117
|
+
if (meta.source != null && APPROVAL_OUTCOME_SOURCES.has(meta.source)) return true
|
|
118
|
+
return meta.button_callback === 'true'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Human label for an outcome in logs / notices. Taps carry no source. */
|
|
122
|
+
export function approvalOutcomeLabel(msg: InboundMessage): string {
|
|
123
|
+
const src = msg.meta?.source
|
|
124
|
+
if (src != null && src !== '') return src
|
|
125
|
+
if (msg.meta?.button_callback === 'true') return 'button_callback'
|
|
126
|
+
return '-'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the synthetic inbound that tells the agent an approval outcome was
|
|
131
|
+
* dropped, so it stops waiting on a wake-up that is never arriving. Pure.
|
|
132
|
+
*
|
|
133
|
+
* It names the dropped source(s) rather than paraphrasing, because the agent's
|
|
134
|
+
* next move depends on which one it was (a dropped `vault_grant_approved`
|
|
135
|
+
* means "retry the vault read and see if the grant exists"; a dropped
|
|
136
|
+
* `secret_declined` means "the operator said no").
|
|
137
|
+
*/
|
|
138
|
+
export function buildApprovalOutcomeDroppedInbound(opts: {
|
|
139
|
+
agent: string
|
|
140
|
+
chatId: string
|
|
141
|
+
threadId?: number
|
|
142
|
+
sources: string[]
|
|
143
|
+
nowMs?: number
|
|
144
|
+
}): InboundMessage {
|
|
145
|
+
const ts = opts.nowMs ?? Date.now()
|
|
146
|
+
const list = opts.sources.length > 0 ? opts.sources.join(', ') : 'unknown'
|
|
147
|
+
const plural = opts.sources.length === 1 ? 'an approval outcome' : 'approval outcomes'
|
|
148
|
+
const msg: InboundMessage = {
|
|
149
|
+
type: 'inbound',
|
|
150
|
+
chatId: opts.chatId,
|
|
151
|
+
...(opts.threadId != null ? { threadId: opts.threadId } : {}),
|
|
152
|
+
messageId: ts,
|
|
153
|
+
user: 'gateway',
|
|
154
|
+
userId: 0,
|
|
155
|
+
ts,
|
|
156
|
+
text:
|
|
157
|
+
`⚠️ Your inbound buffer overflowed while every queued message was ${plural}, ` +
|
|
158
|
+
`so the oldest had to be dropped: ${list}. Do NOT keep waiting on it — ` +
|
|
159
|
+
`re-check the underlying state directly (e.g. retry the vault read to see ` +
|
|
160
|
+
`whether the grant exists) and tell the operator plainly what was lost.`,
|
|
161
|
+
meta: {
|
|
162
|
+
source: APPROVAL_OUTCOME_DROPPED_SOURCE,
|
|
163
|
+
agent: opts.agent,
|
|
164
|
+
...(opts.threadId != null ? { message_thread_id: String(opts.threadId) } : {}),
|
|
165
|
+
dropped_sources: list,
|
|
166
|
+
dropped_count: String(opts.sources.length),
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
return msg
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Max follow-up notice hops per burst. See `createApprovalOutcomeDropNotifier`. */
|
|
173
|
+
export const APPROVAL_OUTCOME_NOTICE_HOPS = 2
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build the `onEvictCritical` handler the gateway wires into the pending
|
|
177
|
+
* inbound buffer: log at a distinct greppable tag, then enqueue ONE synthetic
|
|
178
|
+
* notice naming every outcome dropped since the last notice.
|
|
179
|
+
*
|
|
180
|
+
* Two properties this exists to guarantee, both of which a hand-inlined
|
|
181
|
+
* version in `gateway.ts` would get wrong:
|
|
182
|
+
*
|
|
183
|
+
* - **Never pushes inside the `push` frame.** `onEvictCritical` is invoked
|
|
184
|
+
* from the middle of `push`, on a queue sitting at cap. Pushing there
|
|
185
|
+
* recurses. Every enqueue goes through `queueMicrotask`.
|
|
186
|
+
* - **Terminates.** Deferring alone does not stop recursion, it only turns a
|
|
187
|
+
* stack overflow into an unbounded microtask loop. Termination is
|
|
188
|
+
* structural: the notice carries `approval_outcome_dropped`, which is NOT a
|
|
189
|
+
* member of `APPROVAL_OUTCOME_SOURCES`, so the moment one is resident it
|
|
190
|
+
* becomes the PREFERRED eviction victim and no further critical eviction can
|
|
191
|
+
* fire until it drains. The `scheduled` latch collapses a burst into one
|
|
192
|
+
* notice, and `hops` is a hard budget on top of the structural bound so a
|
|
193
|
+
* future change to victim selection degrades into a truncated chain rather
|
|
194
|
+
* than a spin.
|
|
195
|
+
*
|
|
196
|
+
* KNOWN COST, accepted deliberately: in the pathological state (every one of
|
|
197
|
+
* the 32 slots is a fresh approval outcome) the notice's own push evicts one
|
|
198
|
+
* MORE outcome to make room for itself. So that state costs two dropped
|
|
199
|
+
* outcomes instead of one. The exchange is that the agent is TOLD — the
|
|
200
|
+
* alternative is 32 resident outcomes and an agent blocked forever on a
|
|
201
|
+
* wake-up that was silently discarded. Reaching this state at all requires 32
|
|
202
|
+
* simultaneously-undelivered operator decisions for one agent.
|
|
203
|
+
*/
|
|
204
|
+
export function createApprovalOutcomeDropNotifier(opts: {
|
|
205
|
+
/** Enqueue the notice. Wired to the buffer's own `push`. */
|
|
206
|
+
push: (agent: string, msg: InboundMessage) => void
|
|
207
|
+
log?: (line: string) => void
|
|
208
|
+
/** Defer seam. Defaults to `queueMicrotask`. */
|
|
209
|
+
defer?: (fn: () => void) => void
|
|
210
|
+
nowMs?: () => number
|
|
211
|
+
hops?: number
|
|
212
|
+
}): (agent: string, evicted: InboundMessage) => void {
|
|
213
|
+
const log = opts.log ?? ((line: string) => process.stderr.write(line))
|
|
214
|
+
const defer = opts.defer ?? ((fn: () => void) => queueMicrotask(fn))
|
|
215
|
+
const hops = opts.hops ?? APPROVAL_OUTCOME_NOTICE_HOPS
|
|
216
|
+
/** agent → labels of outcomes dropped since the last notice was built. */
|
|
217
|
+
const pending = new Map<string, string[]>()
|
|
218
|
+
/** agent → a notice enqueue is already scheduled (the re-entrancy latch). */
|
|
219
|
+
const scheduled = new Set<string>()
|
|
220
|
+
|
|
221
|
+
return (agent, evicted) => {
|
|
222
|
+
const label = approvalOutcomeLabel(evicted)
|
|
223
|
+
log(
|
|
224
|
+
`telegram gateway: APPROVAL-OUTCOME-DROPPED agent=${agent} source=${label} ` +
|
|
225
|
+
`chat=${evicted.chatId} ts=${evicted.ts}\n`,
|
|
226
|
+
)
|
|
227
|
+
const list = pending.get(agent) ?? []
|
|
228
|
+
list.push(label)
|
|
229
|
+
pending.set(agent, list)
|
|
230
|
+
if (scheduled.has(agent)) return
|
|
231
|
+
scheduled.add(agent)
|
|
232
|
+
const chatId = evicted.chatId
|
|
233
|
+
const threadId = evicted.threadId
|
|
234
|
+
|
|
235
|
+
const flush = (budget: number): void => {
|
|
236
|
+
// CUMULATIVE, and deliberately not cleared before the push: the notice we
|
|
237
|
+
// are about to enqueue can itself be the entry a later overflow evicts
|
|
238
|
+
// (it is the preferred victim, by design). If each hop named only the
|
|
239
|
+
// drops since the last one, the surviving notice would omit the earliest
|
|
240
|
+
// dropped source — the one the agent is most likely to be blocked on. So
|
|
241
|
+
// every hop restates the whole burst and the list is cleared only when
|
|
242
|
+
// the chain ends.
|
|
243
|
+
const sources = [...(pending.get(agent) ?? [])]
|
|
244
|
+
const before = sources.length
|
|
245
|
+
try {
|
|
246
|
+
opts.push(
|
|
247
|
+
agent,
|
|
248
|
+
buildApprovalOutcomeDroppedInbound({
|
|
249
|
+
agent,
|
|
250
|
+
chatId,
|
|
251
|
+
...(threadId != null ? { threadId } : {}),
|
|
252
|
+
sources,
|
|
253
|
+
...(opts.nowMs != null ? { nowMs: opts.nowMs() } : {}),
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
} catch (e) {
|
|
257
|
+
log(`telegram gateway: APPROVAL-OUTCOME-DROPPED notice push failed: ${String(e)}\n`)
|
|
258
|
+
} finally {
|
|
259
|
+
scheduled.delete(agent)
|
|
260
|
+
}
|
|
261
|
+
// The push above ran against a queue at cap, so it may itself have
|
|
262
|
+
// evicted one more outcome (re-entering this handler with the latch still
|
|
263
|
+
// set, which recorded the label but scheduled nothing). A GROWN list is
|
|
264
|
+
// the signal that happened. Re-state, once more, then stop.
|
|
265
|
+
if (budget > 0 && (pending.get(agent)?.length ?? 0) > before) {
|
|
266
|
+
scheduled.add(agent)
|
|
267
|
+
defer(() => flush(budget - 1))
|
|
268
|
+
} else {
|
|
269
|
+
pending.delete(agent)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
defer(() => flush(hops))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -228,16 +228,28 @@ export function writeBridgeDeadEscalationMarker(
|
|
|
228
228
|
/**
|
|
229
229
|
* Read + consume the escalation marker at boot. Returns the marker only
|
|
230
230
|
* when it is fresh (< maxAgeMs); a stale or malformed marker is cleared
|
|
231
|
-
* and ignored.
|
|
232
|
-
* on exactly the boot that follows the escalation, never
|
|
231
|
+
* and ignored. Whenever this function runs the file IS removed — the cause
|
|
232
|
+
* note must surface on exactly the boot that follows the escalation, never
|
|
233
|
+
* a later one.
|
|
233
234
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
235
|
+
* Since #4641 the caller does not always run: the gateway's boot-resume
|
|
236
|
+
* block `break`s before reaching this call when the per-container-boot
|
|
237
|
+
* generation token says only the GATEWAY respawned. That narrows — and for
|
|
238
|
+
* the common shape closes — the race documented below, so it is an
|
|
239
|
+
* improvement rather than a hole; the marker is left on disk for the boot
|
|
240
|
+
* that genuinely follows the container restart. See "What `break
|
|
241
|
+
* bootResumeInit` skips" in agent-process-liveness.ts.
|
|
242
|
+
*
|
|
243
|
+
* Known race window (accepted, documented per review; largely closed by the
|
|
244
|
+
* #4641 guard above): the marker is written by gateway boot N and consumed
|
|
245
|
+
* by whichever gateway boots NEXT and reaches this call. Normally that is
|
|
246
|
+
* the post-container-restart gateway (the SIGTERM to PID 1 fires ~1.5s
|
|
247
|
+
* after the write and takes the whole container down). But if the
|
|
248
|
+
* escalating gateway PROCESS dies and its supervisor relaunches a new
|
|
249
|
+
* gateway inside the same container before the SIGTERM lands, and that
|
|
250
|
+
* interim gateway's boot block is NOT suppressed by the generation token
|
|
251
|
+
* (i.e. no gateway in this generation had completed its boot resume yet),
|
|
252
|
+
* the interim gateway consumes the marker instead — the cause note is then
|
|
241
253
|
* surfaced (or dropped with the interim process) one boot early, and the
|
|
242
254
|
* post-restart boot sees no marker. Consequences are bounded and safe:
|
|
243
255
|
* the honesty note may be lost for one incident (the loud supervisor-log
|
|
@@ -75,6 +75,11 @@ import {
|
|
|
75
75
|
getEvalCaseProposal,
|
|
76
76
|
setEvalCaseProposalStatus,
|
|
77
77
|
} from '../../src/self-improve/eval-case-proposals.js'
|
|
78
|
+
import {
|
|
79
|
+
buildEvalCaseAppliedInbound,
|
|
80
|
+
buildEvalCaseRejectedInbound,
|
|
81
|
+
buildEvalCaseApplyFailedInbound,
|
|
82
|
+
} from './eval-case-proposal-inbound-builders.js'
|
|
78
83
|
import { maskToken } from '../secret-detect/mask.js'
|
|
79
84
|
import {
|
|
80
85
|
defaultVaultWrite,
|
|
@@ -464,6 +469,19 @@ export interface CallbackQueryHandlersDeps {
|
|
|
464
469
|
brokerList?: typeof realListViaBroker
|
|
465
470
|
brokerListGrants?: typeof realListGrantsViaBroker
|
|
466
471
|
brokerVaultTokenFilePath?: typeof realVaultTokenFilePath
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Run the DETERMINISTIC eval-case applier for an approved proposal. Returns
|
|
475
|
+
* `ok` plus the applier's combined output tail (both go on the card footer
|
|
476
|
+
* and into the outcome inbound the agent is woken with).
|
|
477
|
+
*
|
|
478
|
+
* Injectable for the same reason as the broker seams above: production leaves
|
|
479
|
+
* it unset and gets the real `switchroom self-improve apply-eval-case`
|
|
480
|
+
* `execFileSync`, but a unit test cannot shell out to a real CLI, so the
|
|
481
|
+
* approve path's two branches (applied / apply-failed) were untestable while
|
|
482
|
+
* this was a bare static import.
|
|
483
|
+
*/
|
|
484
|
+
runEvalCaseApply?: (id: string) => { ok: boolean; out: string }
|
|
467
485
|
}
|
|
468
486
|
|
|
469
487
|
// Freshness throttle for the /auth dashboard ↻ refresh button — one live
|
|
@@ -625,6 +643,27 @@ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
|
|
|
625
643
|
const listViaBroker = deps.brokerList ?? realListViaBroker
|
|
626
644
|
const listGrantsViaBroker = deps.brokerListGrants ?? realListGrantsViaBroker
|
|
627
645
|
const vaultTokenFilePath = deps.brokerVaultTokenFilePath ?? realVaultTokenFilePath
|
|
646
|
+
// Eval-case applier seam. The default body is verbatim the execFileSync that
|
|
647
|
+
// used to sit inline in handleEvalCaseProposalCallback.
|
|
648
|
+
const runEvalCaseApply =
|
|
649
|
+
deps.runEvalCaseApply ??
|
|
650
|
+
((id: string): { ok: boolean; out: string } => {
|
|
651
|
+
const cli = process.env.SWITCHROOM_CLI_PATH ?? 'switchroom'
|
|
652
|
+
try {
|
|
653
|
+
const out = execFileSync(
|
|
654
|
+
cli,
|
|
655
|
+
['self-improve', 'apply-eval-case', '--id', id],
|
|
656
|
+
{ encoding: 'utf8', timeout: 15000, env: process.env },
|
|
657
|
+
).trim()
|
|
658
|
+
return { ok: true, out }
|
|
659
|
+
} catch (err) {
|
|
660
|
+
const e = err as { stdout?: string; stderr?: string; message?: string }
|
|
661
|
+
return {
|
|
662
|
+
ok: false,
|
|
663
|
+
out: [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim(),
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
})
|
|
628
667
|
|
|
629
668
|
/**
|
|
630
669
|
* Handle a callback_query from an auth dashboard button. Parses the
|
|
@@ -1377,8 +1416,31 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
|
|
|
1377
1416
|
return
|
|
1378
1417
|
}
|
|
1379
1418
|
|
|
1419
|
+
// Same idiom as the skill handler above: the card's own chat/topic is where
|
|
1420
|
+
// the proposing agent was working, so the outcome inbound resumes it there.
|
|
1421
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
1422
|
+
const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
|
|
1423
|
+
const inboundCtx = {
|
|
1424
|
+
agent,
|
|
1425
|
+
chat_id: cbChatId,
|
|
1426
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1380
1429
|
if (parsed.action === 'deny') {
|
|
1381
1430
|
setEvalCaseProposalStatus(stateDir, parsed.id, 'rejected')
|
|
1431
|
+
// The agent is TOLD it was dismissed. The skill handler stays silent here;
|
|
1432
|
+
// silence is the defect — a dismissed proposal left the agent waiting on a
|
|
1433
|
+
// wake-up that never came.
|
|
1434
|
+
const denied = deliverResumeSyntheticOrBuffer(
|
|
1435
|
+
agent,
|
|
1436
|
+
buildEvalCaseRejectedInbound({
|
|
1437
|
+
ctx: inboundCtx,
|
|
1438
|
+
proposalId: proposal.id,
|
|
1439
|
+
skillSlug: proposal.skill_slug,
|
|
1440
|
+
heldOut: proposal.held_out,
|
|
1441
|
+
operatorId: senderId,
|
|
1442
|
+
}),
|
|
1443
|
+
)
|
|
1382
1444
|
await ctx.answerCallbackQuery({ text: '🚫 Dismissed.' }).catch(() => {})
|
|
1383
1445
|
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
1384
1446
|
await ctx
|
|
@@ -1388,6 +1450,10 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
|
|
|
1388
1450
|
)
|
|
1389
1451
|
.catch(() => {})
|
|
1390
1452
|
}
|
|
1453
|
+
process.stderr.write(
|
|
1454
|
+
`telegram gateway: eval_case_rejected agent=${agent} proposal=${proposal.id} ` +
|
|
1455
|
+
`slug=${proposal.skill_slug} delivered=${denied}\n`,
|
|
1456
|
+
)
|
|
1391
1457
|
return
|
|
1392
1458
|
}
|
|
1393
1459
|
|
|
@@ -1395,20 +1461,7 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
|
|
|
1395
1461
|
setEvalCaseProposalStatus(stateDir, parsed.id, 'approved')
|
|
1396
1462
|
await ctx.answerCallbackQuery({ text: '✅ Adding the eval case…' }).catch(() => {})
|
|
1397
1463
|
|
|
1398
|
-
const
|
|
1399
|
-
let applyOk = true
|
|
1400
|
-
let applyOut = ''
|
|
1401
|
-
try {
|
|
1402
|
-
applyOut = execFileSync(
|
|
1403
|
-
cli,
|
|
1404
|
-
['self-improve', 'apply-eval-case', '--id', parsed.id],
|
|
1405
|
-
{ encoding: 'utf8', timeout: 15000, env: process.env },
|
|
1406
|
-
).trim()
|
|
1407
|
-
} catch (err) {
|
|
1408
|
-
applyOk = false
|
|
1409
|
-
const e = err as { stdout?: string; stderr?: string; message?: string }
|
|
1410
|
-
applyOut = [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim()
|
|
1411
|
-
}
|
|
1464
|
+
const { ok: applyOk, out: applyOut } = runEvalCaseApply(parsed.id)
|
|
1412
1465
|
|
|
1413
1466
|
const footer = applyOk
|
|
1414
1467
|
? '✅ <i>Added as a regression test.</i>'
|
|
@@ -1421,9 +1474,28 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
|
|
|
1421
1474
|
)
|
|
1422
1475
|
.catch(() => {})
|
|
1423
1476
|
}
|
|
1477
|
+
// Tell the agent the OUTCOME, not just that a tap happened — applied and
|
|
1478
|
+
// apply-failed are different instructions (resume vs. don't assume it exists).
|
|
1479
|
+
const outcomeInbound = applyOk
|
|
1480
|
+
? buildEvalCaseAppliedInbound({
|
|
1481
|
+
ctx: inboundCtx,
|
|
1482
|
+
proposalId: proposal.id,
|
|
1483
|
+
skillSlug: proposal.skill_slug,
|
|
1484
|
+
heldOut: proposal.held_out,
|
|
1485
|
+
operatorId: senderId,
|
|
1486
|
+
})
|
|
1487
|
+
: buildEvalCaseApplyFailedInbound({
|
|
1488
|
+
ctx: inboundCtx,
|
|
1489
|
+
proposalId: proposal.id,
|
|
1490
|
+
skillSlug: proposal.skill_slug,
|
|
1491
|
+
heldOut: proposal.held_out,
|
|
1492
|
+
operatorId: senderId,
|
|
1493
|
+
applyOut,
|
|
1494
|
+
})
|
|
1495
|
+
const delivered = deliverResumeSyntheticOrBuffer(agent, outcomeInbound)
|
|
1424
1496
|
process.stderr.write(
|
|
1425
1497
|
`telegram gateway: eval_case_apply agent=${agent} proposal=${proposal.id} ` +
|
|
1426
|
-
`slug=${proposal.skill_slug} ok=${applyOk}\n`,
|
|
1498
|
+
`slug=${proposal.skill_slug} ok=${applyOk} delivered=${delivered}\n`,
|
|
1427
1499
|
)
|
|
1428
1500
|
}
|
|
1429
1501
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the synthetic inbounds the gateway injects after the
|
|
3
|
+
* operator taps Approve / Dismiss on a self-improve EVAL-CASE proposal card
|
|
4
|
+
* (RFC amendment §"corrections as eval cases"). Mirrors
|
|
5
|
+
* `mental-model-propose-inbound-builders.ts`.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS MODULE EXISTS AT ALL: `handleEvalCaseProposalCallback` was a copy of
|
|
8
|
+
* `handleSkillProposalCallback` that dropped the
|
|
9
|
+
* `deliverResumeSyntheticOrBuffer` line. Both of its exits — dismiss and
|
|
10
|
+
* approve — edited the card and returned, so the PROPOSING AGENT was never
|
|
11
|
+
* told the outcome. It had been steered to end its turn cleanly and wait for a
|
|
12
|
+
* wake-up that no code path ever sent: every candidate second delivery path is
|
|
13
|
+
* empty (the self-improve Stop hook reads eval integrity baselines, not
|
|
14
|
+
* proposal status; `switchroom self-improve eval-case propose` is
|
|
15
|
+
* fire-and-forget and returns `ok:true` for POSTING THE CARD, never for the
|
|
16
|
+
* outcome). These three builders are the inbound half of that fix.
|
|
17
|
+
*
|
|
18
|
+
* The shape is load-bearing — `meta.source` is what the bridge keys on to
|
|
19
|
+
* render `<channel source="eval_case_applied">` / `eval_case_rejected` /
|
|
20
|
+
* `eval_case_apply_failed` blocks, and the
|
|
21
|
+
* `meta.{agent,proposal_id,skill_slug,held_out,operator_id}` fields are the
|
|
22
|
+
* forensic anchor tying the woken turn back to the exact proposal and the
|
|
23
|
+
* operator who decided it.
|
|
24
|
+
*
|
|
25
|
+
* A regression that drops a meta field or changes the source string would
|
|
26
|
+
* silently break the agent's wake-up: the bridge would route it as a generic
|
|
27
|
+
* channel event, the model wouldn't know its proposal resolved, and the
|
|
28
|
+
* conversation would drift back to the silent-block this module fixes. Pinning
|
|
29
|
+
* these against fixture tests is cheaper than catching that downstream.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
|
+
|
|
34
|
+
/** Subset of the stored proposal the builders need. Kept narrow so callers
|
|
35
|
+
* don't have to pass the full `EvalCaseProposal` record. */
|
|
36
|
+
export interface EvalCaseProposalInboundContext {
|
|
37
|
+
agent: string
|
|
38
|
+
/** Telegram chat id where the approval card lived. Used as the inbound's
|
|
39
|
+
* chatId so the synthesized turn stays associated with the originating
|
|
40
|
+
* conversation. */
|
|
41
|
+
chat_id: string
|
|
42
|
+
/** Supergroup forum topic (message_thread_id) the agent was working in when
|
|
43
|
+
* it proposed — so the resumed turn's reply lands back in that topic, not
|
|
44
|
+
* General. Undefined for DM / non-topic proposals. */
|
|
45
|
+
threadId?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The meta every eval-case outcome inbound carries, minus `source`. */
|
|
49
|
+
function commonMeta(opts: {
|
|
50
|
+
ctx: EvalCaseProposalInboundContext
|
|
51
|
+
proposalId: string
|
|
52
|
+
skillSlug: string
|
|
53
|
+
heldOut: boolean
|
|
54
|
+
operatorId: string
|
|
55
|
+
}): Record<string, string> {
|
|
56
|
+
return {
|
|
57
|
+
agent: opts.ctx.agent,
|
|
58
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
59
|
+
proposal_id: opts.proposalId,
|
|
60
|
+
skill_slug: opts.skillSlug,
|
|
61
|
+
held_out: String(opts.heldOut),
|
|
62
|
+
operator_id: opts.operatorId,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the synthetic InboundMessage for an approval whose DETERMINISTIC
|
|
68
|
+
* applier succeeded — the case is now on disk (in the skill's `evals.json`, or
|
|
69
|
+
* the held-out sink when `heldOut`). Nothing is left for the agent to write.
|
|
70
|
+
*/
|
|
71
|
+
export function buildEvalCaseAppliedInbound(opts: {
|
|
72
|
+
ctx: EvalCaseProposalInboundContext
|
|
73
|
+
proposalId: string
|
|
74
|
+
skillSlug: string
|
|
75
|
+
heldOut: boolean
|
|
76
|
+
operatorId: string
|
|
77
|
+
nowMs?: number
|
|
78
|
+
}): InboundMessage {
|
|
79
|
+
const ts = opts.nowMs ?? Date.now()
|
|
80
|
+
const sink = opts.heldOut ? 'the held-out sink' : `\`${opts.skillSlug}\`'s \`evals.json\``
|
|
81
|
+
return {
|
|
82
|
+
type: 'inbound',
|
|
83
|
+
chatId: opts.ctx.chat_id,
|
|
84
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
85
|
+
messageId: ts, // synthetic — no Telegram message id exists
|
|
86
|
+
user: 'self-improve',
|
|
87
|
+
userId: 0,
|
|
88
|
+
ts,
|
|
89
|
+
text:
|
|
90
|
+
`✅ Operator approved your proposed eval case for \`${opts.skillSlug}\` ` +
|
|
91
|
+
`(proposal ${opts.proposalId}). The case was applied DETERMINISTICALLY by ` +
|
|
92
|
+
`the gateway and is already written to ${sink} — do NOT write it yourself ` +
|
|
93
|
+
`and do NOT re-propose it. Resume whatever you were doing.`,
|
|
94
|
+
meta: {
|
|
95
|
+
source: 'eval_case_applied',
|
|
96
|
+
...commonMeta({
|
|
97
|
+
ctx: opts.ctx,
|
|
98
|
+
proposalId: opts.proposalId,
|
|
99
|
+
skillSlug: opts.skillSlug,
|
|
100
|
+
heldOut: opts.heldOut,
|
|
101
|
+
operatorId: opts.operatorId,
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the synthetic InboundMessage for an operator dismissal. NOTHING was
|
|
109
|
+
* written. Steers the agent onward rather than into a re-propose loop.
|
|
110
|
+
*
|
|
111
|
+
* This has no sibling in the skill-proposal handler (which stays silent on
|
|
112
|
+
* dismiss). Silence is the defect being fixed: a dismissed proposal left the
|
|
113
|
+
* agent waiting on a wake-up that never came.
|
|
114
|
+
*/
|
|
115
|
+
export function buildEvalCaseRejectedInbound(opts: {
|
|
116
|
+
ctx: EvalCaseProposalInboundContext
|
|
117
|
+
proposalId: string
|
|
118
|
+
skillSlug: string
|
|
119
|
+
heldOut: boolean
|
|
120
|
+
operatorId: string
|
|
121
|
+
nowMs?: number
|
|
122
|
+
}): InboundMessage {
|
|
123
|
+
const ts = opts.nowMs ?? Date.now()
|
|
124
|
+
return {
|
|
125
|
+
type: 'inbound',
|
|
126
|
+
chatId: opts.ctx.chat_id,
|
|
127
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
128
|
+
messageId: ts,
|
|
129
|
+
user: 'self-improve',
|
|
130
|
+
userId: 0,
|
|
131
|
+
ts,
|
|
132
|
+
text:
|
|
133
|
+
`🚫 Operator dismissed your proposed eval case for \`${opts.skillSlug}\` ` +
|
|
134
|
+
`(proposal ${opts.proposalId}). NOTHING was written — no eval case was ` +
|
|
135
|
+
`added. Carry on with the original task without it. Do NOT re-propose the ` +
|
|
136
|
+
`same case without first asking the user.`,
|
|
137
|
+
meta: {
|
|
138
|
+
source: 'eval_case_rejected',
|
|
139
|
+
...commonMeta({
|
|
140
|
+
ctx: opts.ctx,
|
|
141
|
+
proposalId: opts.proposalId,
|
|
142
|
+
skillSlug: opts.skillSlug,
|
|
143
|
+
heldOut: opts.heldOut,
|
|
144
|
+
operatorId: opts.operatorId,
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the synthetic InboundMessage for an approval whose applier FAILED
|
|
152
|
+
* (`switchroom self-improve apply-eval-case` non-zero, timeout, missing skill
|
|
153
|
+
* dir, a stale/rejected proposal the applier's own status check refused).
|
|
154
|
+
* The operator tapped Approve but the case did NOT land — be honest so the
|
|
155
|
+
* agent doesn't assume the regression test exists.
|
|
156
|
+
*
|
|
157
|
+
* `applyOut` is the applier's combined stdout/stderr tail, truncated so a
|
|
158
|
+
* runaway stack trace can't dominate the woken turn's context.
|
|
159
|
+
*/
|
|
160
|
+
export function buildEvalCaseApplyFailedInbound(opts: {
|
|
161
|
+
ctx: EvalCaseProposalInboundContext
|
|
162
|
+
proposalId: string
|
|
163
|
+
skillSlug: string
|
|
164
|
+
heldOut: boolean
|
|
165
|
+
operatorId: string
|
|
166
|
+
applyOut: string
|
|
167
|
+
nowMs?: number
|
|
168
|
+
}): InboundMessage {
|
|
169
|
+
const ts = opts.nowMs ?? Date.now()
|
|
170
|
+
const tail = opts.applyOut.trim().slice(0, 500)
|
|
171
|
+
return {
|
|
172
|
+
type: 'inbound',
|
|
173
|
+
chatId: opts.ctx.chat_id,
|
|
174
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
175
|
+
messageId: ts,
|
|
176
|
+
user: 'self-improve',
|
|
177
|
+
userId: 0,
|
|
178
|
+
ts,
|
|
179
|
+
text:
|
|
180
|
+
`⚠️ The operator approved your proposed eval case for \`${opts.skillSlug}\` ` +
|
|
181
|
+
`(proposal ${opts.proposalId}) but the applier FAILED. The case was NOT ` +
|
|
182
|
+
`written — do NOT assume the regression test exists. Applier output:\n` +
|
|
183
|
+
`${tail.length > 0 ? tail : '(no output)'}\n` +
|
|
184
|
+
`Carry on with the original task; report the failure to the operator if it ` +
|
|
185
|
+
`still matters.`,
|
|
186
|
+
meta: {
|
|
187
|
+
source: 'eval_case_apply_failed',
|
|
188
|
+
...commonMeta({
|
|
189
|
+
ctx: opts.ctx,
|
|
190
|
+
proposalId: opts.proposalId,
|
|
191
|
+
skillSlug: opts.skillSlug,
|
|
192
|
+
heldOut: opts.heldOut,
|
|
193
|
+
operatorId: opts.operatorId,
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|