switchroom 0.14.57 → 0.14.59
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 +19 -9
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +29 -70
- package/dist/host-control/main.js +1 -1
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +268 -14
- package/telegram-plugin/final-answer-detect.ts +34 -0
- package/telegram-plugin/gateway/feed-reopen-gate.ts +162 -0
- package/telegram-plugin/gateway/gateway.ts +304 -4
- package/telegram-plugin/gateway/obligation-ledger.ts +216 -0
- package/telegram-plugin/tests/feed-reopen-gate.test.ts +133 -0
- package/telegram-plugin/tests/final-answer-detect.test.ts +67 -1
- package/telegram-plugin/tests/obligation-ledger.test.ts +167 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tool-activity-summary.ts +14 -4
|
@@ -23874,7 +23874,7 @@ var init_schema = __esm(() => {
|
|
|
23874
23874
|
tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet \u2014 see RFC G \u00a75).")
|
|
23875
23875
|
}).optional();
|
|
23876
23876
|
MicrosoftWorkspaceConfigSchema = exports_external.object({
|
|
23877
|
-
microsoft_client_id: exports_external.string().min(1).describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id')."),
|
|
23877
|
+
microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
|
|
23878
23878
|
microsoft_client_secret: exports_external.string().min(1).optional().describe("Microsoft OAuth client secret. Optional \u2014 public-client apps " + "(Mobile + Desktop platform with 'Allow public client flows' " + "enabled) work without a secret; confidential clients pass " + "one. Either literal or vault reference e.g. " + "'vault:microsoft-oauth-client-secret'."),
|
|
23879
23879
|
authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
|
|
23880
23880
|
org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC \u00a76.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false \u2014 personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
|
|
@@ -32729,7 +32729,7 @@ var MIRROR_MAX_LINES = 6;
|
|
|
32729
32729
|
function escapeFeedHtml(s) {
|
|
32730
32730
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
32731
32731
|
}
|
|
32732
|
-
function renderActivityFeed(lines, final = false) {
|
|
32732
|
+
function renderActivityFeed(lines, final = false, liveSuffix = "") {
|
|
32733
32733
|
if (lines.length === 0)
|
|
32734
32734
|
return null;
|
|
32735
32735
|
const shown = lines.slice(-MIRROR_MAX_LINES);
|
|
@@ -32740,7 +32740,7 @@ function renderActivityFeed(lines, final = false) {
|
|
|
32740
32740
|
const lastIdx = shown.length - 1;
|
|
32741
32741
|
shown.forEach((l, i) => {
|
|
32742
32742
|
const esc = escapeFeedHtml(l);
|
|
32743
|
-
out.push(i === lastIdx && !final ? `<b>\u2192 ${esc}</b>` : `<i>\u2713 ${esc}</i>`);
|
|
32743
|
+
out.push(i === lastIdx && !final ? `<b>\u2192 ${esc}${liveSuffix}</b>` : `<i>\u2713 ${esc}</i>`);
|
|
32744
32744
|
});
|
|
32745
32745
|
return out.join(`
|
|
32746
32746
|
`);
|
|
@@ -32748,10 +32748,10 @@ function renderActivityFeed(lines, final = false) {
|
|
|
32748
32748
|
var NESTED_MAX_LINES = 4;
|
|
32749
32749
|
var NESTED_LINE_MAX = 90;
|
|
32750
32750
|
var NESTED_PREFIX = " \u21b3 ";
|
|
32751
|
-
function renderActivityFeedWithNested(lines, childLines, final = false) {
|
|
32751
|
+
function renderActivityFeedWithNested(lines, childLines, final = false, liveSuffix = "") {
|
|
32752
32752
|
const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
32753
32753
|
if (children.length === 0)
|
|
32754
|
-
return renderActivityFeed(lines, final);
|
|
32754
|
+
return renderActivityFeed(lines, final, liveSuffix);
|
|
32755
32755
|
const out = [];
|
|
32756
32756
|
const shownParent = lines.slice(-MIRROR_MAX_LINES);
|
|
32757
32757
|
const hiddenParent = lines.length - shownParent.length;
|
|
@@ -32767,7 +32767,7 @@ function renderActivityFeedWithNested(lines, childLines, final = false) {
|
|
|
32767
32767
|
shownChild.forEach((l, i) => {
|
|
32768
32768
|
const t = l.length > NESTED_LINE_MAX ? l.slice(0, NESTED_LINE_MAX - 1) + "\u2026" : l;
|
|
32769
32769
|
const esc = escapeFeedHtml(t);
|
|
32770
|
-
out.push(i === lastChildIdx && !final ? `${NESTED_PREFIX}<b>\u2192 ${esc}</b>` : `${NESTED_PREFIX}<i>${esc}</i>`);
|
|
32770
|
+
out.push(i === lastChildIdx && !final ? `${NESTED_PREFIX}<b>\u2192 ${esc}${liveSuffix}</b>` : `${NESTED_PREFIX}<i>${esc}</i>`);
|
|
32771
32771
|
});
|
|
32772
32772
|
return out.length > 0 ? out.join(`
|
|
32773
32773
|
`) : null;
|
|
@@ -39372,6 +39372,13 @@ function isFinalAnswerReply(input) {
|
|
|
39372
39372
|
return true;
|
|
39373
39373
|
return false;
|
|
39374
39374
|
}
|
|
39375
|
+
function isSubstantiveFinalReply(input) {
|
|
39376
|
+
if (input.done === true)
|
|
39377
|
+
return true;
|
|
39378
|
+
if (input.text.length >= FINAL_ANSWER_MIN_CHARS)
|
|
39379
|
+
return true;
|
|
39380
|
+
return false;
|
|
39381
|
+
}
|
|
39375
39382
|
|
|
39376
39383
|
// turn-flush-safety.ts
|
|
39377
39384
|
var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
|
|
@@ -47125,6 +47132,92 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
47125
47132
|
};
|
|
47126
47133
|
}
|
|
47127
47134
|
|
|
47135
|
+
// gateway/obligation-ledger.ts
|
|
47136
|
+
class ObligationLedger {
|
|
47137
|
+
maxRepresents;
|
|
47138
|
+
open = new Map;
|
|
47139
|
+
constructor(maxRepresents = 2) {
|
|
47140
|
+
this.maxRepresents = maxRepresents;
|
|
47141
|
+
}
|
|
47142
|
+
openIfAbsent(input) {
|
|
47143
|
+
if (this.open.has(input.originTurnId))
|
|
47144
|
+
return false;
|
|
47145
|
+
this.open.set(input.originTurnId, { ...input, representCount: 0 });
|
|
47146
|
+
return true;
|
|
47147
|
+
}
|
|
47148
|
+
close(originTurnId) {
|
|
47149
|
+
if (originTurnId == null)
|
|
47150
|
+
return false;
|
|
47151
|
+
return this.open.delete(originTurnId);
|
|
47152
|
+
}
|
|
47153
|
+
isOpen(originTurnId) {
|
|
47154
|
+
return this.open.has(originTurnId);
|
|
47155
|
+
}
|
|
47156
|
+
hasOpen() {
|
|
47157
|
+
return this.open.size > 0;
|
|
47158
|
+
}
|
|
47159
|
+
size() {
|
|
47160
|
+
return this.open.size;
|
|
47161
|
+
}
|
|
47162
|
+
list() {
|
|
47163
|
+
return [...this.open.values()].sort((a, b) => a.openedAt - b.openedAt);
|
|
47164
|
+
}
|
|
47165
|
+
oldest() {
|
|
47166
|
+
let best;
|
|
47167
|
+
for (const o of this.open.values()) {
|
|
47168
|
+
if (best === undefined || o.openedAt < best.openedAt)
|
|
47169
|
+
best = o;
|
|
47170
|
+
}
|
|
47171
|
+
return best;
|
|
47172
|
+
}
|
|
47173
|
+
decideAtIdle() {
|
|
47174
|
+
const o = this.oldest();
|
|
47175
|
+
if (o === undefined)
|
|
47176
|
+
return { action: "none" };
|
|
47177
|
+
if (o.representCount >= this.maxRepresents)
|
|
47178
|
+
return { action: "escalate", obligation: o };
|
|
47179
|
+
return { action: "represent", obligation: o };
|
|
47180
|
+
}
|
|
47181
|
+
resolveCloseTarget(echoedTurnId, liveTurnId) {
|
|
47182
|
+
if (echoedTurnId != null)
|
|
47183
|
+
return echoedTurnId;
|
|
47184
|
+
if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId))
|
|
47185
|
+
return liveTurnId;
|
|
47186
|
+
return null;
|
|
47187
|
+
}
|
|
47188
|
+
markRepresented(originTurnId) {
|
|
47189
|
+
const o = this.open.get(originTurnId);
|
|
47190
|
+
if (o === undefined)
|
|
47191
|
+
return 0;
|
|
47192
|
+
o.representCount += 1;
|
|
47193
|
+
return o.representCount;
|
|
47194
|
+
}
|
|
47195
|
+
}
|
|
47196
|
+
var REPRESENT_PREVIEW_MAX = 200;
|
|
47197
|
+
function buildObligationRepresentInbound(o, now) {
|
|
47198
|
+
const preview = o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + "\u2026" : o.text;
|
|
47199
|
+
const topicClause = o.threadId != null ? " in this topic" : "";
|
|
47200
|
+
return {
|
|
47201
|
+
type: "inbound",
|
|
47202
|
+
chatId: o.chatId,
|
|
47203
|
+
...o.threadId != null ? { threadId: o.threadId } : {},
|
|
47204
|
+
messageId: o.messageId,
|
|
47205
|
+
user: "switchroom",
|
|
47206
|
+
userId: 0,
|
|
47207
|
+
ts: now,
|
|
47208
|
+
text: `You have an earlier message${topicClause} that you started but never actually ` + `answered (you may have set it aside mid-work): "${preview}". Answer it now via the ` + `reply tool \u2014 deliver the real answer, don't just acknowledge it. If you've lost the ` + `surrounding context, call get_recent_messages for this chat${topicClause} first. ` + `That quoted text may be only the first ~200 characters of the original.`,
|
|
47209
|
+
meta: {
|
|
47210
|
+
source: "obligation_represent",
|
|
47211
|
+
origin_turn_id: o.originTurnId,
|
|
47212
|
+
represent_count: String(o.representCount + 1)
|
|
47213
|
+
}
|
|
47214
|
+
};
|
|
47215
|
+
}
|
|
47216
|
+
function obligationEscalationText(o) {
|
|
47217
|
+
const preview = o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + "\u2026" : o.text;
|
|
47218
|
+
return `\u26a0\ufe0f I may have missed an earlier message and I'm not sure I answered it: ` + `"${preview}". If you still need it, please re-send.`;
|
|
47219
|
+
}
|
|
47220
|
+
|
|
47128
47221
|
// gateway/inbound-spool.ts
|
|
47129
47222
|
function spoolId(msg) {
|
|
47130
47223
|
if (msg.meta?.source === "subagent_handback" && typeof msg.meta?.subagent_jsonl_id === "string" && msg.meta.subagent_jsonl_id.length > 0) {
|
|
@@ -47366,6 +47459,28 @@ function shouldArmNoReplyDrain(input) {
|
|
|
47366
47459
|
return input.bufferedDepth > 0;
|
|
47367
47460
|
}
|
|
47368
47461
|
|
|
47462
|
+
// gateway/feed-reopen-gate.ts
|
|
47463
|
+
function shouldReopenFeedAfterAck(input) {
|
|
47464
|
+
if (!input.finalAnswerDelivered)
|
|
47465
|
+
return false;
|
|
47466
|
+
if (input.finalAnswerSubstantive)
|
|
47467
|
+
return false;
|
|
47468
|
+
return input.enabled === true;
|
|
47469
|
+
}
|
|
47470
|
+
function decideFeedReopen(input) {
|
|
47471
|
+
if (!shouldReopenFeedAfterAck(input)) {
|
|
47472
|
+
return { dropLabel: true };
|
|
47473
|
+
}
|
|
47474
|
+
return {
|
|
47475
|
+
dropLabel: false,
|
|
47476
|
+
reset: {
|
|
47477
|
+
finalAnswerDelivered: false,
|
|
47478
|
+
activityMessageId: null,
|
|
47479
|
+
activityLastSentRender: null
|
|
47480
|
+
}
|
|
47481
|
+
};
|
|
47482
|
+
}
|
|
47483
|
+
|
|
47369
47484
|
// gateway/answer-thread-resolve.ts
|
|
47370
47485
|
function resolveAnswerThreadId(input) {
|
|
47371
47486
|
if (input.explicitThreadId != null)
|
|
@@ -52195,10 +52310,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52195
52310
|
}
|
|
52196
52311
|
|
|
52197
52312
|
// ../src/build-info.ts
|
|
52198
|
-
var VERSION = "0.14.
|
|
52199
|
-
var COMMIT_SHA = "
|
|
52200
|
-
var COMMIT_DATE = "2026-06-
|
|
52201
|
-
var LATEST_PR =
|
|
52313
|
+
var VERSION = "0.14.59";
|
|
52314
|
+
var COMMIT_SHA = "178c6d14";
|
|
52315
|
+
var COMMIT_DATE = "2026-06-04T07:31:45Z";
|
|
52316
|
+
var LATEST_PR = 2146;
|
|
52202
52317
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52203
52318
|
|
|
52204
52319
|
// gateway/boot-version.ts
|
|
@@ -53396,6 +53511,10 @@ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw
|
|
|
53396
53511
|
var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
|
|
53397
53512
|
var DELIVERY_CONFIRM_SWEEP_MS = 5000;
|
|
53398
53513
|
var deliveryQueue = createDeliveryQueue();
|
|
53514
|
+
var OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER === "1";
|
|
53515
|
+
var OBLIGATION_REPRESENT_MAX = 2;
|
|
53516
|
+
var OBLIGATION_SWEEP_MS = 5000;
|
|
53517
|
+
var obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX);
|
|
53399
53518
|
var SERIALIZE_UNTIL_REPLIED_ENABLED = process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== "0";
|
|
53400
53519
|
var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
|
|
53401
53520
|
var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
|
|
@@ -53403,6 +53522,17 @@ var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noRepl
|
|
|
53403
53522
|
var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
|
|
53404
53523
|
var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
|
|
53405
53524
|
var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
|
|
53525
|
+
var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
|
|
53526
|
+
var FEED_HEARTBEAT_ENABLED = process.env.SWITCHROOM_FEED_HEARTBEAT !== "0";
|
|
53527
|
+
var FEED_HEARTBEAT_TICK_MS = 6000;
|
|
53528
|
+
var FEED_HEARTBEAT_MIN_STALE_MS = 6000;
|
|
53529
|
+
function formatFeedElapsed(ms) {
|
|
53530
|
+
const s = Math.floor(ms / 1000);
|
|
53531
|
+
if (s < 60)
|
|
53532
|
+
return `${s}s`;
|
|
53533
|
+
const m = Math.floor(s / 60);
|
|
53534
|
+
return `${m}m${(s % 60).toString().padStart(2, "0")}s`;
|
|
53535
|
+
}
|
|
53406
53536
|
function turnInFlightForGate() {
|
|
53407
53537
|
return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
|
|
53408
53538
|
}
|
|
@@ -53443,6 +53573,37 @@ function findTurnByOriginId(originTurnId) {
|
|
|
53443
53573
|
return currentTurn;
|
|
53444
53574
|
return recentTurnsById.get(originTurnId) ?? null;
|
|
53445
53575
|
}
|
|
53576
|
+
function closeObligationOnSubstantiveReply(args, liveTurn) {
|
|
53577
|
+
if (!OBLIGATION_LEDGER_ENABLED)
|
|
53578
|
+
return;
|
|
53579
|
+
const echoed = findTurnByOriginId(args.origin_turn_id);
|
|
53580
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId);
|
|
53581
|
+
if (target != null)
|
|
53582
|
+
obligationLedger.close(target);
|
|
53583
|
+
}
|
|
53584
|
+
function openObligationFromInbound(inboundMsg, gate) {
|
|
53585
|
+
if (!OBLIGATION_LEDGER_ENABLED)
|
|
53586
|
+
return;
|
|
53587
|
+
if (!shouldTrackDelivery({
|
|
53588
|
+
isSteering: gate.isSteering,
|
|
53589
|
+
isInterrupt: gate.isInterrupt,
|
|
53590
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
53591
|
+
effectiveText: gate.effectiveText
|
|
53592
|
+
})) {
|
|
53593
|
+
return;
|
|
53594
|
+
}
|
|
53595
|
+
const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId);
|
|
53596
|
+
if (oid == null)
|
|
53597
|
+
return;
|
|
53598
|
+
obligationLedger.openIfAbsent({
|
|
53599
|
+
originTurnId: oid,
|
|
53600
|
+
chatId: inboundMsg.chatId,
|
|
53601
|
+
threadId: inboundMsg.threadId,
|
|
53602
|
+
messageId: inboundMsg.messageId,
|
|
53603
|
+
text: inboundMsg.text ?? "",
|
|
53604
|
+
openedAt: Date.now()
|
|
53605
|
+
});
|
|
53606
|
+
}
|
|
53446
53607
|
function postQueuedStatus(chatId, bufferedThread, inFlightThread) {
|
|
53447
53608
|
if (!QUEUED_STATUS_UX_ENABLED)
|
|
53448
53609
|
return;
|
|
@@ -54718,6 +54879,40 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
|
|
|
54718
54879
|
}
|
|
54719
54880
|
});
|
|
54720
54881
|
var pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool });
|
|
54882
|
+
function obligationSweep() {
|
|
54883
|
+
if (!OBLIGATION_LEDGER_ENABLED)
|
|
54884
|
+
return;
|
|
54885
|
+
if (!obligationLedger.hasOpen())
|
|
54886
|
+
return;
|
|
54887
|
+
if (turnInFlightForGate())
|
|
54888
|
+
return;
|
|
54889
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
54890
|
+
if (pendingInboundBuffer.depth(agent) > 0)
|
|
54891
|
+
return;
|
|
54892
|
+
const decision = obligationLedger.decideAtIdle();
|
|
54893
|
+
const o = decision.obligation;
|
|
54894
|
+
if (decision.action === "none" || o == null)
|
|
54895
|
+
return;
|
|
54896
|
+
if (decision.action === "represent") {
|
|
54897
|
+
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()));
|
|
54898
|
+
const attempt = obligationLedger.markRepresented(o.originTurnId);
|
|
54899
|
+
process.stderr.write(`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}
|
|
54900
|
+
`);
|
|
54901
|
+
return;
|
|
54902
|
+
}
|
|
54903
|
+
obligationLedger.close(o.originTurnId);
|
|
54904
|
+
process.stderr.write(`telegram gateway: obligation escalated (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${o.originTurnId}
|
|
54905
|
+
`);
|
|
54906
|
+
robustApiCall(() => bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
|
|
54907
|
+
...o.threadId != null ? { message_thread_id: o.threadId } : {}
|
|
54908
|
+
}), { chat_id: o.chatId, ...o.threadId != null ? { threadId: o.threadId } : {}, verb: "obligation.escalate" }).catch((err) => {
|
|
54909
|
+
process.stderr.write(`telegram gateway: obligation escalation send failed: ${err}
|
|
54910
|
+
`);
|
|
54911
|
+
});
|
|
54912
|
+
}
|
|
54913
|
+
if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
54914
|
+
setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref();
|
|
54915
|
+
}
|
|
54721
54916
|
if (bootResumeInbound != null) {
|
|
54722
54917
|
if (inboundSpool != null) {
|
|
54723
54918
|
inboundSpool.put(bootResumeInbound.agent, bootResumeInbound.msg);
|
|
@@ -55690,6 +55885,12 @@ ${url}`;
|
|
|
55690
55885
|
disableNotification
|
|
55691
55886
|
})) {
|
|
55692
55887
|
turn2.finalAnswerDelivered = true;
|
|
55888
|
+
turn2.finalAnswerSubstantive = isSubstantiveFinalReply({
|
|
55889
|
+
text: decision.mergedText,
|
|
55890
|
+
disableNotification
|
|
55891
|
+
});
|
|
55892
|
+
if (turn2.finalAnswerSubstantive)
|
|
55893
|
+
closeObligationOnSubstantiveReply(args, turn2);
|
|
55693
55894
|
}
|
|
55694
55895
|
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
|
|
55695
55896
|
silentAnchorEditDone = true;
|
|
@@ -55889,7 +56090,10 @@ ${url}`;
|
|
|
55889
56090
|
noteSignal(statusKey(chat_id, threadId), Date.now());
|
|
55890
56091
|
if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
|
|
55891
56092
|
turn.finalAnswerDelivered = true;
|
|
56093
|
+
turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification });
|
|
55892
56094
|
finalizeStatusReaction(chat_id, threadId, "done");
|
|
56095
|
+
if (turn.finalAnswerSubstantive)
|
|
56096
|
+
closeObligationOnSubstantiveReply(args, turn);
|
|
55893
56097
|
}
|
|
55894
56098
|
releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
|
|
55895
56099
|
if (turn?.finalAnswerDelivered === true) {
|
|
@@ -56055,6 +56259,13 @@ async function executeStreamReply(args) {
|
|
|
56055
56259
|
done: args.done === true
|
|
56056
56260
|
})) {
|
|
56057
56261
|
turn.finalAnswerDelivered = true;
|
|
56262
|
+
turn.finalAnswerSubstantive = isSubstantiveFinalReply({
|
|
56263
|
+
text: args.text ?? "",
|
|
56264
|
+
disableNotification: args.disable_notification === true,
|
|
56265
|
+
done: args.done === true
|
|
56266
|
+
});
|
|
56267
|
+
if (turn.finalAnswerSubstantive)
|
|
56268
|
+
closeObligationOnSubstantiveReply(args, turn);
|
|
56058
56269
|
const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
56059
56270
|
clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
|
|
56060
56271
|
}
|
|
@@ -57046,12 +57257,12 @@ function closeProgressLane(chatId, threadId) {
|
|
|
57046
57257
|
}
|
|
57047
57258
|
}
|
|
57048
57259
|
var FOREGROUND_SUBAGENT_ACCUM_MAX = 12;
|
|
57049
|
-
function composeTurnActivity(turn, final = false) {
|
|
57260
|
+
function composeTurnActivity(turn, final = false, liveSuffix = "") {
|
|
57050
57261
|
const childLines = [];
|
|
57051
57262
|
for (const narrative of turn.foregroundSubAgents.values()) {
|
|
57052
57263
|
childLines.push(...narrative);
|
|
57053
57264
|
}
|
|
57054
|
-
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final);
|
|
57265
|
+
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix);
|
|
57055
57266
|
}
|
|
57056
57267
|
async function drainActivitySummary(turn) {
|
|
57057
57268
|
try {
|
|
@@ -57090,6 +57301,30 @@ async function drainActivitySummary(turn) {
|
|
|
57090
57301
|
turn.activityInFlight = null;
|
|
57091
57302
|
}
|
|
57092
57303
|
}
|
|
57304
|
+
function feedHeartbeatTick() {
|
|
57305
|
+
const turn = currentTurn;
|
|
57306
|
+
if (turn == null)
|
|
57307
|
+
return;
|
|
57308
|
+
if (turn.activityMessageId == null)
|
|
57309
|
+
return;
|
|
57310
|
+
if (turn.finalAnswerDelivered)
|
|
57311
|
+
return;
|
|
57312
|
+
if (turn.lastToolLabelAt == null)
|
|
57313
|
+
return;
|
|
57314
|
+
const elapsed = Date.now() - turn.lastToolLabelAt;
|
|
57315
|
+
if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS)
|
|
57316
|
+
return;
|
|
57317
|
+
const rendered = composeTurnActivity(turn, false, ` \xB7 ${formatFeedElapsed(elapsed)}`);
|
|
57318
|
+
if (rendered == null)
|
|
57319
|
+
return;
|
|
57320
|
+
turn.activityPendingRender = rendered;
|
|
57321
|
+
if (turn.activityInFlight == null) {
|
|
57322
|
+
turn.activityInFlight = drainActivitySummary(turn);
|
|
57323
|
+
}
|
|
57324
|
+
}
|
|
57325
|
+
if (!STATIC && FEED_HEARTBEAT_ENABLED) {
|
|
57326
|
+
setInterval(feedHeartbeatTick, FEED_HEARTBEAT_TICK_MS).unref();
|
|
57327
|
+
}
|
|
57093
57328
|
function clearActivitySummary(turn, finalHtmlOverride) {
|
|
57094
57329
|
const chat = turn.sessionChatId;
|
|
57095
57330
|
const thread = turn.sessionThreadId;
|
|
@@ -57148,6 +57383,7 @@ function handleSessionEvent(ev) {
|
|
|
57148
57383
|
gatewayReceiveAt: startedAt,
|
|
57149
57384
|
replyCalled: false,
|
|
57150
57385
|
finalAnswerDelivered: false,
|
|
57386
|
+
finalAnswerSubstantive: false,
|
|
57151
57387
|
firstPingAt: null,
|
|
57152
57388
|
silentAnchorMessageId: null,
|
|
57153
57389
|
silentAnchorText: "",
|
|
@@ -57259,10 +57495,21 @@ function handleSessionEvent(ev) {
|
|
|
57259
57495
|
return;
|
|
57260
57496
|
if (isTelegramSurfaceTool(ev.toolName))
|
|
57261
57497
|
return;
|
|
57262
|
-
if (turn.finalAnswerDelivered)
|
|
57263
|
-
|
|
57498
|
+
if (turn.finalAnswerDelivered) {
|
|
57499
|
+
const reopen = decideFeedReopen({
|
|
57500
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
57501
|
+
finalAnswerSubstantive: turn.finalAnswerSubstantive,
|
|
57502
|
+
enabled: FEED_REOPEN_AFTER_ACK_ENABLED
|
|
57503
|
+
});
|
|
57504
|
+
if (reopen.dropLabel)
|
|
57505
|
+
return;
|
|
57506
|
+
turn.finalAnswerDelivered = reopen.reset.finalAnswerDelivered;
|
|
57507
|
+
turn.activityMessageId = reopen.reset.activityMessageId;
|
|
57508
|
+
turn.activityLastSentRender = reopen.reset.activityLastSentRender;
|
|
57509
|
+
}
|
|
57264
57510
|
const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
|
|
57265
57511
|
if (rendered != null) {
|
|
57512
|
+
turn.lastToolLabelAt = Date.now();
|
|
57266
57513
|
turn.activityPendingRender = composeTurnActivity(turn) ?? rendered;
|
|
57267
57514
|
if (turn.activityInFlight == null) {
|
|
57268
57515
|
turn.activityInFlight = drainActivitySummary(turn);
|
|
@@ -57412,6 +57659,7 @@ function handleSessionEvent(ev) {
|
|
|
57412
57659
|
turn.answerStream = null;
|
|
57413
57660
|
streamFinalizedAsAnswer = true;
|
|
57414
57661
|
turn.finalAnswerDelivered = true;
|
|
57662
|
+
turn.finalAnswerSubstantive = true;
|
|
57415
57663
|
const oldStreamedMsgId = streamedMsgId;
|
|
57416
57664
|
(async () => {
|
|
57417
57665
|
let materializedId;
|
|
@@ -57538,6 +57786,7 @@ function handleSessionEvent(ev) {
|
|
|
57538
57786
|
}
|
|
57539
57787
|
}
|
|
57540
57788
|
turn.finalAnswerDelivered = true;
|
|
57789
|
+
turn.finalAnswerSubstantive = true;
|
|
57541
57790
|
const cardTakeover = progressDriver?.takeOverCard({
|
|
57542
57791
|
chatId: backstopChatId,
|
|
57543
57792
|
threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
|
|
@@ -58585,6 +58834,11 @@ ${preBlock(write.output)}`;
|
|
|
58585
58834
|
}
|
|
58586
58835
|
return;
|
|
58587
58836
|
}
|
|
58837
|
+
openObligationFromInbound(inboundMsg, {
|
|
58838
|
+
isSteering,
|
|
58839
|
+
isInterrupt: interrupt.isInterrupt,
|
|
58840
|
+
effectiveText
|
|
58841
|
+
});
|
|
58588
58842
|
if (decideInboundDelivery({
|
|
58589
58843
|
turnInFlight: turnInFlightAtReceipt,
|
|
58590
58844
|
isSteering,
|
|
@@ -81,3 +81,37 @@ export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
|
|
|
81
81
|
if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
|
|
82
82
|
return false
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure predicate: was this reply a *substantive* final answer (as opposed
|
|
87
|
+
* to a reply that is only "final" because it pinged)? `true` if EITHER:
|
|
88
|
+
*
|
|
89
|
+
* - `done === true` — a `stream_reply` terminal call closing the stream.
|
|
90
|
+
* - `text.length >= FINAL_ANSWER_MIN_CHARS` — a substantive-length answer.
|
|
91
|
+
*
|
|
92
|
+
* This is `isFinalAnswerReply` MINUS the notification-only path. The
|
|
93
|
+
* distinction matters for the feed-reopen-after-ack gate
|
|
94
|
+
* (`feed-reopen-gate.ts`): a *short pinging* reply ("on it, checking
|
|
95
|
+
* Brevo…") is classified final by `isFinalAnswerReply` (because it pings)
|
|
96
|
+
* yet is NOT substantive — it is an interim ACK. Only such an ack should
|
|
97
|
+
* cause the live activity feed to re-open when post-ack tool work arrives.
|
|
98
|
+
*
|
|
99
|
+
* A genuine final answer (long, or a stream `done: true`) followed by
|
|
100
|
+
* routine post-answer housekeeping (a memory write / TodoWrite / Bash —
|
|
101
|
+
* none of which are surface tools, so they reach the tool_label handler)
|
|
102
|
+
* must NOT re-open the feed and must NOT reset `finalAnswerDelivered`,
|
|
103
|
+
* otherwise the silent-end re-prompt would spuriously fire and the agent
|
|
104
|
+
* would re-deliver a duplicate / garbled answer.
|
|
105
|
+
*
|
|
106
|
+
* Residual: a reply that is genuinely the final answer yet is BOTH short
|
|
107
|
+
* (<200 chars) AND pinging (e.g. "Done!") is indistinguishable here from
|
|
108
|
+
* an ack, so post-answer housekeeping after it still re-opens the feed.
|
|
109
|
+
* That is much rarer than the housekeeping-after-long-answer case this
|
|
110
|
+
* predicate protects, and is kill-switchable via
|
|
111
|
+
* `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0`.
|
|
112
|
+
*/
|
|
113
|
+
export function isSubstantiveFinalReply(input: FinalAnswerReplyInput): boolean {
|
|
114
|
+
if (input.done === true) return true
|
|
115
|
+
if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed-reopen-after-ack gate (ack-first live-activity visibility).
|
|
3
|
+
*
|
|
4
|
+
* Pure decision: a `tool_label` arrived (the model is calling a tool, i.e.
|
|
5
|
+
* still WORKING) for a turn that has already been classified as having
|
|
6
|
+
* delivered its final answer. Should the gateway *re-open* the live
|
|
7
|
+
* activity feed for the post-ack work?
|
|
8
|
+
*
|
|
9
|
+
* ## The bug this closes
|
|
10
|
+
*
|
|
11
|
+
* In a forum supergroup one agent owns the whole supergroup — a single
|
|
12
|
+
* sequential `claude` CLI with a singleton `currentTurn`. When the model
|
|
13
|
+
* ACKS FIRST ("on it, checking Brevo…") and then does the actual work,
|
|
14
|
+
* that ack reply is classified as the *final answer* by
|
|
15
|
+
* `isFinalAnswerReply` (final-answer-detect.ts) whenever it pings
|
|
16
|
+
* (`!disable_notification`) OR is ≥200 chars — both common for a natural
|
|
17
|
+
* human-feel ack. That sets `turn.finalAnswerDelivered = true`, and the
|
|
18
|
+
* `tool_label` handler's `if (turn.finalAnswerDelivered) return` then
|
|
19
|
+
* drops EVERY subsequent tool label → the live feed goes dark for the
|
|
20
|
+
* real work. The agent looks silent after "On it".
|
|
21
|
+
*
|
|
22
|
+
* ## The decision
|
|
23
|
+
*
|
|
24
|
+
* A new tool label after `finalAnswerDelivered` means the earlier "final"
|
|
25
|
+
* reply MIGHT have been an interim ACK — the turn has NOT delivered its
|
|
26
|
+
* final answer if it is still doing tool work. So reclassify: re-open the
|
|
27
|
+
* feed. The caller then resets `turn.finalAnswerDelivered = false` and
|
|
28
|
+
* `turn.activityMessageId = null` (so a FRESH feed message opens below the
|
|
29
|
+
* ack) and proceeds with the normal append + drain. When the model later
|
|
30
|
+
* sends its REAL final answer, `executeReply` / `stream_reply` re-set
|
|
31
|
+
* `finalAnswerDelivered = true` via `isFinalAnswerReply` and the feed gates
|
|
32
|
+
* off correctly again.
|
|
33
|
+
*
|
|
34
|
+
* ## ACK-ONLY refinement
|
|
35
|
+
*
|
|
36
|
+
* `finalAnswerDelivered` latches true for BOTH a short pinging ack AND a
|
|
37
|
+
* substantive final answer — `isFinalAnswerReply` treats any pinging reply
|
|
38
|
+
* as "final". So reopening unconditionally is HARMFUL after a *genuine*
|
|
39
|
+
* final answer: routine post-answer housekeeping (a memory write /
|
|
40
|
+
* TodoWrite / Bash — none of these are surface tools, so they reach the
|
|
41
|
+
* tool_label handler) fires a tool label → an unconditional reopen would
|
|
42
|
+
* reset `finalAnswerDelivered=false` → the turn-end silent-end re-prompt
|
|
43
|
+
* (`if (turn.finalAnswerDelivered === false)`, NOT gated on zero-outbound)
|
|
44
|
+
* would FIRE → the agent re-delivers a DUPLICATE / garbled answer. Agents
|
|
45
|
+
* routinely write memory after answering, so this would be frequent.
|
|
46
|
+
*
|
|
47
|
+
* The fix: reopen ONLY when the prior reply that set `finalAnswerDelivered`
|
|
48
|
+
* was a SHORT ACK, not a substantive answer. The caller tracks this on the
|
|
49
|
+
* turn as `finalAnswerSubstantive` (set via `isSubstantiveFinalReply` at
|
|
50
|
+
* every site that sets `finalAnswerDelivered = true`). Reopen iff
|
|
51
|
+
* `finalAnswerDelivered && !finalAnswerSubstantive`. When the prior final
|
|
52
|
+
* was substantive, drop the label (legacy gate) — no reopen, no reset — so
|
|
53
|
+
* the silent-end re-prompt and the #2137 drain both see the genuine final
|
|
54
|
+
* correctly.
|
|
55
|
+
*
|
|
56
|
+
* ## Interactions (the reset is correct for all three consumers)
|
|
57
|
+
*
|
|
58
|
+
* 1. #2137 deliver-before-drain gate (`mayDrainBufferedInbound`): reads the
|
|
59
|
+
* ending turn's `finalAnswerDelivered` at turn-end. With the reset, an
|
|
60
|
+
* ack-first turn that is still working keeps it false → the next topic
|
|
61
|
+
* is correctly HELD (no mid-work cross-topic bleed); the bounded
|
|
62
|
+
* no-reply drain timer (~2.5s) still releases the queue if the turn
|
|
63
|
+
* truly ends without a final answer.
|
|
64
|
+
* 2. silent-end re-prompt: a turn that acks, works, then ends with NO real
|
|
65
|
+
* final answer keeps `finalAnswerDelivered=false` → the re-prompt fires
|
|
66
|
+
* (correct — the user got only an ack, no answer).
|
|
67
|
+
* 3. the feed gate itself — this module.
|
|
68
|
+
*
|
|
69
|
+
* ## Kill switch
|
|
70
|
+
*
|
|
71
|
+
* `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0` reverts to the legacy behaviour: a
|
|
72
|
+
* tool label after `finalAnswerDelivered` is dropped (`return`), and the
|
|
73
|
+
* post-ack feed stays dark. The kill switch is read by the CALLER, which
|
|
74
|
+
* passes `enabled` here.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
export interface FeedReopenInput {
|
|
78
|
+
/** Whether the turn has already been classified as having delivered its
|
|
79
|
+
* final answer (`turn.finalAnswerDelivered`). On an ack-first turn this
|
|
80
|
+
* is set true by the ack reply (it pinged or was ≥200 chars), even
|
|
81
|
+
* though the model is still working. */
|
|
82
|
+
finalAnswerDelivered: boolean
|
|
83
|
+
/** Whether the reply that set `finalAnswerDelivered` was a *substantive*
|
|
84
|
+
* final answer (stream `done`, or ≥200 chars) as opposed to a short
|
|
85
|
+
* pinging interim ACK (`turn.finalAnswerSubstantive`, set via
|
|
86
|
+
* `isSubstantiveFinalReply`). Only a short ACK should re-open the feed:
|
|
87
|
+
* reopening after a genuine final answer + post-answer housekeeping
|
|
88
|
+
* would spuriously trip the silent-end re-prompt → duplicate answer. */
|
|
89
|
+
finalAnswerSubstantive: boolean
|
|
90
|
+
/** Kill-switch state. When false the reopen behaviour is OFF and a tool
|
|
91
|
+
* label after `finalAnswerDelivered` is dropped (legacy). */
|
|
92
|
+
enabled: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Pure. Given a tool label has just arrived (the model is calling a tool,
|
|
97
|
+
* so it is still working), returns true when the live activity feed should
|
|
98
|
+
* be RE-OPENED for the post-ack work.
|
|
99
|
+
*
|
|
100
|
+
* - !finalAnswerDelivered → false: the feed was never gated off; the normal
|
|
101
|
+
* append/drain path applies (no reopen needed).
|
|
102
|
+
* - finalAnswerDelivered && finalAnswerSubstantive → false: the prior final
|
|
103
|
+
* was a genuine answer (not an ack). Post-answer housekeeping tool work
|
|
104
|
+
* must NOT reopen — keep the legacy gate so the silent-end re-prompt and
|
|
105
|
+
* the #2137 drain see the delivered final correctly.
|
|
106
|
+
* - finalAnswerDelivered && !enabled (kill switch off) → false: legacy
|
|
107
|
+
* behaviour, the label is dropped by the caller.
|
|
108
|
+
* - finalAnswerDelivered && !finalAnswerSubstantive && enabled → true: the
|
|
109
|
+
* "final" reply was a short interim ack; re-open the feed.
|
|
110
|
+
*/
|
|
111
|
+
export function shouldReopenFeedAfterAck(input: FeedReopenInput): boolean {
|
|
112
|
+
if (!input.finalAnswerDelivered) return false
|
|
113
|
+
if (input.finalAnswerSubstantive) return false
|
|
114
|
+
return input.enabled === true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The feed-state fields the caller mutates on reopen. */
|
|
118
|
+
export interface FeedReopenState {
|
|
119
|
+
finalAnswerDelivered: boolean
|
|
120
|
+
activityMessageId: number | null
|
|
121
|
+
activityLastSentRender: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The branch outcome the tool_label handler takes for a finalAnswer-
|
|
125
|
+
* delivered turn: either drop the label (legacy `return`) or reopen the
|
|
126
|
+
* feed with the given reset state. */
|
|
127
|
+
export interface FeedReopenOutcome {
|
|
128
|
+
/** True → the handler returns early (legacy: label dropped, feed dark). */
|
|
129
|
+
dropLabel: boolean
|
|
130
|
+
/** When dropLabel is false, the new feed-state fields to write on `turn`
|
|
131
|
+
* before the normal append/drain proceeds. */
|
|
132
|
+
reset?: FeedReopenState
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pure. The complete tool_label decision for a turn already marked
|
|
137
|
+
* finalAnswerDelivered. Mirrors exactly what the gateway handler does:
|
|
138
|
+
* - reopen disabled / substantive final / not applicable → drop the label
|
|
139
|
+
* (legacy `return`); the genuine final answer's gate is preserved.
|
|
140
|
+
* - reopen → reclassify the interim ack: finalAnswerDelivered back to
|
|
141
|
+
* false (the turn has NOT delivered its final answer while still doing
|
|
142
|
+
* tool work), activityMessageId cleared so a FRESH feed message opens
|
|
143
|
+
* below the ack, and activityLastSentRender cleared so the drain loop's
|
|
144
|
+
* `pending !== lastSent` guard never mistakes the fresh render for an
|
|
145
|
+
* already-sent one.
|
|
146
|
+
*
|
|
147
|
+
* Returning the deltas (rather than mutating) keeps the decision unit-
|
|
148
|
+
* testable; the handler applies them to the live `turn` atom.
|
|
149
|
+
*/
|
|
150
|
+
export function decideFeedReopen(input: FeedReopenInput): FeedReopenOutcome {
|
|
151
|
+
if (!shouldReopenFeedAfterAck(input)) {
|
|
152
|
+
return { dropLabel: true }
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
dropLabel: false,
|
|
156
|
+
reset: {
|
|
157
|
+
finalAnswerDelivered: false,
|
|
158
|
+
activityMessageId: null,
|
|
159
|
+
activityLastSentRender: null,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|