switchroom 0.13.11 → 0.13.13
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/cli/switchroom.js +60 -5
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +34 -52
- package/telegram-plugin/final-answer-detect.ts +83 -0
- package/telegram-plugin/gateway/gateway.ts +112 -58
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +17 -5
- package/telegram-plugin/silent-end.ts +37 -11
- package/telegram-plugin/subagent-watcher.ts +13 -20
- package/telegram-plugin/tests/final-answer-detect.test.ts +89 -0
- package/telegram-plugin/tests/fleet-state-watcher.test.ts +0 -1
- package/telegram-plugin/tests/silent-end.test.ts +118 -0
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +1 -3
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +0 -1
- package/telegram-plugin/tests/subagent-watcher-parent-marker.test.ts +0 -1
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +1 -4
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +0 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +15 -5
- package/telegram-plugin/tests/turn-flush-safety.test.ts +29 -81
- package/telegram-plugin/turn-flush-safety.ts +23 -53
package/dist/cli/switchroom.js
CHANGED
|
@@ -26509,17 +26509,34 @@ var init_wait = __esm(() => {
|
|
|
26509
26509
|
// src/cli/drive.ts
|
|
26510
26510
|
var exports_drive = {};
|
|
26511
26511
|
__export(exports_drive, {
|
|
26512
|
+
workspaceScopesForTier: () => workspaceScopesForTier,
|
|
26513
|
+
selectGoogleWorkspaceScopes: () => selectGoogleWorkspaceScopes,
|
|
26512
26514
|
selectDriveAccountScopes: () => selectDriveAccountScopes,
|
|
26513
26515
|
runDriveOAuthFlow: () => runDriveOAuthFlow,
|
|
26514
26516
|
registerDriveCommand: () => registerDriveCommand,
|
|
26515
26517
|
__test: () => __test,
|
|
26518
|
+
GOOGLE_SLIDES_SCOPE: () => GOOGLE_SLIDES_SCOPE,
|
|
26519
|
+
GOOGLE_SHEETS_SCOPE: () => GOOGLE_SHEETS_SCOPE,
|
|
26520
|
+
GOOGLE_DOCS_SCOPE: () => GOOGLE_DOCS_SCOPE,
|
|
26516
26521
|
DRIVE_WRITE_SCOPES: () => DRIVE_WRITE_SCOPES,
|
|
26517
26522
|
DRIVE_READONLY_SCOPES: () => DRIVE_READONLY_SCOPES
|
|
26518
26523
|
});
|
|
26519
26524
|
import { createInterface as createInterface2 } from "node:readline";
|
|
26525
|
+
function workspaceScopesForTier(tier) {
|
|
26526
|
+
const docScopes = [GOOGLE_DOCS_SCOPE, GOOGLE_SHEETS_SCOPE];
|
|
26527
|
+
if (tier === "extended" || tier === "complete") {
|
|
26528
|
+
return [...docScopes, GOOGLE_SLIDES_SCOPE];
|
|
26529
|
+
}
|
|
26530
|
+
return docScopes;
|
|
26531
|
+
}
|
|
26520
26532
|
function selectDriveAccountScopes(write) {
|
|
26521
26533
|
return write ? DRIVE_WRITE_SCOPES : DRIVE_READONLY_SCOPES;
|
|
26522
26534
|
}
|
|
26535
|
+
function selectGoogleWorkspaceScopes(opts) {
|
|
26536
|
+
const base = selectDriveAccountScopes(opts.write);
|
|
26537
|
+
const workspace = workspaceScopesForTier(opts.tier ?? "core");
|
|
26538
|
+
return [...new Set([...base, ...workspace])];
|
|
26539
|
+
}
|
|
26523
26540
|
function getVaultPath(configPath) {
|
|
26524
26541
|
try {
|
|
26525
26542
|
const config = loadConfig(configPath);
|
|
@@ -26932,7 +26949,7 @@ function registerDriveCommand(program3, deps = {}) {
|
|
|
26932
26949
|
await runDisconnect({ agentName: agent }, deps);
|
|
26933
26950
|
});
|
|
26934
26951
|
}
|
|
26935
|
-
var EXIT_OK = 0, EXIT_DENIED = 1, EXIT_TIMEOUT = 2, EXIT_RATE_LIMITED = 3, EXIT_ERROR = 4, EXIT_ABORTED = 130, DRIVE_READONLY_SCOPES, DRIVE_WRITE_SCOPES, DEFAULT_SCOPES, __test;
|
|
26952
|
+
var EXIT_OK = 0, EXIT_DENIED = 1, EXIT_TIMEOUT = 2, EXIT_RATE_LIMITED = 3, EXIT_ERROR = 4, EXIT_ABORTED = 130, DRIVE_READONLY_SCOPES, DRIVE_WRITE_SCOPES, DEFAULT_SCOPES, GOOGLE_DOCS_SCOPE = "https://www.googleapis.com/auth/documents", GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets", GOOGLE_SLIDES_SCOPE = "https://www.googleapis.com/auth/presentations", __test;
|
|
26936
26953
|
var init_drive = __esm(() => {
|
|
26937
26954
|
init_source();
|
|
26938
26955
|
init_loader();
|
|
@@ -47314,8 +47331,8 @@ var {
|
|
|
47314
47331
|
} = import__.default;
|
|
47315
47332
|
|
|
47316
47333
|
// src/build-info.ts
|
|
47317
|
-
var VERSION = "0.13.
|
|
47318
|
-
var COMMIT_SHA = "
|
|
47334
|
+
var VERSION = "0.13.13";
|
|
47335
|
+
var COMMIT_SHA = "dc583d57";
|
|
47319
47336
|
|
|
47320
47337
|
// src/cli/agent.ts
|
|
47321
47338
|
init_source();
|
|
@@ -54578,7 +54595,7 @@ function registerAccountAdd(accountParent) {
|
|
|
54578
54595
|
accountParent.command("add <account>").description("Mint a Google OAuth refresh token for <account> and register it with the auth-broker. For Drive scopes the effective flow is desktop-loopback (device-code returns invalid_scope for Drive; OOB is retired) \u2014 use a Desktop OAuth client; on a headless host complete the browser step over an SSH port-forward. Add --write for create/edit (drive.file); default is read-only.").option("--replace", "Overwrite existing credentials for <account> (default refuses if account already registered)", false).option("--write", "Request Drive WRITE scope (drive.file: create + edit app-created files) in addition to read. Default is read-only \u2014 a read grant never silently becomes a write grant. Re-consent an existing account with `--replace --write`.", false).action(withConfigError(async (account, opts) => {
|
|
54579
54596
|
const normalizedAccount = validateAndNormalizeAccountEmail(account);
|
|
54580
54597
|
const [
|
|
54581
|
-
{ runDriveOAuthFlow: runDriveOAuthFlow2,
|
|
54598
|
+
{ runDriveOAuthFlow: runDriveOAuthFlow2, selectGoogleWorkspaceScopes: selectGoogleWorkspaceScopes2 },
|
|
54582
54599
|
{ selectInitialTier: selectInitialTier2 },
|
|
54583
54600
|
{ brokerCall: brokerCall2 },
|
|
54584
54601
|
{ loadConfig: loadConfig2, resolvePath: resolvePath2 },
|
|
@@ -54647,7 +54664,11 @@ function registerAccountAdd(accountParent) {
|
|
|
54647
54664
|
clientIdRaw = await resolveRef(clientIdRaw, "google_client_id");
|
|
54648
54665
|
clientSecretRaw = await resolveRef(clientSecretRaw, "google_client_secret");
|
|
54649
54666
|
}
|
|
54650
|
-
const
|
|
54667
|
+
const tier = gw.tier ?? "core";
|
|
54668
|
+
const accountScopes = selectGoogleWorkspaceScopes2({
|
|
54669
|
+
write: opts.write ?? false,
|
|
54670
|
+
tier
|
|
54671
|
+
});
|
|
54651
54672
|
const oauthCfg = {
|
|
54652
54673
|
client_id: clientIdRaw,
|
|
54653
54674
|
client_secret: clientSecretRaw,
|
|
@@ -54656,6 +54677,9 @@ function registerAccountAdd(accountParent) {
|
|
|
54656
54677
|
if (opts.write) {
|
|
54657
54678
|
console.log(source_default.yellow(" Requesting Drive WRITE scope (drive.file \u2014 create/edit app-created files)."));
|
|
54658
54679
|
}
|
|
54680
|
+
console.log(source_default.gray(` Workspace tier: ${tier} \u2014 requesting Docs + Sheets` + (tier === "extended" || tier === "complete" ? " + Slides" : "") + " API scopes so the tier's tools can authenticate."));
|
|
54681
|
+
console.log(source_default.gray(` Changing the tier later requires re-running this command
|
|
54682
|
+
` + " (`--replace`) \u2014 OAuth scopes are fixed at consent time."));
|
|
54659
54683
|
const oauthEnv = {
|
|
54660
54684
|
DISPLAY: process.env.DISPLAY,
|
|
54661
54685
|
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
|
|
@@ -73048,6 +73072,29 @@ function buildSeedCredentials(input) {
|
|
|
73048
73072
|
}
|
|
73049
73073
|
var AIOFILE_PIN = "aiofile==3.10.2";
|
|
73050
73074
|
var AIOFILE_PKG = AIOFILE_PIN.split("==")[0];
|
|
73075
|
+
function requiredWorkspaceScopesForTier(tier) {
|
|
73076
|
+
const docs = [
|
|
73077
|
+
"https://www.googleapis.com/auth/documents",
|
|
73078
|
+
"https://www.googleapis.com/auth/spreadsheets"
|
|
73079
|
+
];
|
|
73080
|
+
if (tier === "extended" || tier === "complete") {
|
|
73081
|
+
return [...docs, "https://www.googleapis.com/auth/presentations"];
|
|
73082
|
+
}
|
|
73083
|
+
return docs;
|
|
73084
|
+
}
|
|
73085
|
+
function findMissingWorkspaceScopes(seedScope, tier) {
|
|
73086
|
+
const have = new Set(seedScope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
|
|
73087
|
+
return requiredWorkspaceScopesForTier(tier).filter((s) => !have.has(s));
|
|
73088
|
+
}
|
|
73089
|
+
var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
73090
|
+
function buildMissingScopeWarning(missing, tier, accountEmail, hasWriteScope) {
|
|
73091
|
+
const short = missing.map((s) => s.replace(/^https:\/\/www\.googleapis\.com\/auth\//, "")).join(", ");
|
|
73092
|
+
return `drive-mcp-launcher: WARNING \u2014 the Google account '${accountEmail}' was ` + `consented WITHOUT the scope(s) needed for tier '${tier ?? "core"}': ` + `${short}.
|
|
73093
|
+
` + ` The matching MCP tools (Docs / Sheets / Slides create+edit) will FAIL ` + `to authenticate. OAuth scopes are fixed at consent time \u2014 re-run on the ` + `host to re-mint the token with the correct scopes:
|
|
73094
|
+
` + ` switchroom auth google account add ${accountEmail} --replace` + `${hasWriteScope ? " --write" : ""}
|
|
73095
|
+
` + ` (scopes are derived from \`google_workspace.tier\` \u2014 set the tier ` + `before re-running${hasWriteScope ? "; --write preserves the existing " + "Drive write capability" : ""}). Drive read/file tools are unaffected.
|
|
73096
|
+
`;
|
|
73097
|
+
}
|
|
73051
73098
|
function buildUvxArgs(tier) {
|
|
73052
73099
|
const args = [
|
|
73053
73100
|
"--from",
|
|
@@ -73072,6 +73119,9 @@ function buildChildEnv(baseEnv, credentialsDir, accountEmail) {
|
|
|
73072
73119
|
delete env2.WORKSPACE_MCP_STATELESS_MODE;
|
|
73073
73120
|
delete env2.GOOGLE_APPLICATION_CREDENTIALS;
|
|
73074
73121
|
delete env2.WORKSPACE_MCP_SERVICE_ACCOUNT_FILE;
|
|
73122
|
+
if (!env2.WORKSPACE_MCP_PORT) {
|
|
73123
|
+
env2.WORKSPACE_MCP_PORT = env2.SWITCHROOM_GDRIVE_MCP_PORT ?? "8631";
|
|
73124
|
+
}
|
|
73075
73125
|
return env2;
|
|
73076
73126
|
}
|
|
73077
73127
|
function classifyRootSchema(schema) {
|
|
@@ -73288,6 +73338,11 @@ async function runDriveMcpLauncher(opts) {
|
|
|
73288
73338
|
process.exit(1);
|
|
73289
73339
|
}
|
|
73290
73340
|
const tier = opts.tier ?? configSecrets.tier;
|
|
73341
|
+
const missingScopes = findMissingWorkspaceScopes(brokerCreds.scope, tier);
|
|
73342
|
+
if (missingScopes.length > 0) {
|
|
73343
|
+
const hasWriteScope = brokerCreds.scope.split(/\s+/).map((s) => s.trim()).includes(DRIVE_FILE_SCOPE);
|
|
73344
|
+
process.stderr.write(buildMissingScopeWarning(missingScopes, tier, brokerCreds.accountEmail, hasWriteScope));
|
|
73345
|
+
}
|
|
73291
73346
|
const args = buildUvxArgs(tier);
|
|
73292
73347
|
const env2 = buildChildEnv(process.env, credentialsDir, brokerCreds.accountEmail);
|
|
73293
73348
|
const { spawn: spawn5 } = await import("node:child_process");
|
package/package.json
CHANGED
|
@@ -37383,6 +37383,19 @@ function recordSilentTurnEnd(args, deps) {
|
|
|
37383
37383
|
writeSilentEndState(args, deps);
|
|
37384
37384
|
return { exhausted: false };
|
|
37385
37385
|
}
|
|
37386
|
+
var recordUndeliveredTurnEnd = recordSilentTurnEnd;
|
|
37387
|
+
|
|
37388
|
+
// final-answer-detect.ts
|
|
37389
|
+
var FINAL_ANSWER_MIN_CHARS = 200;
|
|
37390
|
+
function isFinalAnswerReply(input) {
|
|
37391
|
+
if (input.done === true)
|
|
37392
|
+
return true;
|
|
37393
|
+
if (!input.disableNotification)
|
|
37394
|
+
return true;
|
|
37395
|
+
if (input.text.length >= FINAL_ANSWER_MIN_CHARS)
|
|
37396
|
+
return true;
|
|
37397
|
+
return false;
|
|
37398
|
+
}
|
|
37386
37399
|
|
|
37387
37400
|
// turn-flush-safety.ts
|
|
37388
37401
|
var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
|
|
@@ -40593,28 +40606,12 @@ function isSilentFlushMarker2(text) {
|
|
|
40593
40606
|
}
|
|
40594
40607
|
return SILENT_MARKERS2.has(trimmed.toUpperCase());
|
|
40595
40608
|
}
|
|
40596
|
-
var REPLY_CALLED_TAIL_MIN_CHARS = 40;
|
|
40597
40609
|
function decideTurnFlush(input) {
|
|
40598
40610
|
const flushEnabled = input.flushEnabled !== false;
|
|
40599
40611
|
if (!flushEnabled)
|
|
40600
40612
|
return { kind: "skip", reason: "flag-disabled" };
|
|
40601
|
-
if (input.replyCalled)
|
|
40602
|
-
|
|
40603
|
-
const tail = input.capturedText.slice(tailIdx).join(`
|
|
40604
|
-
`).trim();
|
|
40605
|
-
const minChars = input.replyCalledTailMinChars ?? REPLY_CALLED_TAIL_MIN_CHARS;
|
|
40606
|
-
if (tail.length === 0) {
|
|
40607
|
-
return { kind: "skip", reason: "reply-called" };
|
|
40608
|
-
}
|
|
40609
|
-
if (tail.length < minChars) {
|
|
40610
|
-
return { kind: "skip", reason: "reply-called-no-new-text" };
|
|
40611
|
-
}
|
|
40612
|
-
if (input.chatId == null)
|
|
40613
|
-
return { kind: "skip", reason: "no-inbound-chat" };
|
|
40614
|
-
if (isSilentFlushMarker2(tail))
|
|
40615
|
-
return { kind: "skip", reason: "silent-marker" };
|
|
40616
|
-
return { kind: "flush", text: tail };
|
|
40617
|
-
}
|
|
40613
|
+
if (input.replyCalled)
|
|
40614
|
+
return { kind: "skip", reason: "reply-called" };
|
|
40618
40615
|
if (input.chatId == null)
|
|
40619
40616
|
return { kind: "skip", reason: "no-inbound-chat" };
|
|
40620
40617
|
const joined = input.capturedText.join(`
|
|
@@ -46654,14 +46651,6 @@ function startSubagentWatcher(config) {
|
|
|
46654
46651
|
return;
|
|
46655
46652
|
if (entry.state === "done" && !entry.completionNotified) {
|
|
46656
46653
|
entry.completionNotified = true;
|
|
46657
|
-
const desc = escapeHtml8(truncate3(entry.description, 80));
|
|
46658
|
-
const summary = entry.lastSummaryLine ? ` \u2014 ${escapeHtml8(truncate3(entry.lastSummaryLine, 120))}` : "";
|
|
46659
|
-
const tools = entry.toolCount > 0 ? ` (${entry.toolCount} tools)` : "";
|
|
46660
|
-
try {
|
|
46661
|
-
config.sendNotification(`\u2713 Worker done: ${desc}${tools}${summary}`);
|
|
46662
|
-
} catch (err) {
|
|
46663
|
-
log?.(`subagent-watcher: completion notification error: ${err.message}`);
|
|
46664
|
-
}
|
|
46665
46654
|
if (config.onFinish) {
|
|
46666
46655
|
try {
|
|
46667
46656
|
config.onFinish({
|
|
@@ -48027,9 +48016,9 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48027
48016
|
}
|
|
48028
48017
|
|
|
48029
48018
|
// ../src/build-info.ts
|
|
48030
|
-
var VERSION = "0.13.
|
|
48031
|
-
var COMMIT_SHA = "
|
|
48032
|
-
var COMMIT_DATE = "2026-05-
|
|
48019
|
+
var VERSION = "0.13.13";
|
|
48020
|
+
var COMMIT_SHA = "dc583d57";
|
|
48021
|
+
var COMMIT_DATE = "2026-05-22T22:02:14+10:00";
|
|
48033
48022
|
var LATEST_PR = null;
|
|
48034
48023
|
var COMMITS_AHEAD_OF_TAG = 3;
|
|
48035
48024
|
|
|
@@ -50446,6 +50435,7 @@ async function executeUpdateChecklist(args) {
|
|
|
50446
50435
|
return { content: [{ type: "text", text: `checklist updated (id: ${message_id})` }] };
|
|
50447
50436
|
}
|
|
50448
50437
|
async function executeReply(args) {
|
|
50438
|
+
const turn = currentTurn;
|
|
50449
50439
|
const chat_id = args.chat_id;
|
|
50450
50440
|
if (!chat_id)
|
|
50451
50441
|
throw new Error("reply: chat_id is required");
|
|
@@ -50731,6 +50721,9 @@ ${url}`;
|
|
|
50731
50721
|
process.stderr.write(`telegram gateway: reply: endStatusReaction hook threw: ${err}
|
|
50732
50722
|
`);
|
|
50733
50723
|
}
|
|
50724
|
+
if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
|
|
50725
|
+
turn.finalAnswerDelivered = true;
|
|
50726
|
+
}
|
|
50734
50727
|
}
|
|
50735
50728
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
|
|
50736
50729
|
`);
|
|
@@ -50740,6 +50733,7 @@ ${url}`;
|
|
|
50740
50733
|
return { content: [{ type: "text", text: result }] };
|
|
50741
50734
|
}
|
|
50742
50735
|
async function executeStreamReply(args) {
|
|
50736
|
+
const turn = currentTurn;
|
|
50743
50737
|
if (!args.chat_id)
|
|
50744
50738
|
throw new Error("stream_reply: chat_id is required");
|
|
50745
50739
|
if (args.text == null || args.text === "")
|
|
@@ -50842,6 +50836,13 @@ async function executeStreamReply(args) {
|
|
|
50842
50836
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
50843
50837
|
outboundDedup.record(sChatId, sThreadId, args.text, Date.now());
|
|
50844
50838
|
}
|
|
50839
|
+
if (turn != null && isFinalAnswerReply({
|
|
50840
|
+
text: args.text ?? "",
|
|
50841
|
+
disableNotification: args.disable_notification === true,
|
|
50842
|
+
done: args.done === true
|
|
50843
|
+
})) {
|
|
50844
|
+
turn.finalAnswerDelivered = true;
|
|
50845
|
+
}
|
|
50845
50846
|
return { content: [{ type: "text", text: `${result.status} (id: ${result.messageId ?? "pending"})` }] };
|
|
50846
50847
|
}
|
|
50847
50848
|
async function executeProgressUpdate(args) {
|
|
@@ -51593,8 +51594,8 @@ function handleSessionEvent(ev) {
|
|
|
51593
51594
|
startedAt,
|
|
51594
51595
|
gatewayReceiveAt: startedAt,
|
|
51595
51596
|
replyCalled: false,
|
|
51597
|
+
finalAnswerDelivered: false,
|
|
51596
51598
|
capturedText: [],
|
|
51597
|
-
capturedTextLenAtLastReply: 0,
|
|
51598
51599
|
orphanedReplyTimeoutId: null,
|
|
51599
51600
|
registryKey: null,
|
|
51600
51601
|
lastAssistantMsgId: null,
|
|
@@ -51659,7 +51660,6 @@ function handleSessionEvent(ev) {
|
|
|
51659
51660
|
const name = ev.toolName;
|
|
51660
51661
|
if (isTelegramReplyTool(name)) {
|
|
51661
51662
|
turn.replyCalled = true;
|
|
51662
|
-
turn.capturedTextLenAtLastReply = turn.capturedText.length;
|
|
51663
51663
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
51664
51664
|
clearTimeout(turn.orphanedReplyTimeoutId);
|
|
51665
51665
|
turn.orphanedReplyTimeoutId = null;
|
|
@@ -51822,13 +51822,8 @@ function handleSessionEvent(ev) {
|
|
|
51822
51822
|
chatId: turn.sessionChatId,
|
|
51823
51823
|
replyCalled: turn.replyCalled,
|
|
51824
51824
|
capturedText: turn.capturedText,
|
|
51825
|
-
capturedTextLenAtLastReply: turn.capturedTextLenAtLastReply,
|
|
51826
51825
|
flushEnabled: TURN_FLUSH_SAFETY_ENABLED
|
|
51827
51826
|
});
|
|
51828
|
-
if (flushDecision.kind === "flush" && turn.replyCalled) {
|
|
51829
|
-
process.stderr.write(`telegram gateway: WARN post-reply-tail flush (#1291) \u2014 model emitted ${flushDecision.text.length} chars after a prior reply call without a follow-up reply tool chat=${chatId} turnStartedAt=${turn.startedAt}
|
|
51830
|
-
`);
|
|
51831
|
-
}
|
|
51832
51827
|
if (flushDecision.kind === "skip" && flushDecision.reason !== "reply-called") {
|
|
51833
51828
|
process.stderr.write(`telegram gateway: turn-flush skipped \u2014 reason=${flushDecision.reason}
|
|
51834
51829
|
`);
|
|
@@ -51899,6 +51894,7 @@ function handleSessionEvent(ev) {
|
|
|
51899
51894
|
const backstopChatId = chatId;
|
|
51900
51895
|
const backstopThreadId = threadId;
|
|
51901
51896
|
const backstopCtrl = ctrl;
|
|
51897
|
+
turn.finalAnswerDelivered = true;
|
|
51902
51898
|
const cardTakeover = progressDriver?.takeOverCard({
|
|
51903
51899
|
chatId: backstopChatId,
|
|
51904
51900
|
threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
|
|
@@ -52037,8 +52033,8 @@ function handleSessionEvent(ev) {
|
|
|
52037
52033
|
longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
|
|
52038
52034
|
ended_via: outboundMetrics.outboundCount > 0 ? "reply" : "silent"
|
|
52039
52035
|
});
|
|
52040
|
-
if (
|
|
52041
|
-
const silentEnd =
|
|
52036
|
+
if (turn.finalAnswerDelivered === false) {
|
|
52037
|
+
const silentEnd = recordUndeliveredTurnEnd({
|
|
52042
52038
|
chatId,
|
|
52043
52039
|
threadId: threadId ?? null,
|
|
52044
52040
|
turnKey: tKey
|
|
@@ -57308,20 +57304,6 @@ var didOneTimeSetup = false;
|
|
|
57308
57304
|
agentCwd: watcherAgentDir,
|
|
57309
57305
|
db: turnsDb,
|
|
57310
57306
|
parentStateDir: STATE_DIR,
|
|
57311
|
-
sendNotification: (text) => {
|
|
57312
|
-
const ownerChatId = loadAccess().allowFrom[0];
|
|
57313
|
-
if (!ownerChatId)
|
|
57314
|
-
return;
|
|
57315
|
-
swallowingApiCall(() => lockedBot.api.sendMessage(ownerChatId, text, {
|
|
57316
|
-
parse_mode: "HTML",
|
|
57317
|
-
link_preview_options: { is_disabled: true },
|
|
57318
|
-
...TOPIC_ID != null ? { message_thread_id: TOPIC_ID } : {}
|
|
57319
|
-
}), {
|
|
57320
|
-
chat_id: ownerChatId,
|
|
57321
|
-
verb: "subagent-watcher-notification",
|
|
57322
|
-
...TOPIC_ID != null ? { threadId: TOPIC_ID } : {}
|
|
57323
|
-
});
|
|
57324
|
-
},
|
|
57325
57307
|
log: (msg) => process.stderr.write(`telegram gateway: ${msg}
|
|
57326
57308
|
`),
|
|
57327
57309
|
onStall: (agentId, idleMs, description) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* final-answer-detect.ts — #1664 "did this reply deliver the final answer?"
|
|
3
|
+
*
|
|
4
|
+
* Background. An agent often ends a turn with its real answer as plain
|
|
5
|
+
* assistant transcript text instead of a `reply` / `stream_reply` tool
|
|
6
|
+
* call. The gateway renders that transcript as a live Telegram draft
|
|
7
|
+
* (`sendMessageDraft`) and, at turn_end, retracts the draft — so the
|
|
8
|
+
* answer is never finalized and the user watches it vanish (#1664).
|
|
9
|
+
*
|
|
10
|
+
* The gateway's `replyCalled` flag flips on the FIRST reply / stream_reply
|
|
11
|
+
* tool use and stays true for the rest of the turn. It cannot distinguish
|
|
12
|
+
* "the model sent an interim ack" from "the model sent its real answer" —
|
|
13
|
+
* both set `replyCalled`. The silent-end re-prompt safety net needs a
|
|
14
|
+
* finer signal: it must engage when a turn ended with only an interim
|
|
15
|
+
* ack and the real answer left as transcript text.
|
|
16
|
+
*
|
|
17
|
+
* This module is that finer signal — a pure predicate the gateway calls
|
|
18
|
+
* for each reply that lands. A turn whose every reply was classified
|
|
19
|
+
* "interim" ends with `CurrentTurn.finalAnswerDelivered === false`, which
|
|
20
|
+
* triggers the re-prompt; a turn with at least one "final" reply does not.
|
|
21
|
+
*
|
|
22
|
+
* Keeping the policy in one unit-testable function is the point — the
|
|
23
|
+
* gateway is a multi-thousand-line module that's expensive to import in a
|
|
24
|
+
* test. See `telegram-plugin/tests/final-answer-detect.test.ts`.
|
|
25
|
+
*
|
|
26
|
+
* The fix re-prompts the model; it never materializes the draft into a
|
|
27
|
+
* message (`reference/principles.md`: the model communicates, the
|
|
28
|
+
* framework is the safety net). So a false "interim" classification is
|
|
29
|
+
* cheap (one extra re-prompt) and a false "final" classification is the
|
|
30
|
+
* dangerous one (a real answer left undelivered) — the length backstop
|
|
31
|
+
* exists to make the dangerous miss rare.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Length backstop for the final-answer classification. The pacing
|
|
36
|
+
* contract (`docs/telegram-style.md`) says interim updates pass
|
|
37
|
+
* `disable_notification: true` and the final answer omits it — so a
|
|
38
|
+
* notification-bearing reply is the primary "final answer" signal. But a
|
|
39
|
+
* model that mis-marks a genuinely substantive reply as interim
|
|
40
|
+
* (`disable_notification: true` on what is really the answer) would
|
|
41
|
+
* otherwise leave the turn looking undelivered. Any reply at or above
|
|
42
|
+
* this many characters therefore ALSO counts as the final answer,
|
|
43
|
+
* regardless of the notification flag. 200 chars is comfortably longer
|
|
44
|
+
* than a typical interim ack ("on it", "looking into that…", "give me a
|
|
45
|
+
* sec") and short enough that a real answer almost always clears it.
|
|
46
|
+
*/
|
|
47
|
+
export const FINAL_ANSWER_MIN_CHARS = 200
|
|
48
|
+
|
|
49
|
+
export interface FinalAnswerReplyInput {
|
|
50
|
+
/** The reply text the model sent (the model's own answer text, before
|
|
51
|
+
* any HTML conversion or Telegraph-link substitution). */
|
|
52
|
+
text: string
|
|
53
|
+
/** The `disable_notification` argument the reply tool was called with.
|
|
54
|
+
* `true` is the pacing contract's "interim update" marker; the final
|
|
55
|
+
* answer omits it (effectively `false`). */
|
|
56
|
+
disableNotification: boolean
|
|
57
|
+
/** For `stream_reply` only: whether this call carried `done: true`. A
|
|
58
|
+
* `done: true` call explicitly closes the stream and IS the final
|
|
59
|
+
* answer by definition. Pass `false` for the plain `reply` tool. */
|
|
60
|
+
done?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pure predicate: did this reply deliver the turn's final answer (as
|
|
65
|
+
* opposed to an interim ack)? `true` if ANY of:
|
|
66
|
+
*
|
|
67
|
+
* - `done === true` — a `stream_reply` terminal call; the model
|
|
68
|
+
* explicitly closed the stream, so this is the final answer.
|
|
69
|
+
* - `disableNotification === false` — the pacing contract's explicit
|
|
70
|
+
* "final answer" signal (interim updates set it `true`).
|
|
71
|
+
* - `text.length >= FINAL_ANSWER_MIN_CHARS` — the length backstop for
|
|
72
|
+
* a substantive answer mis-marked as interim.
|
|
73
|
+
*
|
|
74
|
+
* The gateway ORs this across every reply in a turn; once one reply
|
|
75
|
+
* qualifies, `CurrentTurn.finalAnswerDelivered` latches true and the
|
|
76
|
+
* silent-end re-prompt will not engage for that turn.
|
|
77
|
+
*/
|
|
78
|
+
export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
|
|
79
|
+
if (input.done === true) return true
|
|
80
|
+
if (!input.disableNotification) return true
|
|
81
|
+
if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
|
|
82
|
+
return false
|
|
83
|
+
}
|