talon-agent 3.34.0 → 3.34.1

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.
@@ -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 does the post-turn accounting:
8
- * usage fallback from the session summary, metrics, session id/name
9
- * persistence, and the shared delivery decision.
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";
@@ -151,9 +146,6 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
151
146
  await turnClient.session.abort({ sessionID: sessionId });
152
147
  });
153
148
  const promptStartedAt = Date.now();
154
- const seenQuestionIds = new Set<string>();
155
- const seenPermissionIds = new Set<string>();
156
- const seenToolCallIds = new Set<string>();
157
149
 
158
150
  const setupMs = Date.now() - t0;
159
151
  let promptMs = 0;
@@ -176,9 +168,9 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
176
168
  modelID,
177
169
  state,
178
170
  chatId,
179
- seenQuestionIds,
180
- seenPermissionIds,
181
- seenToolCallIds,
171
+ seenQuestionIds: new Set<string>(),
172
+ seenPermissionIds: new Set<string>(),
173
+ seenToolCallIds: new Set<string>(),
182
174
  toolOverrides,
183
175
  onStreamDelta: undefined,
184
176
  onTextBlock,
@@ -198,25 +190,15 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
198
190
  if (outcome.retry) return outcome.retry;
199
191
 
200
192
  // Terminal failure — account for whatever the turn consumed before
201
- // dying and drop the live overlay (the retry path above did its own
202
- // accounting inside the recursive attempt).
203
- recordFailedTurnAccounting({
193
+ // dying (the retry path above did its own accounting inside the
194
+ // recursive attempt).
195
+ accountFailedTurn({
204
196
  backend: id,
205
197
  chatId,
198
+ state,
206
199
  durationMs: Date.now() - t0,
207
- toolCalls: state.toolCalls,
208
- apiCalls: state.numApiCalls,
209
200
  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
201
  });
219
-
220
202
  logError(
221
203
  "agent",
222
204
  `[${chatId}] ${label} error: ${outcome.classified.message}`,
@@ -232,71 +214,25 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
232
214
 
233
215
  // ── Post-loop accounting ──────────────────────────────────────────────────
234
216
 
235
- // If the SSE loop missed any usage info, fall back to the session
236
- // summary endpoint (which always reflects the final server state).
237
- if (
238
- state.sdkInputTokens === 0 &&
239
- state.sdkOutputTokens === 0 &&
240
- state.sdkCacheRead === 0
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
- }
217
+ await fillUsageFromSummary(
218
+ oc as unknown as RemoteSessionClient,
219
+ sessionId,
220
+ promptStartedAt,
221
+ state,
222
+ );
260
223
 
261
224
  const responseText = finalizeResponseText(state);
262
225
  const durationMs = Date.now() - t0;
263
- recordTurnMetrics({
226
+ accountTurn({
264
227
  chatId,
265
228
  backend: id,
266
- durationMs,
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,
229
+ state,
291
230
  durationMs,
292
231
  model: activeModel,
232
+ sessionId: state.newSessionId,
293
233
  });
294
-
295
- // Set a descriptive session name from the user's first message.
296
- if (previousTurns === 0) {
297
- const name = extractSessionName(text);
298
- if (name) setSessionName(chatId, name);
299
- }
234
+ incrementTurns(chatId);
235
+ nameSessionFromFirstMessage({ chatId, text, previousTurns });
300
236
 
301
237
  // ── Delivery — the decision tree shared by every backend ──────────────────
302
238
  const delivery = await routeDelivery({
@@ -307,40 +243,48 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
307
243
  onTextBlock,
308
244
  });
309
245
 
310
- log(
311
- "agent",
312
- `[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
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, {
246
+ return finishCallbackTurn({
247
+ chatId,
248
+ state,
249
+ responseText,
332
250
  durationMs,
333
- toolCalls: state.toolCalls,
251
+ setupMs,
252
+ turnMs: promptMs,
253
+ delivery,
254
+ detail: `events=${formatEventCounts(state.eventCounts)}`,
334
255
  });
256
+ }
335
257
 
336
- return {
337
- text: responseText,
338
- durationMs,
339
- inputTokens: state.sdkInputTokens,
340
- outputTokens: state.sdkOutputTokens,
341
- cacheRead: state.sdkCacheRead,
342
- cacheWrite: state.sdkCacheWrite,
343
- };
258
+ /**
259
+ * If the SSE loop missed any usage info, fall back to the session
260
+ * summary endpoint (which always reflects the final server state).
261
+ */
262
+ async function fillUsageFromSummary(
263
+ oc: RemoteSessionClient,
264
+ sessionId: string,
265
+ promptStartedAt: number,
266
+ state: StreamState,
267
+ ): Promise<void> {
268
+ if (
269
+ state.sdkInputTokens !== 0 ||
270
+ state.sdkOutputTokens !== 0 ||
271
+ state.sdkCacheRead !== 0
272
+ ) {
273
+ return;
274
+ }
275
+ try {
276
+ const summary = await getTurnSummary(oc, sessionId, promptStartedAt);
277
+ if (summary.usage.assistantMessages > 0) {
278
+ recordTokens(state, {
279
+ inputTokens: summary.usage.inputTokens,
280
+ outputTokens: summary.usage.outputTokens,
281
+ cacheRead: summary.usage.cacheRead,
282
+ cacheWrite: summary.usage.cacheWrite,
283
+ });
284
+ }
285
+ } catch {
286
+ // best-effort — session summaries can race on cancellation
287
+ }
344
288
  }
345
289
 
346
290
  /**
@@ -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,
@@ -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
- /** Stable prefix for chat-scoped plugin MCP registrations. */
55
- export const TALON_PLUGIN_MCP_SERVER_NAME = "talon-plugin";
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
- export function safeMcpNamePart(value: string, fallback: string): string {
115
+ function safeMcpNamePart(value: string, fallback: string): string {
91
116
  return value.replace(/[^a-zA-Z0-9_-]+/g, "_") || fallback;
92
117
  }
93
118
 
94
- /** Per-chat registration name for a plugin-provided MCP server. */
95
- function getPluginMcpServerName(pluginName: string, chatId: string): string {
96
- return `${TALON_PLUGIN_MCP_SERVER_NAME}-${safeMcpNamePart(chatId, "chat")}-${safeMcpNamePart(pluginName, "plugin")}`;
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
  }