switchroom 0.18.32 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// Card-tool handlers — the agent-facing MCP tool INVOCATION side of the
|
|
2
|
+
// approval-card families, extracted verbatim from gateway.ts (#2996 P5-tail).
|
|
3
|
+
//
|
|
4
|
+
// This module owns the `execute*` handlers that STAGE an approval card in
|
|
5
|
+
// response to an agent tool call. The operator-tap RESOLUTION side (the
|
|
6
|
+
// `callback_query:data` handlers) already lives in callback-query-handlers.ts
|
|
7
|
+
// (#3013 / #3329); this is its sibling — the propose/stage half:
|
|
8
|
+
//
|
|
9
|
+
// - executeVaultRequestSave — agent HAS a value, asks to persist it (vrs:)
|
|
10
|
+
// - executeVaultRequestAccess — agent needs read/write ACL on a key (vra:)
|
|
11
|
+
// - executeRequestSecret — agent needs a value it does NOT have (vsp:)
|
|
12
|
+
// - executeMentalModelPropose — agent proposes a standing mental model (mmp:)
|
|
13
|
+
//
|
|
14
|
+
// Deliberately NOT moved (they stay in gateway.ts and are INJECTED here as the
|
|
15
|
+
// ONE shared singleton — Amendment 1): the pending-state stores
|
|
16
|
+
// (pendingVaultRequestSaves / -Accesses / pendingSecretRequests /
|
|
17
|
+
// pendingMentalModelProposes / pendingCardStore), the secret WRITE path
|
|
18
|
+
// (writeRequestedSecret / captureProvidedSecret — shared with handleInbound),
|
|
19
|
+
// the sweepSecretRequests reaper hook, the per-agent propose rate limiter
|
|
20
|
+
// (checkMentalModelProposeRate + mentalModelProposeTimes[]),
|
|
21
|
+
// readLiveSwitchroomConfigText, and the gateway wrappers (robustApiCall,
|
|
22
|
+
// lockedBot, assertAllowedChat). Config constants are injected under their
|
|
23
|
+
// exact source names so bodies stay byte-identical.
|
|
24
|
+
//
|
|
25
|
+
// Style: factory over a deps object, following callback-query-handlers.ts /
|
|
26
|
+
// outbound-send-path.ts. Function bodies are byte-identical to the
|
|
27
|
+
// pre-extraction gateway.ts text (behavior-preserving #2996). None of these
|
|
28
|
+
// handlers reads the `currentTurn` global (Amendment 9 satisfied trivially —
|
|
29
|
+
// they take only `args`).
|
|
30
|
+
|
|
31
|
+
import { randomBytes } from 'crypto'
|
|
32
|
+
import { richMessage } from '../rich-send.js'
|
|
33
|
+
import { retryWithThreadFallback, type RetryCallOpts } from '../retry-api-call.js'
|
|
34
|
+
import {
|
|
35
|
+
renderVaultRequestSaveCard,
|
|
36
|
+
buildVaultRequestSaveKeyboard,
|
|
37
|
+
} from './vault-request-save-card.js'
|
|
38
|
+
import {
|
|
39
|
+
renderVaultRequestAccessCard,
|
|
40
|
+
buildVaultRequestAccessKeyboard,
|
|
41
|
+
} from './vault-request-access-card.js'
|
|
42
|
+
import {
|
|
43
|
+
renderSecretRequestCard,
|
|
44
|
+
buildSecretRequestKeyboard,
|
|
45
|
+
} from './secret-request-card.js'
|
|
46
|
+
import {
|
|
47
|
+
renderMentalModelProposeCard,
|
|
48
|
+
buildMentalModelProposeKeyboard,
|
|
49
|
+
} from './mental-model-propose-card.js'
|
|
50
|
+
import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
|
|
51
|
+
import type {
|
|
52
|
+
PendingVaultRequestSave,
|
|
53
|
+
PendingVaultRequestAccess,
|
|
54
|
+
PendingSecretRequest,
|
|
55
|
+
PendingMentalModelPropose,
|
|
56
|
+
} from './callback-query-handlers.js'
|
|
57
|
+
import type { SweepableCardStore } from './approval-card-stores.js'
|
|
58
|
+
import type { PendingCardStore } from './pending-card-store.js'
|
|
59
|
+
|
|
60
|
+
// Moved with executeMentalModelPropose (its only user) — the propose-name slug
|
|
61
|
+
// gate, distinct from the vault-key regex.
|
|
62
|
+
const MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
|
|
63
|
+
|
|
64
|
+
// ─── Bot shape the handlers touch (grammy Bot / chat-locked wrapper) ────────
|
|
65
|
+
|
|
66
|
+
/** Minimal bot surface the card senders touch. */
|
|
67
|
+
export interface CardToolBotApi {
|
|
68
|
+
api: {
|
|
69
|
+
sendRichMessage: (
|
|
70
|
+
chat_id: number | string,
|
|
71
|
+
rich_message: unknown,
|
|
72
|
+
other?: Record<string, unknown>,
|
|
73
|
+
) => Promise<{ message_id: number }>
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Deps ────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Everything the card-tool handlers read from gateway module scope. Stores are
|
|
81
|
+
* the consolidated #3008/#3011 surfaces (never raw Maps) and are the ONE shared
|
|
82
|
+
* singleton — never re-`new`'d here (Amendment 1). Config constants are injected
|
|
83
|
+
* under their exact source-name so handler bodies stay byte-identical.
|
|
84
|
+
*/
|
|
85
|
+
export interface CardToolHandlersDeps {
|
|
86
|
+
/** The chat-lock-wrapped bot (serialized sends). */
|
|
87
|
+
lockedBot: unknown
|
|
88
|
+
/** Flood-wait-aware retry wrapper (gateway's `robustApiCall`). */
|
|
89
|
+
robustApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T>
|
|
90
|
+
/** Gateway allowFrom gate — throws if chat_id is not permitted. */
|
|
91
|
+
assertAllowedChat: (chat_id: string | number) => void
|
|
92
|
+
/** Gateway-side vault-key shape gate (UX gate, not a security boundary). */
|
|
93
|
+
VAULT_KEY_REGEX: RegExp
|
|
94
|
+
VAULT_KEY_REGEX_LABEL: string
|
|
95
|
+
// Pending-state stores (consolidated surfaces; the ONE shared instance).
|
|
96
|
+
pendingVaultRequestSaves: SweepableCardStore<PendingVaultRequestSave>
|
|
97
|
+
pendingVaultRequestAccesses: SweepableCardStore<PendingVaultRequestAccess>
|
|
98
|
+
pendingSecretRequests: SweepableCardStore<PendingSecretRequest>
|
|
99
|
+
/** Durable card-metadata store (restart survival). */
|
|
100
|
+
pendingCardStore: PendingCardStore
|
|
101
|
+
/**
|
|
102
|
+
* Reaper hook that sweeps BOTH pendingSecretRequests and the transient
|
|
103
|
+
* armedSecretCaptures store. Injected (not re-derived) so executeRequestSecret
|
|
104
|
+
* stays byte-identical and armedSecretCaptures — which is shared with the
|
|
105
|
+
* handleInbound capture path — stays the ONE instance in gateway.ts.
|
|
106
|
+
*/
|
|
107
|
+
sweepSecretRequests: (now?: number) => void
|
|
108
|
+
/**
|
|
109
|
+
* Broker no-token `list` probe AS THIS AGENT (path-as-identity) — the
|
|
110
|
+
* authoritative standing-ACL coverage check in executeVaultRequestAccess
|
|
111
|
+
* (Fix B, #1487 follow-up). Injected (rather than imported) so the
|
|
112
|
+
* fail-open probe path and the ALREADY-covered short-circuit are
|
|
113
|
+
* deterministic under test; gateway wires the real
|
|
114
|
+
* src/vault/broker/client.js listViaBroker.
|
|
115
|
+
*/
|
|
116
|
+
listViaBroker: () => Promise<string[] | null>
|
|
117
|
+
/** VAULT_REQUEST_ACCESS_TTL_MS (config-driven approval-card lifetime). */
|
|
118
|
+
VAULT_REQUEST_ACCESS_TTL_MS: number
|
|
119
|
+
// ── executeMentalModelPropose deps ────────────────────────────────────────
|
|
120
|
+
/** The ONE pending-propose store (gateway singleton; expiry/restore stay there). */
|
|
121
|
+
pendingMentalModelProposes: SweepableCardStore<PendingMentalModelPropose>
|
|
122
|
+
/**
|
|
123
|
+
* The rate-limit window log — the SAME array instance the gateway-side
|
|
124
|
+
* checkMentalModelProposeRate mutates. The handler pushes onto it only
|
|
125
|
+
* after a card actually posts (validation errors / dupes don't consume
|
|
126
|
+
* budget); injected as the array (not an accessor) to keep the push
|
|
127
|
+
* byte-identical.
|
|
128
|
+
*/
|
|
129
|
+
mentalModelProposeTimes: number[]
|
|
130
|
+
/** Per-agent propose rate limiter (window state lives in gateway). */
|
|
131
|
+
checkMentalModelProposeRate: (now?: number) => { ok: true } | { ok: false; retryAtMs: number }
|
|
132
|
+
/** Live switchroom.yaml bytes for the duplicate-name pre-check. */
|
|
133
|
+
readLiveSwitchroomConfigText: () => string
|
|
134
|
+
/** memory.mental_models[] schema caps (src/config/schema.ts), injected under source names. */
|
|
135
|
+
MENTAL_MODEL_SOURCE_QUERY_MAX: number
|
|
136
|
+
MENTAL_MODEL_MAX_TOKENS_CAP: number
|
|
137
|
+
MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the card-tool handler family over the injected gateway deps. Bodies are
|
|
142
|
+
* verbatim from gateway.ts — behavior-preserving (#2996 P5-tail).
|
|
143
|
+
*/
|
|
144
|
+
export function createCardToolHandlers(deps: CardToolHandlersDeps) {
|
|
145
|
+
const lockedBot = deps.lockedBot as CardToolBotApi
|
|
146
|
+
const {
|
|
147
|
+
robustApiCall,
|
|
148
|
+
assertAllowedChat,
|
|
149
|
+
VAULT_KEY_REGEX,
|
|
150
|
+
VAULT_KEY_REGEX_LABEL,
|
|
151
|
+
pendingVaultRequestSaves,
|
|
152
|
+
pendingVaultRequestAccesses,
|
|
153
|
+
pendingSecretRequests,
|
|
154
|
+
pendingCardStore,
|
|
155
|
+
sweepSecretRequests,
|
|
156
|
+
listViaBroker,
|
|
157
|
+
VAULT_REQUEST_ACCESS_TTL_MS,
|
|
158
|
+
pendingMentalModelProposes,
|
|
159
|
+
mentalModelProposeTimes,
|
|
160
|
+
checkMentalModelProposeRate,
|
|
161
|
+
readLiveSwitchroomConfigText,
|
|
162
|
+
MENTAL_MODEL_SOURCE_QUERY_MAX,
|
|
163
|
+
MENTAL_MODEL_MAX_TOKENS_CAP,
|
|
164
|
+
MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW,
|
|
165
|
+
} = deps
|
|
166
|
+
|
|
167
|
+
async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
168
|
+
const chat_id = String(args.chat_id ?? '')
|
|
169
|
+
if (!chat_id) throw new Error('vault_request_save: chat_id is required')
|
|
170
|
+
const key = args.key as string
|
|
171
|
+
if (!key || typeof key !== 'string') throw new Error('vault_request_save: key is required')
|
|
172
|
+
const value = args.value as string
|
|
173
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
174
|
+
throw new Error('vault_request_save: value is required and must be a non-empty string')
|
|
175
|
+
}
|
|
176
|
+
const why = typeof args.why === 'string' ? args.why : undefined
|
|
177
|
+
const kindRaw = typeof args.kind === 'string' ? args.kind : 'string'
|
|
178
|
+
if (kindRaw !== 'string' && kindRaw !== 'binary') {
|
|
179
|
+
throw new Error('vault_request_save: kind must be "string" or "binary"')
|
|
180
|
+
}
|
|
181
|
+
assertAllowedChat(chat_id)
|
|
182
|
+
|
|
183
|
+
// Validate slug shape — vault keys must match a tight charset so the
|
|
184
|
+
// host CLI hints render cleanly and reference resolution stays
|
|
185
|
+
// predictable. Includes `/` so the canonical namespaced shape
|
|
186
|
+
// (`fatsecret/client_id`, `mff/agent-private-key`, ...) is
|
|
187
|
+
// accepted — issue #1047. The broker itself has no key regex
|
|
188
|
+
// (just `z.string().min(1)` in protocol.ts); this gateway-side
|
|
189
|
+
// gate is a UX guard, not a security boundary.
|
|
190
|
+
if (!VAULT_KEY_REGEX.test(key)) {
|
|
191
|
+
throw new Error(`vault_request_save: key must match ${VAULT_KEY_REGEX_LABEL}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
195
|
+
|
|
196
|
+
// Stage the request server-side. The value never leaves gateway memory
|
|
197
|
+
// until the user approves.
|
|
198
|
+
const stageId = randomBytes(4).toString('hex')
|
|
199
|
+
const pending: PendingVaultRequestSave = {
|
|
200
|
+
agent: agentSlug,
|
|
201
|
+
chat_id,
|
|
202
|
+
key,
|
|
203
|
+
kind: kindRaw,
|
|
204
|
+
value,
|
|
205
|
+
why,
|
|
206
|
+
staged_at: Date.now(),
|
|
207
|
+
}
|
|
208
|
+
pendingVaultRequestSaves.set(stageId, pending)
|
|
209
|
+
pendingVaultRequestSaves.sweep(Date.now())
|
|
210
|
+
|
|
211
|
+
// Send the approval card. #1075: route through retryWithThreadFallback
|
|
212
|
+
// so a deleted topic still lands the card on the main chat instead of
|
|
213
|
+
// crashing the tool call.
|
|
214
|
+
const text = renderVaultRequestSaveCard(pending, agentSlug)
|
|
215
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
216
|
+
// Remember the agent's working topic so the save-outcome inbound resumes in it.
|
|
217
|
+
if (threadId != null) pending.threadId = threadId
|
|
218
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
219
|
+
robustApiCall,
|
|
220
|
+
(tid) =>
|
|
221
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
222
|
+
reply_markup: buildVaultRequestSaveKeyboard(stageId),
|
|
223
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
224
|
+
}),
|
|
225
|
+
{ threadId, chat_id, verb: 'vault_request_save.card' },
|
|
226
|
+
)
|
|
227
|
+
pending.card_message_id = sent.message_id
|
|
228
|
+
// Persist card METADATA (never the staged `value` — secrets hygiene) so a
|
|
229
|
+
// gateway restart doesn't strand the parked agent. A restored Save tap can't
|
|
230
|
+
// complete (value is gone) and degrades to a "value lost to restart" wake-up.
|
|
231
|
+
pendingCardStore.add({
|
|
232
|
+
family: 'vault_request_save',
|
|
233
|
+
stageId,
|
|
234
|
+
agent: pending.agent,
|
|
235
|
+
chatId: pending.chat_id,
|
|
236
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
237
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
238
|
+
key: pending.key,
|
|
239
|
+
kind: pending.kind,
|
|
240
|
+
...(pending.why != null ? { why: pending.why } : {}),
|
|
241
|
+
stagedAt: pending.staged_at,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{
|
|
247
|
+
type: 'text',
|
|
248
|
+
text: `vault_request_save: card sent (stage_id=${stageId}, key=${key}). The user must tap a button before the secret is persisted; do not assume success until you see the user's next message confirming the outcome.`,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `vault_request_access` tool — agent surfaces an approval card asking
|
|
256
|
+
* the operator to grant a vault ACL it doesn't yet have. See #1012.
|
|
257
|
+
* Auth boundary: only operators on the gateway allowFrom list can tap;
|
|
258
|
+
* the agent itself can only REQUEST.
|
|
259
|
+
*/
|
|
260
|
+
async function executeVaultRequestAccess(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
261
|
+
const chat_id = String(args.chat_id ?? '')
|
|
262
|
+
if (!chat_id) throw new Error('vault_request_access: chat_id is required')
|
|
263
|
+
const key = args.key as string
|
|
264
|
+
if (!key || typeof key !== 'string') throw new Error('vault_request_access: key is required')
|
|
265
|
+
if (!VAULT_KEY_REGEX.test(key)) {
|
|
266
|
+
throw new Error(`vault_request_access: key must match ${VAULT_KEY_REGEX_LABEL}`)
|
|
267
|
+
}
|
|
268
|
+
const scopeRaw = typeof args.scope === 'string' ? args.scope : 'read'
|
|
269
|
+
if (scopeRaw !== 'read' && scopeRaw !== 'write') {
|
|
270
|
+
throw new Error('vault_request_access: scope must be "read" or "write"')
|
|
271
|
+
}
|
|
272
|
+
// Accept `why` as an alias for `reason`: the sibling tool
|
|
273
|
+
// vault_request_save uses `why`, and agents cross-contaminate the two
|
|
274
|
+
// schemas — without the alias the rationale silently drops off the
|
|
275
|
+
// approval card and the operator sees "why: not provided".
|
|
276
|
+
const reason =
|
|
277
|
+
typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
|
|
278
|
+
// Duration: accept a "30d" / "12h" string from the agent OR default
|
|
279
|
+
// to 30 days. Cap at 90 days — beyond that the operator should use
|
|
280
|
+
// the host CLI and pick the lifetime explicitly. Refuse "never"
|
|
281
|
+
// requests outright; agent-initiated grants must have a sunset.
|
|
282
|
+
let ttl_seconds = 30 * 24 * 60 * 60
|
|
283
|
+
const durRaw = args.duration
|
|
284
|
+
if (typeof durRaw === 'string' && durRaw.length > 0) {
|
|
285
|
+
const m = durRaw.match(/^(\d+)([dh])$/)
|
|
286
|
+
if (!m) {
|
|
287
|
+
throw new Error('vault_request_access: duration must look like "30d" or "12h"')
|
|
288
|
+
}
|
|
289
|
+
const n = Number(m[1])
|
|
290
|
+
const unit = m[2]
|
|
291
|
+
const parsed = unit === 'd' ? n * 86400 : n * 3600
|
|
292
|
+
const NINETY_DAYS = 90 * 86400
|
|
293
|
+
if (parsed <= 0 || parsed > NINETY_DAYS) {
|
|
294
|
+
throw new Error('vault_request_access: duration must be > 0 and <= 90d')
|
|
295
|
+
}
|
|
296
|
+
ttl_seconds = parsed
|
|
297
|
+
}
|
|
298
|
+
assertAllowedChat(chat_id)
|
|
299
|
+
|
|
300
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
301
|
+
|
|
302
|
+
// Fix B (#1487 follow-up): if this agent's STANDING ACL already
|
|
303
|
+
// covers the key, do NOT render a card or mint a grant. Minting
|
|
304
|
+
// writes a `.vault-token` that — pre-#1487 — *shadowed* the standing
|
|
305
|
+
// ACL (the exact gymbro trap) and is simply redundant post-#1487.
|
|
306
|
+
// Determine coverage AUTHORITATIVELY by probing the broker AS THIS
|
|
307
|
+
// AGENT (no-token list over the per-agent socket — path-as-identity;
|
|
308
|
+
// the gateway runs in the agent's container so the broker attributes
|
|
309
|
+
// it to this agent). NOT a gateway-side config read: the gateway can
|
|
310
|
+
// see newer config than the broker has loaded, so a config-derived
|
|
311
|
+
// "covered" could be wrong where the broker still denies. `list`
|
|
312
|
+
// returns only ACL-visible key NAMES — never secret values. Read
|
|
313
|
+
// scope only: schedule.secrets[] confers read, not write.
|
|
314
|
+
if (scopeRaw === 'read') {
|
|
315
|
+
try {
|
|
316
|
+
const visible = await listViaBroker()
|
|
317
|
+
if (visible !== null && visible.includes(key)) {
|
|
318
|
+
return {
|
|
319
|
+
content: [
|
|
320
|
+
{
|
|
321
|
+
type: 'text',
|
|
322
|
+
text:
|
|
323
|
+
`vault_request_access: '${key}' is ALREADY covered by ${agentSlug}'s ` +
|
|
324
|
+
`standing ACL (schedule.secrets[]). No approval card or grant is needed — ` +
|
|
325
|
+
`read it directly: \`switchroom vault get ${key}\`. Do NOT request a grant ` +
|
|
326
|
+
`for this key (a minted token would shadow the standing ACL). If a read ` +
|
|
327
|
+
`still returns VAULT-BROKER-DENIED, the broker likely needs a restart to ` +
|
|
328
|
+
`pick up a recent config change — tell the operator; don't re-request.`,
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Probe failed (broker unreachable / transient): fall through to
|
|
335
|
+
// the normal card flow. Fail-open is correct here — a redundant
|
|
336
|
+
// card is harmless; suppressing a needed card is not.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const stageId = randomBytes(4).toString('hex')
|
|
341
|
+
const pending: PendingVaultRequestAccess = {
|
|
342
|
+
agent: agentSlug,
|
|
343
|
+
chat_id,
|
|
344
|
+
key,
|
|
345
|
+
scope: scopeRaw,
|
|
346
|
+
reason,
|
|
347
|
+
ttl_seconds,
|
|
348
|
+
staged_at: Date.now(),
|
|
349
|
+
}
|
|
350
|
+
pendingVaultRequestAccesses.set(stageId, pending)
|
|
351
|
+
pendingVaultRequestAccesses.sweep(Date.now())
|
|
352
|
+
|
|
353
|
+
// renderVaultRequestAccessCard self-hardens its field line breaks (this card
|
|
354
|
+
// is sent direct, bypassing the switchroomReply chokepoint).
|
|
355
|
+
const text = renderVaultRequestAccessCard(pending)
|
|
356
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
357
|
+
// Remember the agent's working topic so the grant-outcome inbound resumes in it.
|
|
358
|
+
if (threadId != null) pending.threadId = threadId
|
|
359
|
+
// #1075: deleted-topic safe — fall back to main chat.
|
|
360
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
361
|
+
robustApiCall,
|
|
362
|
+
(tid) =>
|
|
363
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
364
|
+
reply_markup: buildVaultRequestAccessKeyboard(stageId),
|
|
365
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
366
|
+
}),
|
|
367
|
+
{ threadId, chat_id, verb: 'vault_request_access.card' },
|
|
368
|
+
)
|
|
369
|
+
pending.card_message_id = sent.message_id
|
|
370
|
+
// Persist card metadata (no secret material — this flow stages only the ACL
|
|
371
|
+
// request) so a gateway restart doesn't strand the parked agent.
|
|
372
|
+
pendingCardStore.add({
|
|
373
|
+
family: 'vault_request_access',
|
|
374
|
+
stageId,
|
|
375
|
+
agent: pending.agent,
|
|
376
|
+
chatId: pending.chat_id,
|
|
377
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
378
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
379
|
+
key: pending.key,
|
|
380
|
+
scope: pending.scope,
|
|
381
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
382
|
+
ttlSeconds: pending.ttl_seconds,
|
|
383
|
+
stagedAt: pending.staged_at,
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
content: [
|
|
388
|
+
{
|
|
389
|
+
type: 'text',
|
|
390
|
+
text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny — do not retry the vault read until you see a confirmation message. If the card times out (${Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)} min) you can re-request.`,
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `request_secret` tool — agent surfaces a card asking the operator to
|
|
398
|
+
* provide a missing secret. No `value` arg: the value arrives via secure
|
|
399
|
+
* capture (the operator's next message after they tap [Provide securely]).
|
|
400
|
+
*/
|
|
401
|
+
async function executeRequestSecret(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
402
|
+
const chat_id = String(args.chat_id ?? '')
|
|
403
|
+
if (!chat_id) throw new Error('request_secret: chat_id is required')
|
|
404
|
+
const key = args.key as string
|
|
405
|
+
if (!key || typeof key !== 'string') throw new Error('request_secret: key is required')
|
|
406
|
+
const reason = typeof args.reason === 'string' ? args.reason : undefined
|
|
407
|
+
assertAllowedChat(chat_id)
|
|
408
|
+
if (!VAULT_KEY_REGEX.test(key)) {
|
|
409
|
+
throw new Error(`request_secret: key must match ${VAULT_KEY_REGEX_LABEL}`)
|
|
410
|
+
}
|
|
411
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
412
|
+
|
|
413
|
+
// Dedupe: one open request per (chat, key). Drop any prior stage for
|
|
414
|
+
// the same target so the operator never sees stacked cards.
|
|
415
|
+
for (const [sid, p] of pendingSecretRequests) {
|
|
416
|
+
if (p.chat_id === chat_id && p.key === key) {
|
|
417
|
+
pendingSecretRequests.delete(sid)
|
|
418
|
+
pendingCardStore.remove(sid)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const stageId = randomBytes(4).toString('hex')
|
|
423
|
+
const pending: PendingSecretRequest = { agent: agentSlug, chat_id, key, reason, staged_at: Date.now() }
|
|
424
|
+
pendingSecretRequests.set(stageId, pending)
|
|
425
|
+
sweepSecretRequests()
|
|
426
|
+
|
|
427
|
+
const text = renderSecretRequestCard(pending)
|
|
428
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
429
|
+
// Remember the agent's working topic so the provide/decline/fail inbound resumes in it.
|
|
430
|
+
if (threadId != null) pending.threadId = threadId
|
|
431
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
432
|
+
robustApiCall,
|
|
433
|
+
(tid) =>
|
|
434
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
435
|
+
reply_markup: buildSecretRequestKeyboard(stageId),
|
|
436
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
437
|
+
}),
|
|
438
|
+
{ threadId, chat_id, verb: 'request_secret.card' },
|
|
439
|
+
)
|
|
440
|
+
pending.card_message_id = sent.message_id
|
|
441
|
+
// Persist card metadata so a gateway restart doesn't strand the parked
|
|
442
|
+
// agent. request_secret holds NO value at staging time (the value arrives
|
|
443
|
+
// after the operator taps [Provide securely]), so nothing sensitive lands
|
|
444
|
+
// on disk here.
|
|
445
|
+
pendingCardStore.add({
|
|
446
|
+
family: 'request_secret',
|
|
447
|
+
stageId,
|
|
448
|
+
agent: pending.agent,
|
|
449
|
+
chatId: pending.chat_id,
|
|
450
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
451
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
452
|
+
key: pending.key,
|
|
453
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
454
|
+
stagedAt: pending.staged_at,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
content: [
|
|
459
|
+
{
|
|
460
|
+
type: 'text',
|
|
461
|
+
text: `request_secret: card sent (stage_id=${stageId}, key=${key}). END YOUR TURN now and wait — a fresh inbound message arrives once the operator provides (or declines) the secret. Do NOT ask them to paste it as a normal message; the card handles it securely.`,
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* `mental_model_propose` tool (hindsight Phase 5) — the agent surfaces a
|
|
469
|
+
* candidate mental model for the operator to approve. Mirrors the
|
|
470
|
+
* `vault_request_access` shape: the agent can only PROPOSE; the [Approve]/[Deny]
|
|
471
|
+
* tap is operator-gated (handleMentalModelProposeCallback), so an agent can
|
|
472
|
+
* never self-approve. On Approve the proposal is DECLARED — appended to the
|
|
473
|
+
* agent's memory.mental_models[] via the operator-approved config-edit path
|
|
474
|
+
* (reusing config_propose_edit apply+reconcile) — and ensured in the bank. On
|
|
475
|
+
* Deny nothing is written. Guardrails enforced here BEFORE any card:
|
|
476
|
+
* duplicate-name rejection against the agent's already-declared models, and a
|
|
477
|
+
* per-agent rate limit so proposals stay non-spammy.
|
|
478
|
+
*/
|
|
479
|
+
async function executeMentalModelPropose(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
480
|
+
const chat_id = String(args.chat_id ?? '')
|
|
481
|
+
if (!chat_id) throw new Error('mental_model_propose: chat_id is required')
|
|
482
|
+
const name = typeof args.name === 'string' ? args.name.trim() : ''
|
|
483
|
+
if (!name) throw new Error('mental_model_propose: name is required')
|
|
484
|
+
if (!MENTAL_MODEL_NAME_REGEX.test(name)) {
|
|
485
|
+
throw new Error('mental_model_propose: name must be a slug (letters/digits/_/-, ≤64 chars, e.g. `training-plan-state`)')
|
|
486
|
+
}
|
|
487
|
+
const source_query = typeof args.source_query === 'string' ? args.source_query.trim() : ''
|
|
488
|
+
if (!source_query) throw new Error('mental_model_propose: source_query is required')
|
|
489
|
+
// Enforce the memory.mental_models[] schema cap (src/config/schema.ts) up-front
|
|
490
|
+
// so the operator never approves a card that then fails hostd config validation.
|
|
491
|
+
// The 2000-char ceiling also keeps the rendered card under Telegram's 4096-char
|
|
492
|
+
// message limit.
|
|
493
|
+
if (source_query.length > MENTAL_MODEL_SOURCE_QUERY_MAX) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`mental_model_propose: source_query is ${source_query.length} chars; the schema caps it at ${MENTAL_MODEL_SOURCE_QUERY_MAX} (a standing reflection query, not a document). Shorten it.`,
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
// Accept `why` as an alias for `reason` (mirrors the vault tools).
|
|
499
|
+
const reason =
|
|
500
|
+
typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
|
|
501
|
+
let refresh_after_consolidation: boolean | undefined
|
|
502
|
+
if (args.refresh_after_consolidation !== undefined) {
|
|
503
|
+
if (typeof args.refresh_after_consolidation !== 'boolean') {
|
|
504
|
+
throw new Error('mental_model_propose: refresh_after_consolidation must be a boolean')
|
|
505
|
+
}
|
|
506
|
+
refresh_after_consolidation = args.refresh_after_consolidation
|
|
507
|
+
}
|
|
508
|
+
let max_tokens: number | undefined
|
|
509
|
+
if (args.max_tokens !== undefined) {
|
|
510
|
+
const n = Number(args.max_tokens)
|
|
511
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
512
|
+
throw new Error('mental_model_propose: max_tokens must be a positive integer')
|
|
513
|
+
}
|
|
514
|
+
// Enforce the schema ceiling (src/config/schema.ts) here so the card can't be
|
|
515
|
+
// approved into a config-validation failure downstream.
|
|
516
|
+
if (n > MENTAL_MODEL_MAX_TOKENS_CAP) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`mental_model_propose: max_tokens ${n} exceeds the schema cap of ${MENTAL_MODEL_MAX_TOKENS_CAP} (a mental model is a standing summary, not a corpus).`,
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
max_tokens = n
|
|
522
|
+
}
|
|
523
|
+
assertAllowedChat(chat_id)
|
|
524
|
+
|
|
525
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
526
|
+
|
|
527
|
+
// Rate limit: a proposal is a rare, deliberate curation act — throttle so a
|
|
528
|
+
// looping agent can never spam the operator with cards.
|
|
529
|
+
const rate = checkMentalModelProposeRate()
|
|
530
|
+
if (!rate.ok) {
|
|
531
|
+
const retryAtIso = new Date(rate.retryAtMs).toISOString()
|
|
532
|
+
return {
|
|
533
|
+
content: [
|
|
534
|
+
{
|
|
535
|
+
type: 'text',
|
|
536
|
+
text:
|
|
537
|
+
`mental_model_propose: RATE-LIMITED (max ${MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW} proposals/hour). ` +
|
|
538
|
+
`No card was posted. Next slot opens at ${retryAtIso}. Proposing mental models is meant to be ` +
|
|
539
|
+
`rare — batch or wait rather than re-firing.`,
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Duplicate-name guard: reject a proposal for a model already DECLARED for
|
|
546
|
+
// this agent, BEFORE posting a card (the name is the idempotent-ensure key).
|
|
547
|
+
try {
|
|
548
|
+
const configText = readLiveSwitchroomConfigText()
|
|
549
|
+
const declared = readDeclaredMentalModelNames(configText, agentSlug)
|
|
550
|
+
if (declared.includes(name)) {
|
|
551
|
+
return {
|
|
552
|
+
content: [
|
|
553
|
+
{
|
|
554
|
+
type: 'text',
|
|
555
|
+
text:
|
|
556
|
+
`mental_model_propose: '${name}' is ALREADY a declared mental model for ${agentSlug} ` +
|
|
557
|
+
`(memory.mental_models[]). No card was posted — it already exists and is ensured in your ` +
|
|
558
|
+
`bank. Pick a different name if you meant a NEW model, or just use the existing one.`,
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} catch (err) {
|
|
564
|
+
// Config read failed (transient) — fall through to the card. The approve
|
|
565
|
+
// path re-reads and re-checks (dupe guard is defense-in-depth), so a
|
|
566
|
+
// redundant card is harmless; suppressing a needed card is not.
|
|
567
|
+
process.stderr.write(`telegram gateway: mental_model_propose dup pre-check read failed: ${(err as Error).message}\n`)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const stageId = randomBytes(4).toString('hex')
|
|
571
|
+
const pending: PendingMentalModelPropose = {
|
|
572
|
+
agent: agentSlug,
|
|
573
|
+
chat_id,
|
|
574
|
+
spec: {
|
|
575
|
+
name,
|
|
576
|
+
source_query,
|
|
577
|
+
...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
|
|
578
|
+
...(max_tokens !== undefined ? { max_tokens } : {}),
|
|
579
|
+
},
|
|
580
|
+
...(reason ? { reason } : {}),
|
|
581
|
+
staged_at: Date.now(),
|
|
582
|
+
}
|
|
583
|
+
pendingMentalModelProposes.set(stageId, pending)
|
|
584
|
+
pendingMentalModelProposes.sweep(Date.now())
|
|
585
|
+
|
|
586
|
+
const text = renderMentalModelProposeCard({
|
|
587
|
+
agent: agentSlug,
|
|
588
|
+
name,
|
|
589
|
+
source_query,
|
|
590
|
+
...(reason ? { reason } : {}),
|
|
591
|
+
...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
|
|
592
|
+
})
|
|
593
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
594
|
+
if (threadId != null) pending.threadId = threadId
|
|
595
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
596
|
+
robustApiCall,
|
|
597
|
+
(tid) =>
|
|
598
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
599
|
+
reply_markup: buildMentalModelProposeKeyboard(stageId),
|
|
600
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
601
|
+
}),
|
|
602
|
+
{ threadId, chat_id, verb: 'mental_model_propose.card' },
|
|
603
|
+
)
|
|
604
|
+
pending.card_message_id = sent.message_id
|
|
605
|
+
// Persist card metadata (the proposed DECLARATION is not secret material) so
|
|
606
|
+
// a gateway restart doesn't strand the parked agent.
|
|
607
|
+
pendingCardStore.add({
|
|
608
|
+
family: 'mental_model_propose',
|
|
609
|
+
stageId,
|
|
610
|
+
agent: pending.agent,
|
|
611
|
+
chatId: pending.chat_id,
|
|
612
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
613
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
614
|
+
spec: pending.spec,
|
|
615
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
616
|
+
stagedAt: pending.staged_at,
|
|
617
|
+
})
|
|
618
|
+
// Only count a proposal against the rate budget once its card actually
|
|
619
|
+
// posted (validation errors / dupes don't consume the budget).
|
|
620
|
+
mentalModelProposeTimes.push(Date.now())
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
content: [
|
|
624
|
+
{
|
|
625
|
+
type: 'text',
|
|
626
|
+
text:
|
|
627
|
+
`mental_model_propose: card sent (stage_id=${stageId}, name=${name}). Wait for the operator to tap ` +
|
|
628
|
+
`Approve or Deny — END YOUR TURN cleanly. A fresh inbound arrives with the outcome ` +
|
|
629
|
+
`(source=mental_model_proposal_applied / mental_model_proposal_denied). Do NOT re-propose this ` +
|
|
630
|
+
`model while the card is open.`,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { executeVaultRequestSave, executeVaultRequestAccess, executeRequestSecret, executeMentalModelPropose }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export type CardToolHandlers = ReturnType<typeof createCardToolHandlers>
|