@chrisromp/copilot-bridge 0.10.0 → 0.12.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/README.md +4 -0
- package/config.sample.json +20 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -1
- package/dist/core/bridge-docs.d.ts +1 -1
- package/dist/core/bridge-docs.d.ts.map +1 -1
- package/dist/core/bridge-docs.js +133 -1
- package/dist/core/bridge-docs.js.map +1 -1
- package/dist/core/bridge.d.ts +19 -2
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +29 -5
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/command-handler.d.ts +16 -4
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +258 -51
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/model-fallback.d.ts +2 -2
- package/dist/core/model-fallback.d.ts.map +1 -1
- package/dist/core/model-fallback.js +11 -4
- package/dist/core/model-fallback.js.map +1 -1
- package/dist/core/quiet-mode.js +1 -1
- package/dist/core/session-manager.d.ts +40 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +316 -36
- package/dist/core/session-manager.js.map +1 -1
- package/dist/index.js +243 -48
- package/dist/index.js.map +1 -1
- package/dist/state/store.d.ts +1 -0
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +13 -2
- package/dist/state/store.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/templates/admin/AGENTS.md +38 -38
- package/templates/agents/AGENTS.md +0 -49
package/dist/index.js
CHANGED
|
@@ -19,6 +19,11 @@ import path from 'node:path';
|
|
|
19
19
|
const log = createLogger('bridge');
|
|
20
20
|
// Active streaming responses, keyed by channelId
|
|
21
21
|
const activeStreams = new Map(); // channelId → streamKey
|
|
22
|
+
// Channels where the no_reply tool was called — used to suppress the SDK's
|
|
23
|
+
// second agentic turn (which always fires after a tool call).
|
|
24
|
+
const noReplyChannels = new Set();
|
|
25
|
+
// Tracks whether content was emitted after no_reply (second turn succeeded)
|
|
26
|
+
const noReplyHadContent = new Set();
|
|
22
27
|
// Preserve thread context across turn_end stream finalization so auto-started
|
|
23
28
|
// streams stay in the same thread.
|
|
24
29
|
const channelThreadRoots = new Map(); // channelId → threadRootId
|
|
@@ -32,7 +37,7 @@ const channelLocks = new Map();
|
|
|
32
37
|
// Per-channel promise chain to serialize SESSION EVENT handling (prevents race on auto-start)
|
|
33
38
|
const eventLocks = new Map();
|
|
34
39
|
// Channels in "quiet mode" — all streaming output suppressed until we determine
|
|
35
|
-
// whether the response is NO_REPLY. Used for
|
|
40
|
+
// whether the response is NO_REPLY. Used for scheduled tasks and silent cron jobs.
|
|
36
41
|
// State managed in src/core/quiet-mode.ts
|
|
37
42
|
// Bot adapters keyed by "platform:botName" for channel→adapter lookup
|
|
38
43
|
const botAdapters = new Map();
|
|
@@ -482,7 +487,7 @@ async function main() {
|
|
|
482
487
|
log.info(`Auto-registered DM channel ${dm.channelId.slice(0, 8)}... for bot "${botName}"`);
|
|
483
488
|
}
|
|
484
489
|
else {
|
|
485
|
-
// Mark pre-configured DM channels so
|
|
490
|
+
// Mark pre-configured DM channels so restart notice logic can identify them
|
|
486
491
|
markChannelAsDM(dm.channelId);
|
|
487
492
|
}
|
|
488
493
|
}
|
|
@@ -547,8 +552,8 @@ async function main() {
|
|
|
547
552
|
}
|
|
548
553
|
},
|
|
549
554
|
});
|
|
550
|
-
//
|
|
551
|
-
|
|
555
|
+
// Post restart notice to admin DM channels (no session creation needed)
|
|
556
|
+
postRestartNotices();
|
|
552
557
|
// Graceful shutdown
|
|
553
558
|
const shutdown = async () => {
|
|
554
559
|
log.info('Shutting down...');
|
|
@@ -725,7 +730,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
|
|
|
725
730
|
}
|
|
726
731
|
const mcpInfo = undefined;
|
|
727
732
|
const contextUsage = sessionManager.getContextUsage(msg.channelId);
|
|
728
|
-
const cmdResult = handleCommand(msg.channelId, commandText, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage);
|
|
733
|
+
const cmdResult = handleCommand(msg.channelId, commandText, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage, getConfig().providers);
|
|
729
734
|
if (cmdResult.handled) {
|
|
730
735
|
// Model/agent switch while busy — defer to serialized path
|
|
731
736
|
if (cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent') {
|
|
@@ -763,12 +768,82 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
|
|
|
763
768
|
}
|
|
764
769
|
catch { /* best-effort */ }
|
|
765
770
|
}
|
|
771
|
+
/** Test BYOK provider connectivity by hitting its models endpoint. */
|
|
772
|
+
async function testProviderConnectivity(providerName) {
|
|
773
|
+
const providers = getConfig().providers ?? {};
|
|
774
|
+
const provider = providers[providerName];
|
|
775
|
+
if (!provider)
|
|
776
|
+
return `⚠️ Provider "${providerName}" not found in config.`;
|
|
777
|
+
const baseUrl = provider.baseUrl.replace(/\/+$/, '');
|
|
778
|
+
const modelsUrl = `${baseUrl}/models`;
|
|
779
|
+
// Resolve auth
|
|
780
|
+
let apiKey = provider.apiKey;
|
|
781
|
+
if (!apiKey && provider.apiKeyEnv)
|
|
782
|
+
apiKey = process.env[provider.apiKeyEnv];
|
|
783
|
+
let bearerToken = provider.bearerToken;
|
|
784
|
+
if (!bearerToken && provider.bearerTokenEnv)
|
|
785
|
+
bearerToken = process.env[provider.bearerTokenEnv];
|
|
786
|
+
const headers = { 'Accept': 'application/json' };
|
|
787
|
+
if (apiKey)
|
|
788
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
789
|
+
else if (bearerToken)
|
|
790
|
+
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
791
|
+
const startTime = Date.now();
|
|
792
|
+
try {
|
|
793
|
+
const controller = new AbortController();
|
|
794
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
795
|
+
const response = await fetch(modelsUrl, { headers, signal: controller.signal });
|
|
796
|
+
clearTimeout(timeout);
|
|
797
|
+
const elapsed = Date.now() - startTime;
|
|
798
|
+
if (!response.ok) {
|
|
799
|
+
return `❌ Provider "${providerName}" returned HTTP ${response.status} ${response.statusText}\n URL: \`${modelsUrl}\``;
|
|
800
|
+
}
|
|
801
|
+
const data = await response.json();
|
|
802
|
+
const modelCount = Array.isArray(data?.data) ? data.data.length : '?';
|
|
803
|
+
const configuredModels = provider.models.map(m => m.id);
|
|
804
|
+
const lines = [
|
|
805
|
+
`✅ Provider "${providerName}" is reachable (${elapsed}ms)`,
|
|
806
|
+
` URL: \`${modelsUrl}\``,
|
|
807
|
+
` Remote models: ${modelCount}`,
|
|
808
|
+
` Configured: ${configuredModels.map(m => `\`${m}\``).join(', ')}`,
|
|
809
|
+
];
|
|
810
|
+
// Check if configured models exist on the remote
|
|
811
|
+
if (Array.isArray(data?.data)) {
|
|
812
|
+
const remoteIds = new Set(data.data.map((m) => m.id));
|
|
813
|
+
const missing = configuredModels.filter(id => !remoteIds.has(id));
|
|
814
|
+
if (missing.length > 0) {
|
|
815
|
+
lines.push(` ⚠️ Not found on remote: ${missing.map(m => `\`${m}\``).join(', ')}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return lines.join('\n');
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
const elapsed = Date.now() - startTime;
|
|
822
|
+
if (err?.name === 'AbortError') {
|
|
823
|
+
return `❌ Provider "${providerName}" timed out after 10s\n URL: \`${modelsUrl}\``;
|
|
824
|
+
}
|
|
825
|
+
const msg = String(err?.message ?? err);
|
|
826
|
+
if (msg.includes('ECONNREFUSED')) {
|
|
827
|
+
return `❌ Provider "${providerName}" connection refused\n URL: \`${modelsUrl}\`\n Is the service running?`;
|
|
828
|
+
}
|
|
829
|
+
if (msg.includes('ENOTFOUND')) {
|
|
830
|
+
return `❌ Provider "${providerName}" hostname not found\n URL: \`${modelsUrl}\``;
|
|
831
|
+
}
|
|
832
|
+
return `❌ Provider "${providerName}" failed (${elapsed}ms): ${msg}\n URL: \`${modelsUrl}\``;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
766
835
|
async function handleInboundMessage(msg, sessionManager, platformName, botName) {
|
|
767
836
|
// Ignore messages from any bot we manage on this platform (prevents cross-bot loops)
|
|
768
837
|
for (const [key, a] of botAdapters) {
|
|
769
838
|
if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
|
|
770
839
|
return;
|
|
771
840
|
}
|
|
841
|
+
// Clear stale no_reply flags from previous turn.
|
|
842
|
+
// Note: a late post-no_reply error could theoretically arrive after this
|
|
843
|
+
// clear if the user types very quickly after restart, but post-no_reply errors take
|
|
844
|
+
// ~30s and users rarely type during that window.
|
|
845
|
+
noReplyChannels.delete(msg.channelId);
|
|
846
|
+
noReplyHadContent.delete(msg.channelId);
|
|
772
847
|
// Check user-level access control (reads live config — hot-reloadable)
|
|
773
848
|
const botInfo = getPlatformBots(platformName).get(botName);
|
|
774
849
|
if (!checkUserAccess(msg.userId, msg.username, botInfo?.access, getPlatformAccess(platformName))) {
|
|
@@ -847,11 +922,11 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
847
922
|
}
|
|
848
923
|
// Get cached context usage for /context and /status
|
|
849
924
|
const contextUsage = sessionManager.getContextUsage(msg.channelId);
|
|
850
|
-
const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, undefined, contextUsage);
|
|
925
|
+
const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, undefined, contextUsage, getConfig().providers);
|
|
851
926
|
if (cmdResult.handled) {
|
|
852
927
|
const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
|
|
853
928
|
// Send response before action, except for actions that send their own ack after completing
|
|
854
|
-
const deferResponse = cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent' || cmdResult.action === 'set_reasoning';
|
|
929
|
+
const deferResponse = cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent' || cmdResult.action === 'set_reasoning' || cmdResult.action === 'reload_mcp' || cmdResult.action === 'reload_skills';
|
|
855
930
|
if (cmdResult.response && !deferResponse) {
|
|
856
931
|
await adapter.sendMessage(msg.channelId, cmdResult.response, { threadRootId: threadRoot });
|
|
857
932
|
}
|
|
@@ -930,6 +1005,30 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
930
1005
|
await adapter.updateMessage(msg.channelId, ackId, reloadMsg);
|
|
931
1006
|
break;
|
|
932
1007
|
}
|
|
1008
|
+
case 'reload_mcp': {
|
|
1009
|
+
const mcpAck = await adapter.sendMessage(msg.channelId, '⏳ Reloading MCP servers...', { threadRootId: threadRoot });
|
|
1010
|
+
try {
|
|
1011
|
+
await sessionManager.reloadMcp(msg.channelId);
|
|
1012
|
+
await adapter.updateMessage(msg.channelId, mcpAck, '✅ MCP servers reloaded.');
|
|
1013
|
+
}
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
log.warn(`MCP reload failed for ${msg.channelId}:`, err);
|
|
1016
|
+
await adapter.updateMessage(msg.channelId, mcpAck, `❌ MCP reload failed: ${err?.message ?? err}`);
|
|
1017
|
+
}
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
case 'reload_skills': {
|
|
1021
|
+
const skillsAck = await adapter.sendMessage(msg.channelId, '⏳ Reloading skills...', { threadRootId: threadRoot });
|
|
1022
|
+
try {
|
|
1023
|
+
await sessionManager.reloadSkills(msg.channelId);
|
|
1024
|
+
await adapter.updateMessage(msg.channelId, skillsAck, '✅ Skills reloaded.');
|
|
1025
|
+
}
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
log.warn(`Skills reload failed for ${msg.channelId}:`, err);
|
|
1028
|
+
await adapter.updateMessage(msg.channelId, skillsAck, `❌ Skills reload failed: ${err?.message ?? err}`);
|
|
1029
|
+
}
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
933
1032
|
case 'resume_session': {
|
|
934
1033
|
const oldResumeStream = activeStreams.get(msg.channelId);
|
|
935
1034
|
if (oldResumeStream) {
|
|
@@ -999,7 +1098,8 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
999
1098
|
case 'switch_model': {
|
|
1000
1099
|
const ackId = await adapter.sendMessage(msg.channelId, '⏳ Switching model...', { threadRootId: threadRoot });
|
|
1001
1100
|
try {
|
|
1002
|
-
|
|
1101
|
+
const { modelId, provider } = cmdResult.payload;
|
|
1102
|
+
await sessionManager.switchModel(msg.channelId, modelId, provider);
|
|
1003
1103
|
await adapter.updateMessage(msg.channelId, ackId, cmdResult.response ?? '✅ Model switched.');
|
|
1004
1104
|
}
|
|
1005
1105
|
catch (err) {
|
|
@@ -1020,6 +1120,19 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1020
1120
|
}
|
|
1021
1121
|
break;
|
|
1022
1122
|
}
|
|
1123
|
+
case 'provider_test': {
|
|
1124
|
+
const providerName = cmdResult.payload;
|
|
1125
|
+
const ackId = await adapter.sendMessage(msg.channelId, cmdResult.response ?? `🔄 Testing provider "${providerName}"...`, { threadRootId: threadRoot });
|
|
1126
|
+
try {
|
|
1127
|
+
const result = await testProviderConnectivity(providerName);
|
|
1128
|
+
await adapter.updateMessage(msg.channelId, ackId, result);
|
|
1129
|
+
}
|
|
1130
|
+
catch (err) {
|
|
1131
|
+
log.error(`Provider test failed for "${providerName}":`, err);
|
|
1132
|
+
await adapter.updateMessage(msg.channelId, ackId, `❌ Provider test failed: ${err?.message ?? 'unknown error'}`);
|
|
1133
|
+
}
|
|
1134
|
+
break;
|
|
1135
|
+
}
|
|
1023
1136
|
case 'set_reasoning': {
|
|
1024
1137
|
const reasoningSessionId = sessionManager.getSessionId(msg.channelId);
|
|
1025
1138
|
if (!reasoningSessionId) {
|
|
@@ -1029,14 +1142,12 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1029
1142
|
}
|
|
1030
1143
|
const ackId = await adapter.sendMessage(msg.channelId, `🧠 Setting reasoning effort to **${cmdResult.payload}**...`, { threadRootId: threadRoot });
|
|
1031
1144
|
try {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
const suffix = wasNew ? ' (previous session expired — new session created)' : '';
|
|
1035
|
-
await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort set to **${cmdResult.payload}**.${suffix}`);
|
|
1145
|
+
await sessionManager.setReasoningEffort(msg.channelId, cmdResult.payload);
|
|
1146
|
+
await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort set to **${cmdResult.payload}**.`);
|
|
1036
1147
|
}
|
|
1037
1148
|
catch (err) {
|
|
1038
|
-
log.error(`Failed to
|
|
1039
|
-
await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort saved as **${cmdResult.payload}** but
|
|
1149
|
+
log.error(`Failed to set reasoning effort on ${msg.channelId.slice(0, 8)}...:`, err);
|
|
1150
|
+
await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort saved as **${cmdResult.payload}** but RPC failed. Use \`/reload\` to apply.`);
|
|
1040
1151
|
}
|
|
1041
1152
|
break;
|
|
1042
1153
|
}
|
|
@@ -1270,11 +1381,25 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1270
1381
|
if (toggleAction === 'disable') {
|
|
1271
1382
|
const allNames = [...new Set(skills.map(s => s.name))];
|
|
1272
1383
|
setChannelPrefs(msg.channelId, { disabledSkills: allNames });
|
|
1273
|
-
|
|
1384
|
+
// Apply via RPC for each skill
|
|
1385
|
+
for (const name of allNames) {
|
|
1386
|
+
try {
|
|
1387
|
+
await sessionManager.toggleSkillRpc(msg.channelId, name, 'disable');
|
|
1388
|
+
}
|
|
1389
|
+
catch { /* best-effort */ }
|
|
1390
|
+
}
|
|
1391
|
+
await adapter.sendMessage(msg.channelId, `🔴 Disabled all ${allNames.length} skills.`, { threadRootId: threadRoot });
|
|
1274
1392
|
}
|
|
1275
1393
|
else {
|
|
1394
|
+
const allNames = [...currentDisabled];
|
|
1276
1395
|
setChannelPrefs(msg.channelId, { disabledSkills: [] });
|
|
1277
|
-
|
|
1396
|
+
for (const name of allNames) {
|
|
1397
|
+
try {
|
|
1398
|
+
await sessionManager.toggleSkillRpc(msg.channelId, name, 'enable');
|
|
1399
|
+
}
|
|
1400
|
+
catch { /* best-effort */ }
|
|
1401
|
+
}
|
|
1402
|
+
await adapter.sendMessage(msg.channelId, `🟢 Enabled all skills.`, { threadRootId: threadRoot });
|
|
1278
1403
|
}
|
|
1279
1404
|
break;
|
|
1280
1405
|
}
|
|
@@ -1310,6 +1435,13 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1310
1435
|
}
|
|
1311
1436
|
if (matched.length > 0) {
|
|
1312
1437
|
setChannelPrefs(msg.channelId, { disabledSkills: [...currentDisabled] });
|
|
1438
|
+
// Apply each toggle via RPC (best-effort — pref is already persisted)
|
|
1439
|
+
for (const name of matched) {
|
|
1440
|
+
try {
|
|
1441
|
+
await sessionManager.toggleSkillRpc(msg.channelId, name, toggleAction);
|
|
1442
|
+
}
|
|
1443
|
+
catch { /* best-effort */ }
|
|
1444
|
+
}
|
|
1313
1445
|
}
|
|
1314
1446
|
const lines = [];
|
|
1315
1447
|
if (matched.length > 0) {
|
|
@@ -1323,9 +1455,6 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1323
1455
|
if (notFound.length > 0) {
|
|
1324
1456
|
lines.push(`❌ No match: ${notFound.map(n => `"${n}"`).join(', ')}`);
|
|
1325
1457
|
}
|
|
1326
|
-
if (matched.length > 0) {
|
|
1327
|
-
lines.push('Use `/reload` to apply.');
|
|
1328
|
-
}
|
|
1329
1458
|
await adapter.sendMessage(msg.channelId, lines.join(' '), { threadRootId: threadRoot });
|
|
1330
1459
|
break;
|
|
1331
1460
|
}
|
|
@@ -1840,6 +1969,14 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1840
1969
|
}
|
|
1841
1970
|
if (event.type === 'assistant.message') {
|
|
1842
1971
|
const content = formatted.content?.trim();
|
|
1972
|
+
// Check if this message includes a no_reply tool request — the SDK may
|
|
1973
|
+
// bundle tool requests with content and then skip tool execution entirely,
|
|
1974
|
+
// so we must detect no_reply here (not just in tool.execution_start)
|
|
1975
|
+
const toolRequests = event.data?.toolRequests;
|
|
1976
|
+
const hasNoReply = toolRequests?.some(t => t.name === 'no_reply');
|
|
1977
|
+
if (hasNoReply) {
|
|
1978
|
+
noReplyChannels.add(channelId);
|
|
1979
|
+
}
|
|
1843
1980
|
// Skip empty assistant.message events (tool-call signals)
|
|
1844
1981
|
if (!content)
|
|
1845
1982
|
return;
|
|
@@ -1866,6 +2003,15 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1866
2003
|
// Suppress verbose/tool/status events during quiet — but let session.idle
|
|
1867
2004
|
// and session.error pass through so channel idle tracking still works
|
|
1868
2005
|
if (formatted.type === 'tool_start' || formatted.type === 'tool_complete') {
|
|
2006
|
+
// Detect no_reply tool even during quiet mode so we can suppress the
|
|
2007
|
+
// second-turn error that the SDK fires after the tool completes
|
|
2008
|
+
if (formatted.type === 'tool_start' && event.type === 'tool.execution_start') {
|
|
2009
|
+
const toolName = event.data?.toolName ?? event.data?.name;
|
|
2010
|
+
if (toolName === 'no_reply') {
|
|
2011
|
+
log.info(`no_reply tool invoked (quiet) on channel ${channelId.slice(0, 8)}...`);
|
|
2012
|
+
noReplyChannels.add(channelId);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1869
2015
|
return;
|
|
1870
2016
|
}
|
|
1871
2017
|
if (formatted.type === 'status' && event.type !== 'session.idle') {
|
|
@@ -1874,7 +2020,20 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1874
2020
|
// Errors: exit quiet and fall through to normal error handling (surfaces to user)
|
|
1875
2021
|
if (formatted.type === 'error') {
|
|
1876
2022
|
exitQuietMode(channelId);
|
|
1877
|
-
// Fall through to normal error handling
|
|
2023
|
+
// Fall through to post-no_reply suppression or normal error handling below
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
// Suppress errors from the second agentic turn after no_reply tool.
|
|
2027
|
+
// The SDK always starts another turn after a tool call; that turn's model
|
|
2028
|
+
// call may fail but the session remains functional.
|
|
2029
|
+
// Scoped to the specific SDK error to avoid masking unrelated failures.
|
|
2030
|
+
if (formatted.type === 'error' && noReplyChannels.has(channelId)) {
|
|
2031
|
+
const msg = event.data?.message ?? '';
|
|
2032
|
+
if (typeof msg === 'string' && msg.includes('Failed to get response from the AI model')) {
|
|
2033
|
+
log.info(`Suppressing post-no_reply model error on ${channelId.slice(0, 8)}...`);
|
|
2034
|
+
noReplyChannels.delete(channelId);
|
|
2035
|
+
noReplyHadContent.delete(channelId);
|
|
2036
|
+
return;
|
|
1878
2037
|
}
|
|
1879
2038
|
}
|
|
1880
2039
|
if (formatted.verbose && !verbose)
|
|
@@ -1886,6 +2045,31 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1886
2045
|
cancelIdleDebounce(channelId);
|
|
1887
2046
|
if (!isBusy(channelId))
|
|
1888
2047
|
markBusy(channelId);
|
|
2048
|
+
// Track if content arrives after no_reply (second turn succeeded)
|
|
2049
|
+
if (noReplyChannels.has(channelId) && formatted.content?.trim()) {
|
|
2050
|
+
noReplyHadContent.add(channelId);
|
|
2051
|
+
}
|
|
2052
|
+
// Suppress NO_REPLY responses — agent decided no response was needed.
|
|
2053
|
+
// This can happen outside quiet mode when the agent determines a user
|
|
2054
|
+
// message doesn't require a reply.
|
|
2055
|
+
if (event.type === 'assistant.message') {
|
|
2056
|
+
// Detect no_reply in tool requests bundled with the message — the SDK
|
|
2057
|
+
// may skip tool execution when content is present alongside tool calls
|
|
2058
|
+
const toolReqs = event.data?.toolRequests;
|
|
2059
|
+
if (toolReqs?.some(t => t.name === 'no_reply')) {
|
|
2060
|
+
noReplyChannels.add(channelId);
|
|
2061
|
+
}
|
|
2062
|
+
const trimmed = formatted.content?.trim();
|
|
2063
|
+
if (trimmed === 'NO_REPLY' || trimmed === '`NO_REPLY`') {
|
|
2064
|
+
log.info(`Filtered NO_REPLY on channel ${channelId.slice(0, 8)}...`);
|
|
2065
|
+
const sk = activeStreams.get(channelId);
|
|
2066
|
+
if (sk) {
|
|
2067
|
+
await streaming.deleteStream(sk);
|
|
2068
|
+
activeStreams.delete(channelId);
|
|
2069
|
+
}
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
1889
2073
|
// When response content starts, finalize the activity feed
|
|
1890
2074
|
if (activityFeeds.has(channelId)) {
|
|
1891
2075
|
await finalizeActivityFeed(channelId, adapter);
|
|
@@ -1939,6 +2123,12 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1939
2123
|
if (event.type === 'tool.execution_start') {
|
|
1940
2124
|
const toolName = event.data?.toolName ?? event.data?.name ?? 'unknown';
|
|
1941
2125
|
const args = event.data?.arguments ?? {};
|
|
2126
|
+
// Detect no_reply tool — mark channel for stream suppression
|
|
2127
|
+
if (toolName === 'no_reply') {
|
|
2128
|
+
log.info(`no_reply tool invoked on channel ${channelId.slice(0, 8)}...`);
|
|
2129
|
+
noReplyChannels.add(channelId);
|
|
2130
|
+
break;
|
|
2131
|
+
}
|
|
1942
2132
|
const loop = loopDetector.recordToolCall(channelId, toolName, args);
|
|
1943
2133
|
if (loop.isCritical) {
|
|
1944
2134
|
// Critical loop — warn and force a new session
|
|
@@ -2028,7 +2218,30 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
2028
2218
|
await finalizeActivityFeed(channelId, adapter);
|
|
2029
2219
|
initialStreamPosted.delete(channelId);
|
|
2030
2220
|
channelThreadRoots.delete(channelId);
|
|
2031
|
-
|
|
2221
|
+
// If no_reply tool was called, handle stream based on whether
|
|
2222
|
+
// the SDK's second turn produced any content.
|
|
2223
|
+
if (noReplyChannels.has(channelId)) {
|
|
2224
|
+
if (noReplyHadContent.has(channelId)) {
|
|
2225
|
+
// Second turn succeeded and produced content — finalize normally
|
|
2226
|
+
// (content will already have been suppressed by quiet mode or
|
|
2227
|
+
// the text NO_REPLY fallback in most cases)
|
|
2228
|
+
log.info(`no_reply: second turn had content, finalizing stream for ${channelId.slice(0, 8)}...`);
|
|
2229
|
+
if (streamKey) {
|
|
2230
|
+
await streaming.finalizeStream(streamKey);
|
|
2231
|
+
activeStreams.delete(channelId);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
else if (streamKey) {
|
|
2235
|
+
// No content from second turn — delete the stream silently
|
|
2236
|
+
log.info(`no_reply: deleting stream for ${channelId.slice(0, 8)}...`);
|
|
2237
|
+
await streaming.deleteStream(streamKey);
|
|
2238
|
+
activeStreams.delete(channelId);
|
|
2239
|
+
}
|
|
2240
|
+
noReplyHadContent.delete(channelId);
|
|
2241
|
+
// Don't clear noReplyChannels here — the SDK may start a second
|
|
2242
|
+
// agentic turn that errors. We clear it on the error or next message.
|
|
2243
|
+
}
|
|
2244
|
+
else if (streamKey) {
|
|
2032
2245
|
log.info(`Session idle, finalizing stream for ${channelId.slice(0, 8)}...`);
|
|
2033
2246
|
await streaming.finalizeStream(streamKey);
|
|
2034
2247
|
activeStreams.delete(channelId);
|
|
@@ -2089,41 +2302,23 @@ async function finalizeActivityFeed(channelId, adapter) {
|
|
|
2089
2302
|
}
|
|
2090
2303
|
activityFeeds.delete(channelId);
|
|
2091
2304
|
}
|
|
2092
|
-
// ---
|
|
2093
|
-
|
|
2094
|
-
|
|
2305
|
+
// --- Startup Restart Notice ---
|
|
2306
|
+
/** Post a restart notice to admin DM channels (no session creation or LLM interaction). */
|
|
2307
|
+
function postRestartNotices() {
|
|
2095
2308
|
const allSessions = getAllChannelSessions();
|
|
2096
|
-
if (allSessions.length === 0)
|
|
2097
|
-
return;
|
|
2098
2309
|
for (const { channelId } of allSessions) {
|
|
2099
|
-
// Only nudge channels belonging to admin bots
|
|
2100
2310
|
if (!isConfiguredChannel(channelId))
|
|
2101
2311
|
continue;
|
|
2102
2312
|
const channelConfig = getChannelConfig(channelId);
|
|
2103
2313
|
const botName = getChannelBotName(channelId);
|
|
2104
2314
|
if (!isBotAdmin(channelConfig.platform, botName))
|
|
2105
2315
|
continue;
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
const clearQuiet = enterQuietMode(channelId);
|
|
2116
|
-
try {
|
|
2117
|
-
await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
|
|
2118
|
-
}
|
|
2119
|
-
finally {
|
|
2120
|
-
clearQuiet();
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
catch (err) {
|
|
2124
|
-
exitQuietMode(channelId);
|
|
2125
|
-
log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
|
|
2126
|
-
}
|
|
2316
|
+
if (!channelConfig.isDM)
|
|
2317
|
+
continue;
|
|
2318
|
+
const resolved = getAdapterForChannel(channelId);
|
|
2319
|
+
if (!resolved)
|
|
2320
|
+
continue;
|
|
2321
|
+
resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
|
|
2127
2322
|
}
|
|
2128
2323
|
}
|
|
2129
2324
|
// Start the bridge
|