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.
- package/package.json +4 -2
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -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/sessions.ts +2 -2
- 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/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/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/weaver/shuttle.ts +4 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -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";
|
|
@@ -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
|
|
202
|
-
//
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
226
|
+
accountTurn({
|
|
264
227
|
chatId,
|
|
265
228
|
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,
|
|
229
|
+
state,
|
|
291
230
|
durationMs,
|
|
292
231
|
model: activeModel,
|
|
232
|
+
sessionId: state.newSessionId,
|
|
293
233
|
});
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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, {
|
|
246
|
+
return finishCallbackTurn({
|
|
247
|
+
chatId,
|
|
248
|
+
state,
|
|
249
|
+
responseText,
|
|
332
250
|
durationMs,
|
|
333
|
-
|
|
251
|
+
setupMs,
|
|
252
|
+
turnMs: promptMs,
|
|
253
|
+
delivery,
|
|
254
|
+
detail: `events=${formatEventCounts(state.eventCounts)}`,
|
|
334
255
|
});
|
|
256
|
+
}
|
|
335
257
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
/**
|
|
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
|
}
|