talon-agent 3.34.0 → 3.35.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/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +6 -4
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +290 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +76 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/lifecycle.ts +37 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/server-bindings.ts +12 -0
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/tools/history.ts +10 -1
- package/src/core/weaver/shuttle.ts +4 -0
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/storage/whatsapp-messages.ts +51 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -7,6 +7,7 @@ import type { QueryParams, QueryResult } from "../../shared/handler-types.js";
|
|
|
7
7
|
import { runRemoteChatTurn } from "../../remote-server/chat-turn.js";
|
|
8
8
|
import {
|
|
9
9
|
ensureServer,
|
|
10
|
+
trackActiveTurn,
|
|
10
11
|
ensureSession,
|
|
11
12
|
ensureChatMcpServer,
|
|
12
13
|
ensurePluginMcpServers,
|
|
@@ -26,6 +27,7 @@ export function handleMessage(params: QueryParams): Promise<QueryResult> {
|
|
|
26
27
|
label: "OpenCode",
|
|
27
28
|
getConfig,
|
|
28
29
|
ensureServer,
|
|
30
|
+
trackActiveTurn,
|
|
29
31
|
parseModelSelection: parseStoredOpenCodeModelSelection,
|
|
30
32
|
resolveProviderID,
|
|
31
33
|
ensureSession,
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Resolves the active model against the server's catalog, makes sure the
|
|
6
6
|
* server, session, and this chat's MCP servers exist, builds the prompt
|
|
7
|
-
* pair, drives the turn (`./turn.ts`), then
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* pair, drives the turn (`./turn.ts`), then runs the shared post-turn
|
|
8
|
+
* phases (`backend/shared/turn-phases.ts`) with the one family-specific
|
|
9
|
+
* step in between: the usage fallback from the session summary.
|
|
10
10
|
*
|
|
11
11
|
* Kilo and OpenCode ran byte-for-byte copies of this (modulo the backend
|
|
12
12
|
* name in log lines). The copies are gone; `RemoteChatBindings` is the
|
|
@@ -14,13 +14,7 @@
|
|
|
14
14
|
* already returns.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
getSession,
|
|
19
|
-
incrementTurns,
|
|
20
|
-
recordUsage,
|
|
21
|
-
setSessionId,
|
|
22
|
-
setSessionName,
|
|
23
|
-
} from "../../storage/sessions.js";
|
|
17
|
+
import { getSession, incrementTurns } from "../../storage/sessions.js";
|
|
24
18
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
25
19
|
import { log, logError } from "../../util/log.js";
|
|
26
20
|
import { traceMessage } from "../../util/trace.js";
|
|
@@ -32,13 +26,14 @@ import {
|
|
|
32
26
|
finalizeResponseText,
|
|
33
27
|
formatUserPrompt,
|
|
34
28
|
prepareSystemPrompt,
|
|
35
|
-
extractSessionName,
|
|
36
|
-
summarizeUsage,
|
|
37
29
|
routeDelivery,
|
|
38
30
|
applyRetryDecision,
|
|
39
|
-
recordTurnMetrics,
|
|
40
|
-
recordFailedTurnAccounting,
|
|
41
31
|
registerTurnInterrupt,
|
|
32
|
+
accountTurn,
|
|
33
|
+
accountFailedTurn,
|
|
34
|
+
nameSessionFromFirstMessage,
|
|
35
|
+
finishCallbackTurn,
|
|
36
|
+
type StreamState,
|
|
42
37
|
} from "../shared/index.js";
|
|
43
38
|
import type { RemoteAgentClient } from "./client.js";
|
|
44
39
|
import type { RemoteServerBindings } from "./server-bindings.js";
|
|
@@ -50,6 +45,7 @@ export type RemoteChatBindings<TClient extends RemoteAgentClient> = Pick<
|
|
|
50
45
|
RemoteServerBindings<TClient>,
|
|
51
46
|
| "getConfig"
|
|
52
47
|
| "ensureServer"
|
|
48
|
+
| "trackActiveTurn"
|
|
53
49
|
| "parseModelSelection"
|
|
54
50
|
| "resolveProviderID"
|
|
55
51
|
| "ensureSession"
|
|
@@ -150,10 +146,12 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
|
|
|
150
146
|
state.turnTerminated = true;
|
|
151
147
|
await turnClient.session.abort({ sessionID: sessionId });
|
|
152
148
|
});
|
|
149
|
+
// Stopping the backend mid-turn (a `/model` swap, shutdown) aborts this
|
|
150
|
+
// controller, which rejects the turn promptly instead of leaving it to
|
|
151
|
+
// the deadline.
|
|
152
|
+
const stopController = new AbortController();
|
|
153
|
+
const untrackTurn = bindings.trackActiveTurn(stopController);
|
|
153
154
|
const promptStartedAt = Date.now();
|
|
154
|
-
const seenQuestionIds = new Set<string>();
|
|
155
|
-
const seenPermissionIds = new Set<string>();
|
|
156
|
-
const seenToolCallIds = new Set<string>();
|
|
157
155
|
|
|
158
156
|
const setupMs = Date.now() - t0;
|
|
159
157
|
let promptMs = 0;
|
|
@@ -176,13 +174,14 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
|
|
|
176
174
|
modelID,
|
|
177
175
|
state,
|
|
178
176
|
chatId,
|
|
179
|
-
seenQuestionIds,
|
|
180
|
-
seenPermissionIds,
|
|
181
|
-
seenToolCallIds,
|
|
177
|
+
seenQuestionIds: new Set<string>(),
|
|
178
|
+
seenPermissionIds: new Set<string>(),
|
|
179
|
+
seenToolCallIds: new Set<string>(),
|
|
182
180
|
toolOverrides,
|
|
183
181
|
onStreamDelta: undefined,
|
|
184
182
|
onTextBlock,
|
|
185
183
|
onToolUse,
|
|
184
|
+
stopSignal: stopController.signal,
|
|
186
185
|
});
|
|
187
186
|
promptMs = Date.now() - turnStart;
|
|
188
187
|
} catch (err) {
|
|
@@ -198,31 +197,22 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
|
|
|
198
197
|
if (outcome.retry) return outcome.retry;
|
|
199
198
|
|
|
200
199
|
// Terminal failure — account for whatever the turn consumed before
|
|
201
|
-
// dying
|
|
202
|
-
//
|
|
203
|
-
|
|
200
|
+
// dying (the retry path above did its own accounting inside the
|
|
201
|
+
// recursive attempt).
|
|
202
|
+
accountFailedTurn({
|
|
204
203
|
backend: id,
|
|
205
204
|
chatId,
|
|
205
|
+
state,
|
|
206
206
|
durationMs: Date.now() - t0,
|
|
207
|
-
toolCalls: state.toolCalls,
|
|
208
|
-
apiCalls: state.numApiCalls,
|
|
209
207
|
model: activeModel,
|
|
210
|
-
usage: {
|
|
211
|
-
inputTokens: state.sdkInputTokens,
|
|
212
|
-
outputTokens: state.sdkOutputTokens,
|
|
213
|
-
cacheRead: state.sdkCacheRead,
|
|
214
|
-
cacheWrite: state.sdkCacheWrite,
|
|
215
|
-
},
|
|
216
|
-
contextTokens: state.contextTokens,
|
|
217
|
-
contextWindow: state.contextWindow,
|
|
218
208
|
});
|
|
219
|
-
|
|
220
209
|
logError(
|
|
221
210
|
"agent",
|
|
222
211
|
`[${chatId}] ${label} error: ${outcome.classified.message}`,
|
|
223
212
|
);
|
|
224
213
|
throw outcome.classified;
|
|
225
214
|
} finally {
|
|
215
|
+
untrackTurn();
|
|
226
216
|
unregisterInterrupt();
|
|
227
217
|
// Note: we deliberately do NOT disconnect the chat MCP server here.
|
|
228
218
|
// The server is named per-chat so it's safe to keep across turns;
|
|
@@ -232,71 +222,25 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
|
|
|
232
222
|
|
|
233
223
|
// ── Post-loop accounting ──────────────────────────────────────────────────
|
|
234
224
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
state
|
|
240
|
-
|
|
241
|
-
) {
|
|
242
|
-
try {
|
|
243
|
-
const summary = await getTurnSummary(
|
|
244
|
-
oc as unknown as RemoteSessionClient,
|
|
245
|
-
sessionId,
|
|
246
|
-
promptStartedAt,
|
|
247
|
-
);
|
|
248
|
-
if (summary.usage.assistantMessages > 0) {
|
|
249
|
-
recordTokens(state, {
|
|
250
|
-
inputTokens: summary.usage.inputTokens,
|
|
251
|
-
outputTokens: summary.usage.outputTokens,
|
|
252
|
-
cacheRead: summary.usage.cacheRead,
|
|
253
|
-
cacheWrite: summary.usage.cacheWrite,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
} catch {
|
|
257
|
-
// best-effort — session summaries can race on cancellation
|
|
258
|
-
}
|
|
259
|
-
}
|
|
225
|
+
await fillUsageFromSummary(
|
|
226
|
+
oc as unknown as RemoteSessionClient,
|
|
227
|
+
sessionId,
|
|
228
|
+
promptStartedAt,
|
|
229
|
+
state,
|
|
230
|
+
);
|
|
260
231
|
|
|
261
232
|
const responseText = finalizeResponseText(state);
|
|
262
233
|
const durationMs = Date.now() - t0;
|
|
263
|
-
|
|
234
|
+
accountTurn({
|
|
264
235
|
chatId,
|
|
265
236
|
backend: id,
|
|
266
|
-
|
|
267
|
-
toolCalls: state.toolCalls,
|
|
268
|
-
apiCalls: state.numApiCalls,
|
|
269
|
-
usage: {
|
|
270
|
-
inputTokens: state.sdkInputTokens,
|
|
271
|
-
outputTokens: state.sdkOutputTokens,
|
|
272
|
-
cacheRead: state.sdkCacheRead,
|
|
273
|
-
cacheWrite: state.sdkCacheWrite,
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
if (state.newSessionId) {
|
|
278
|
-
// setSessionId tolerates "same id" calls — keep it simple.
|
|
279
|
-
const stored = getSession(chatId).sessionId;
|
|
280
|
-
if (stored !== state.newSessionId) {
|
|
281
|
-
setSessionId(chatId, state.newSessionId);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
incrementTurns(chatId);
|
|
286
|
-
recordUsage(chatId, {
|
|
287
|
-
inputTokens: state.sdkInputTokens,
|
|
288
|
-
outputTokens: state.sdkOutputTokens,
|
|
289
|
-
cacheRead: state.sdkCacheRead,
|
|
290
|
-
cacheWrite: state.sdkCacheWrite,
|
|
237
|
+
state,
|
|
291
238
|
durationMs,
|
|
292
239
|
model: activeModel,
|
|
240
|
+
sessionId: state.newSessionId,
|
|
293
241
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (previousTurns === 0) {
|
|
297
|
-
const name = extractSessionName(text);
|
|
298
|
-
if (name) setSessionName(chatId, name);
|
|
299
|
-
}
|
|
242
|
+
incrementTurns(chatId);
|
|
243
|
+
nameSessionFromFirstMessage({ chatId, text, previousTurns });
|
|
300
244
|
|
|
301
245
|
// ── Delivery — the decision tree shared by every backend ──────────────────
|
|
302
246
|
const delivery = await routeDelivery({
|
|
@@ -307,40 +251,50 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
|
|
|
307
251
|
onTextBlock,
|
|
308
252
|
});
|
|
309
253
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
log(
|
|
316
|
-
"agent",
|
|
317
|
-
`[${chatId}] -> (${summarizeUsage(
|
|
318
|
-
{
|
|
319
|
-
inputTokens: state.sdkInputTokens,
|
|
320
|
-
outputTokens: state.sdkOutputTokens,
|
|
321
|
-
cacheRead: state.sdkCacheRead,
|
|
322
|
-
cacheWrite: state.sdkCacheWrite,
|
|
323
|
-
},
|
|
324
|
-
{ durationMs, toolCalls: state.toolCalls },
|
|
325
|
-
)} terminator=${state.turnTerminated ? "yes" : "no"} ` +
|
|
326
|
-
`delivered=${state.deliveredTextNorms.length} ` +
|
|
327
|
-
`respLen=${responseText.length} ` +
|
|
328
|
-
`setup=${setupMs}ms turn=${promptMs}ms ` +
|
|
329
|
-
`events=${formatEventCounts(state.eventCounts)})`,
|
|
330
|
-
);
|
|
331
|
-
traceMessage(chatId, "out", responseText, {
|
|
254
|
+
return finishCallbackTurn({
|
|
255
|
+
chatId,
|
|
256
|
+
state,
|
|
257
|
+
responseText,
|
|
332
258
|
durationMs,
|
|
333
|
-
|
|
259
|
+
setupMs,
|
|
260
|
+
turnMs: promptMs,
|
|
261
|
+
delivery,
|
|
262
|
+
detail: `events=${formatEventCounts(state.eventCounts)}`,
|
|
334
263
|
});
|
|
264
|
+
}
|
|
335
265
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
266
|
+
/**
|
|
267
|
+
* If the SSE loop missed the usage info, fall back to the session summary
|
|
268
|
+
* endpoint (which always reflects the final server state). Best-effort:
|
|
269
|
+
* session summaries can race on cancellation, so a failure leaves the
|
|
270
|
+
* counts at zero.
|
|
271
|
+
*/
|
|
272
|
+
async function fillUsageFromSummary(
|
|
273
|
+
oc: RemoteSessionClient,
|
|
274
|
+
sessionId: string,
|
|
275
|
+
promptStartedAt: number,
|
|
276
|
+
state: ReturnType<typeof createStreamState>,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
if (
|
|
279
|
+
state.sdkInputTokens !== 0 ||
|
|
280
|
+
state.sdkOutputTokens !== 0 ||
|
|
281
|
+
state.sdkCacheRead !== 0
|
|
282
|
+
) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const summary = await getTurnSummary(oc, sessionId, promptStartedAt);
|
|
287
|
+
if (summary.usage.assistantMessages > 0) {
|
|
288
|
+
recordTokens(state, {
|
|
289
|
+
inputTokens: summary.usage.inputTokens,
|
|
290
|
+
outputTokens: summary.usage.outputTokens,
|
|
291
|
+
cacheRead: summary.usage.cacheRead,
|
|
292
|
+
cacheWrite: summary.usage.cacheWrite,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// best-effort — session summaries can race on cancellation
|
|
297
|
+
}
|
|
344
298
|
}
|
|
345
299
|
|
|
346
300
|
/**
|
|
@@ -42,7 +42,11 @@ export { stopRemoteServer } from "./lifecycle.js";
|
|
|
42
42
|
|
|
43
43
|
export {
|
|
44
44
|
TALON_MCP_SERVER_NAME,
|
|
45
|
+
TALON_PLUGIN_MCP_SERVER_NAME,
|
|
46
|
+
PLUGIN_MCP_SERVER_NAME_MAX_LENGTH,
|
|
45
47
|
getChatMcpServerName,
|
|
48
|
+
getPluginMcpServerName,
|
|
49
|
+
getPluginMcpServerPrefix,
|
|
46
50
|
isTalonToolID,
|
|
47
51
|
ensureChatMcpServer,
|
|
48
52
|
ensurePluginMcpServers,
|
|
@@ -13,11 +13,29 @@
|
|
|
13
13
|
* reuse machinery.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { TalonError } from "../../core/errors.js";
|
|
16
17
|
import { log, logWarn } from "../../util/log.js";
|
|
17
18
|
import type { RemoteAgentClient } from "./client.js";
|
|
18
19
|
import type { RemoteServerState } from "./state.js";
|
|
19
20
|
import { errMsg } from "./state.js";
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Rejection for a turn whose server was stopped underneath it (a backend
|
|
24
|
+
* hot-swap or shutdown). Non-retryable on purpose: the chat no longer
|
|
25
|
+
* points at this backend, so a fallback-model retry against it would be
|
|
26
|
+
* wrong — the turn fails cleanly and the next message runs on the new
|
|
27
|
+
* backend.
|
|
28
|
+
*/
|
|
29
|
+
export class RemoteServerStoppedError extends TalonError {
|
|
30
|
+
constructor(label: string) {
|
|
31
|
+
super(`${label} server stopped while the turn was in flight`, {
|
|
32
|
+
reason: "unknown",
|
|
33
|
+
retryable: false,
|
|
34
|
+
});
|
|
35
|
+
this.name = "RemoteServerStoppedError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* Inputs passed to {@link ensureRemoteServer}. The backend supplies its
|
|
23
41
|
* SDK's client/server factories; the shared helper handles the
|
|
@@ -180,6 +198,7 @@ export function stopRemoteServer<TClient extends RemoteAgentClient>(
|
|
|
180
198
|
extraCleanup?: () => void,
|
|
181
199
|
): void {
|
|
182
200
|
state.clientPromise = null;
|
|
201
|
+
abortActiveTurns(state);
|
|
183
202
|
state.modelProviderCache.clear();
|
|
184
203
|
state.registeredMcpServers.clear();
|
|
185
204
|
state.registeredMcpTools.clear();
|
|
@@ -201,3 +220,21 @@ export function stopRemoteServer<TClient extends RemoteAgentClient>(
|
|
|
201
220
|
// reused and is intentionally left running for its external owner.
|
|
202
221
|
state.client = null;
|
|
203
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Reject every in-flight turn with {@link RemoteServerStoppedError} before
|
|
226
|
+
* the server goes away. The turn's own cleanup unregisters its controller;
|
|
227
|
+
* clearing here only covers turns that never reach their `finally`.
|
|
228
|
+
*/
|
|
229
|
+
function abortActiveTurns<TClient extends RemoteAgentClient>(
|
|
230
|
+
state: RemoteServerState<TClient>,
|
|
231
|
+
): void {
|
|
232
|
+
if (state.activeTurns.size === 0) return;
|
|
233
|
+
log(
|
|
234
|
+
"agent",
|
|
235
|
+
`${state.label} stopping with ${state.activeTurns.size} turn(s) in flight; aborting them`,
|
|
236
|
+
);
|
|
237
|
+
const reason = new RemoteServerStoppedError(state.label);
|
|
238
|
+
for (const controller of state.activeTurns) controller.abort(reason);
|
|
239
|
+
state.activeTurns.clear();
|
|
240
|
+
}
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* `talon-tools-<chatId>` MCP server. Returns the registered name so
|
|
15
15
|
* callers can scope tool overrides to this chat alone.
|
|
16
16
|
* - {@link ensurePluginMcpServers} — register chat-namespaced plugin MCP
|
|
17
|
-
* servers (`mempalace-tools`, `brave-search`, `github-tools`, …)
|
|
17
|
+
* servers (`mempalace-tools`, `brave-search`, `github-tools`, …) under
|
|
18
|
+
* short `tp-<chat hash>-<plugin>` names (see
|
|
19
|
+
* {@link getPluginMcpServerName} for the length budget).
|
|
18
20
|
* - {@link buildToolOverrides} — produce a `tools` map that whitelists
|
|
19
21
|
* this chat's Talon tools and blacklists every other chat's. Used
|
|
20
22
|
* as the `tools` field on the prompt payload to constrain the model's
|
|
@@ -29,12 +31,15 @@
|
|
|
29
31
|
* registrations run concurrently through the hub; later turns skip them.
|
|
30
32
|
*/
|
|
31
33
|
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
|
|
32
36
|
import { log, logDebug, logWarn } from "../../util/log.js";
|
|
33
37
|
import {
|
|
34
38
|
talonHubUrl,
|
|
35
39
|
pluginHubUrl,
|
|
36
40
|
hubPluginServerNames,
|
|
37
41
|
listHubPluginToolNames,
|
|
42
|
+
describeHubChildExit,
|
|
38
43
|
} from "../../core/mcp-hub/index.js";
|
|
39
44
|
import type { RemoteAgentClient } from "./client.js";
|
|
40
45
|
import type { RemoteServerState } from "./state.js";
|
|
@@ -51,8 +56,28 @@ import {
|
|
|
51
56
|
|
|
52
57
|
/** Stable name prefix for Talon's per-chat MCP servers. */
|
|
53
58
|
export const TALON_MCP_SERVER_NAME = "talon-tools";
|
|
54
|
-
/**
|
|
55
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Stable prefix for chat-scoped plugin MCP registrations. Deliberately
|
|
61
|
+
* terse: the upstream agent server composes tool ids as
|
|
62
|
+
* `<server>_<tool>`, and Anthropic rejects tool names over 64 characters
|
|
63
|
+
* (`\`name\` must be at most 64 characters`). The old
|
|
64
|
+
* `talon-plugin-<chatId>-<plugin>` form alone reached ~45 characters and
|
|
65
|
+
* hard-failed the first call of any long-named plugin tool.
|
|
66
|
+
*/
|
|
67
|
+
export const TALON_PLUGIN_MCP_SERVER_NAME = "tp";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Upper bound on a generated plugin MCP server name. The 64-character
|
|
71
|
+
* Anthropic tool-name limit must cover `<server>_<tool>`, so this leaves
|
|
72
|
+
* 31 characters for the plugin's own tool name — comfortably above the
|
|
73
|
+
* longest ones in the wild (`browser_take_screenshot`, 23).
|
|
74
|
+
*/
|
|
75
|
+
export const PLUGIN_MCP_SERVER_NAME_MAX_LENGTH = 32;
|
|
76
|
+
|
|
77
|
+
/** Hex characters of the chat-id hash embedded in a plugin server name. */
|
|
78
|
+
const PLUGIN_SERVER_CHAT_HASH_LENGTH = 8;
|
|
79
|
+
/** Hex characters of the plugin-name hash appended when the name is cut. */
|
|
80
|
+
const PLUGIN_SERVER_PLUGIN_HASH_LENGTH = 6;
|
|
56
81
|
|
|
57
82
|
/**
|
|
58
83
|
* MCP add() calls slower than this get a `[slow]` annotation in the log
|
|
@@ -87,13 +112,48 @@ export function getChatMcpServerName(chatId: string): string {
|
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
/** Sanitize a component embedded in an upstream MCP registration name. */
|
|
90
|
-
|
|
115
|
+
function safeMcpNamePart(value: string, fallback: string): string {
|
|
91
116
|
return value.replace(/[^a-zA-Z0-9_-]+/g, "_") || fallback;
|
|
92
117
|
}
|
|
93
118
|
|
|
94
|
-
/**
|
|
95
|
-
function
|
|
96
|
-
return
|
|
119
|
+
/** Leading `length` hex characters of the SHA-256 of `value`. */
|
|
120
|
+
function shortHash(value: string, length: number): string {
|
|
121
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Name prefix shared by every plugin MCP server registered for one chat
|
|
126
|
+
* (`tp-<8-hex chat hash>-`). The permission ruleset allows this chat's
|
|
127
|
+
* prefix and denies `tp-*` for everything else, so the prefix must be a
|
|
128
|
+
* pure function of the chat id — never of the plugin.
|
|
129
|
+
*/
|
|
130
|
+
export function getPluginMcpServerPrefix(chatId: string): string {
|
|
131
|
+
return `${TALON_PLUGIN_MCP_SERVER_NAME}-${shortHash(chatId, PLUGIN_SERVER_CHAT_HASH_LENGTH)}-`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Per-chat registration name for a plugin-provided MCP server.
|
|
136
|
+
*
|
|
137
|
+
* Deterministic for a given (plugin, chat) pair and never longer than
|
|
138
|
+
* {@link PLUGIN_MCP_SERVER_NAME_MAX_LENGTH}. The plugin name is kept
|
|
139
|
+
* verbatim while it fits; an overlong one is cut and suffixed with a short
|
|
140
|
+
* hash of the full name so two long plugins sharing a head stay distinct.
|
|
141
|
+
* Nothing parses the name back — `state.pluginMcpServersByChat` is the
|
|
142
|
+
* reverse map from chat + plugin to registered name.
|
|
143
|
+
*/
|
|
144
|
+
export function getPluginMcpServerName(
|
|
145
|
+
pluginName: string,
|
|
146
|
+
chatId: string,
|
|
147
|
+
): string {
|
|
148
|
+
const prefix = getPluginMcpServerPrefix(chatId);
|
|
149
|
+
const budget = PLUGIN_MCP_SERVER_NAME_MAX_LENGTH - prefix.length;
|
|
150
|
+
const safePlugin = safeMcpNamePart(pluginName, "plugin");
|
|
151
|
+
if (safePlugin.length <= budget) return `${prefix}${safePlugin}`;
|
|
152
|
+
const head = safePlugin.slice(
|
|
153
|
+
0,
|
|
154
|
+
budget - PLUGIN_SERVER_PLUGIN_HASH_LENGTH - 1,
|
|
155
|
+
);
|
|
156
|
+
return `${prefix}${head}-${shortHash(pluginName, PLUGIN_SERVER_PLUGIN_HASH_LENGTH)}`;
|
|
97
157
|
}
|
|
98
158
|
|
|
99
159
|
/** Whether a tool id belongs to one of Talon's MCP servers. */
|
|
@@ -257,7 +317,7 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
|
|
|
257
317
|
const ms = Date.now() - startedAt;
|
|
258
318
|
log(
|
|
259
319
|
"agent",
|
|
260
|
-
`Registered plugin MCP server: ${serverName} (${ms}ms)` +
|
|
320
|
+
`Registered plugin MCP server: ${serverName} (${name} for chat ${chatId}, ${ms}ms)` +
|
|
261
321
|
(ms > SLOW_MCP_REGISTRATION_MS ? " [slow]" : ""),
|
|
262
322
|
);
|
|
263
323
|
return serverName;
|
|
@@ -271,9 +331,13 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
|
|
|
271
331
|
? logDebug
|
|
272
332
|
: logWarn;
|
|
273
333
|
warnedMcpRegistrationFailures.add(serverName);
|
|
334
|
+
// "Connection closed" alone says nothing; the hub knows how the
|
|
335
|
+
// child behind this server last died.
|
|
336
|
+
const exit = describeHubChildExit(name, chatId);
|
|
274
337
|
level(
|
|
275
338
|
"agent",
|
|
276
|
-
`Plugin MCP registration failed for ${serverName}: ${errMsg(err)}
|
|
339
|
+
`Plugin MCP registration failed for ${serverName}: ${errMsg(err)}` +
|
|
340
|
+
(exit ? ` (hub child ${exit})` : ""),
|
|
277
341
|
);
|
|
278
342
|
return null;
|
|
279
343
|
}
|