talon-agent 3.33.4 → 3.34.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/package.json +2 -1
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.34.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"lint": "oxlint src/",
|
|
84
84
|
"depcruise": "node scripts/check-architecture.mjs",
|
|
85
85
|
"ratchets": "node scripts/check-ratchets.mjs",
|
|
86
|
+
"function-size": "node scripts/check-function-size.mjs",
|
|
86
87
|
"knip": "knip",
|
|
87
88
|
"format": "prettier --write src/ prompts/",
|
|
88
89
|
"format:check": "prettier --check src/ prompts/",
|
package/src/app.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { pruneSettledTriggers } from "./storage/trigger-store.js";
|
|
|
27
27
|
import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
|
|
28
28
|
import { spawnSuccessor } from "./util/respawn.js";
|
|
29
29
|
import { log, logError, logWarn } from "./util/log.js";
|
|
30
|
+
import { bootPhase, bootReport } from "./util/boot-timer.js";
|
|
30
31
|
import {
|
|
31
32
|
getVfs,
|
|
32
33
|
mountNamespaceFs,
|
|
@@ -51,7 +52,7 @@ import {
|
|
|
51
52
|
removePidRecordIfOwnedBy,
|
|
52
53
|
} from "./core/daemon/pidfile.js";
|
|
53
54
|
|
|
54
|
-
const { config } = await bootstrap();
|
|
55
|
+
const { config } = await bootPhase("bootstrap", () => bootstrap());
|
|
55
56
|
|
|
56
57
|
// Record this process as the daemon. The gateway port is appended once
|
|
57
58
|
// the gateway binds (it may fall back from the default on EADDRINUSE).
|
|
@@ -69,15 +70,19 @@ gateway.onShutdownRequest((reason) => void gracefulShutdown(reason));
|
|
|
69
70
|
const configuredFrontends = [...new Set(getFrontends(config))];
|
|
70
71
|
|
|
71
72
|
const frontends: Frontend[] = [];
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
73
|
+
await bootPhase("frontends create", async () => {
|
|
74
|
+
for (const name of configuredFrontends) {
|
|
75
|
+
const frontend = await createFrontendById(name, config, gateway);
|
|
76
|
+
frontends.push(frontend);
|
|
77
|
+
log("bot", `Frontend: ${getFrontendDescriptor(name)?.label ?? name}`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
77
80
|
|
|
78
81
|
// ── Create backend + wire dispatcher ─────────────────────────────────────────
|
|
79
82
|
|
|
80
|
-
const { backend } = await
|
|
83
|
+
const { backend } = await bootPhase("backend + dispatcher", () =>
|
|
84
|
+
initBackendAndDispatcher(config, frontends),
|
|
85
|
+
);
|
|
81
86
|
gateway.backend = backend;
|
|
82
87
|
|
|
83
88
|
// Subscribe the gateway to chat-role rebinds so `/model`, `/settings`,
|
|
@@ -303,7 +308,10 @@ async function main(): Promise<void> {
|
|
|
303
308
|
"Terminal frontend shares stdin with the other frontends; it will run alongside them without blocking startup.",
|
|
304
309
|
);
|
|
305
310
|
}
|
|
306
|
-
await
|
|
311
|
+
await bootPhase("frontends start", () =>
|
|
312
|
+
Promise.all(blockingFrontends.map((frontend) => frontend.start())),
|
|
313
|
+
);
|
|
314
|
+
log("bot", `Ready in ${bootReport()}`);
|
|
307
315
|
for (const frontend of stdinFrontends) {
|
|
308
316
|
void frontend
|
|
309
317
|
.start()
|
|
@@ -334,6 +334,79 @@ export function extractToolResults(msg: SDKUserMessage): ToolResultInfo[] {
|
|
|
334
334
|
return results;
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
/** Context fill of the last API iteration + whether the turn's first request hit the cache. */
|
|
338
|
+
function readContextFill(
|
|
339
|
+
state: StreamState,
|
|
340
|
+
usage: SDKResultMessage["usage"],
|
|
341
|
+
): void {
|
|
342
|
+
if (
|
|
343
|
+
!usage ||
|
|
344
|
+
!Array.isArray(usage.iterations) ||
|
|
345
|
+
usage.iterations.length === 0
|
|
346
|
+
)
|
|
347
|
+
return;
|
|
348
|
+
const last = usage.iterations[usage.iterations.length - 1];
|
|
349
|
+
state.contextTokens =
|
|
350
|
+
(last.input_tokens ?? 0) +
|
|
351
|
+
(last.cache_read_input_tokens ?? 0) +
|
|
352
|
+
(last.cache_creation_input_tokens ?? 0);
|
|
353
|
+
// The same array answers a question the per-turn totals can't: whether
|
|
354
|
+
// the FIRST request of this turn read a cache or paid to write one.
|
|
355
|
+
state.cacheStats = turnCacheStats(usage.iterations);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Token counts from the ACTIVE model's usage only. modelUsage is keyed by
|
|
360
|
+
* the exact SDK model string (e.g. "sonnet[1m]") and holds cumulative
|
|
361
|
+
* session totals per model — summing all entries double-counts when
|
|
362
|
+
* switching models mid-session.
|
|
363
|
+
*/
|
|
364
|
+
function readModelUsage(
|
|
365
|
+
state: StreamState,
|
|
366
|
+
modelUsage: Record<string, ModelUsage>,
|
|
367
|
+
sdkModel: string,
|
|
368
|
+
): void {
|
|
369
|
+
// Drift check: modelUsage is keyed by the model that ACTUALLY served
|
|
370
|
+
// the turn. If none of the keys is (an alias of) the model we asked
|
|
371
|
+
// for, the API substituted — warn instead of burying it in the
|
|
372
|
+
// accounting line.
|
|
373
|
+
checkModelDrift(sdkModel, Object.keys(modelUsage ?? {}));
|
|
374
|
+
const mu = modelUsage[sdkModel] ?? Object.values(modelUsage).at(-1);
|
|
375
|
+
if (!mu) return;
|
|
376
|
+
state.sdkInputTokens = mu.inputTokens ?? 0;
|
|
377
|
+
state.sdkOutputTokens = mu.outputTokens ?? 0;
|
|
378
|
+
state.sdkCacheRead = mu.cacheReadInputTokens ?? 0;
|
|
379
|
+
state.sdkCacheWrite = mu.cacheCreationInputTokens ?? 0;
|
|
380
|
+
if (mu.contextWindow > 0) {
|
|
381
|
+
state.contextWindow = mu.contextWindow;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Failed turn. Two shapes (see StreamState.resultErrorText):
|
|
387
|
+
* - error subtype: no `result` field, but `errors[]` carries diagnostics.
|
|
388
|
+
* - success subtype with is_error: `result` (and the trailing assistant
|
|
389
|
+
* text) IS the API error message, e.g. "You've hit your weekly limit
|
|
390
|
+
* · resets Jul 10, 9am". Prefer that text — it's the user-facing one.
|
|
391
|
+
*/
|
|
392
|
+
function readResultError(msg: SDKResultMessage, state: StreamState): void {
|
|
393
|
+
if (msg.subtype !== "success") {
|
|
394
|
+
const diagnostics = (msg.errors ?? [])
|
|
395
|
+
.filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
|
|
396
|
+
.join("; ")
|
|
397
|
+
.slice(0, 500);
|
|
398
|
+
state.resultErrorText =
|
|
399
|
+
state.lastTrailingText.trim() ||
|
|
400
|
+
diagnostics ||
|
|
401
|
+
`Claude SDK turn failed (${msg.subtype})`;
|
|
402
|
+
} else if (msg.is_error) {
|
|
403
|
+
state.resultErrorText =
|
|
404
|
+
(typeof msg.result === "string" && msg.result.trim()) ||
|
|
405
|
+
state.lastTrailingText.trim() ||
|
|
406
|
+
"Claude SDK turn failed (API error)";
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
337
410
|
/**
|
|
338
411
|
* Process the final result message — extracts token counts, context info,
|
|
339
412
|
* and API call counts from the typed SDK result.
|
|
@@ -350,39 +423,8 @@ export function processResultMessage(
|
|
|
350
423
|
state.costUsd = msg.total_cost_usd;
|
|
351
424
|
}
|
|
352
425
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (usage && Array.isArray(usage.iterations) && usage.iterations.length > 0) {
|
|
356
|
-
const last = usage.iterations[usage.iterations.length - 1];
|
|
357
|
-
state.contextTokens =
|
|
358
|
-
(last.input_tokens ?? 0) +
|
|
359
|
-
(last.cache_read_input_tokens ?? 0) +
|
|
360
|
-
(last.cache_creation_input_tokens ?? 0);
|
|
361
|
-
// The same array answers a question the per-turn totals can't: whether
|
|
362
|
-
// the FIRST request of this turn read a cache or paid to write one.
|
|
363
|
-
state.cacheStats = turnCacheStats(usage.iterations);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Read token counts from the ACTIVE model's usage only.
|
|
367
|
-
// modelUsage is keyed by the exact SDK model string (e.g. "sonnet[1m]")
|
|
368
|
-
// and contains cumulative session totals per model — summing all entries
|
|
369
|
-
// double-counts when switching models mid-session.
|
|
370
|
-
const modelUsage: Record<string, ModelUsage> = msg.modelUsage;
|
|
371
|
-
// Drift check: modelUsage is keyed by the model that ACTUALLY served
|
|
372
|
-
// the turn. If none of the keys is (an alias of) the model we asked
|
|
373
|
-
// for, the API substituted — warn instead of burying it in the
|
|
374
|
-
// accounting line.
|
|
375
|
-
checkModelDrift(sdkModel, Object.keys(modelUsage ?? {}));
|
|
376
|
-
const mu = modelUsage[sdkModel] ?? Object.values(modelUsage).at(-1);
|
|
377
|
-
if (mu) {
|
|
378
|
-
state.sdkInputTokens = mu.inputTokens ?? 0;
|
|
379
|
-
state.sdkOutputTokens = mu.outputTokens ?? 0;
|
|
380
|
-
state.sdkCacheRead = mu.cacheReadInputTokens ?? 0;
|
|
381
|
-
state.sdkCacheWrite = mu.cacheCreationInputTokens ?? 0;
|
|
382
|
-
if (mu.contextWindow > 0) {
|
|
383
|
-
state.contextWindow = mu.contextWindow;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
426
|
+
readContextFill(state, msg.usage);
|
|
427
|
+
readModelUsage(state, msg.modelUsage, sdkModel);
|
|
386
428
|
|
|
387
429
|
log(
|
|
388
430
|
"agent",
|
|
@@ -400,26 +442,7 @@ export function processResultMessage(
|
|
|
400
442
|
state.currentBlockText = msg.result;
|
|
401
443
|
}
|
|
402
444
|
|
|
403
|
-
|
|
404
|
-
// - error subtype: no `result` field, but `errors[]` carries diagnostics.
|
|
405
|
-
// - success subtype with is_error: `result` (and the trailing assistant
|
|
406
|
-
// text) IS the API error message, e.g. "You've hit your weekly limit
|
|
407
|
-
// · resets Jul 10, 9am". Prefer that text — it's the user-facing one.
|
|
408
|
-
if (msg.subtype !== "success") {
|
|
409
|
-
const diagnostics = (msg.errors ?? [])
|
|
410
|
-
.filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
|
|
411
|
-
.join("; ")
|
|
412
|
-
.slice(0, 500);
|
|
413
|
-
state.resultErrorText =
|
|
414
|
-
state.lastTrailingText.trim() ||
|
|
415
|
-
diagnostics ||
|
|
416
|
-
`Claude SDK turn failed (${msg.subtype})`;
|
|
417
|
-
} else if (msg.is_error) {
|
|
418
|
-
state.resultErrorText =
|
|
419
|
-
(typeof msg.result === "string" && msg.result.trim()) ||
|
|
420
|
-
state.lastTrailingText.trim() ||
|
|
421
|
-
"Claude SDK turn failed (API error)";
|
|
422
|
-
}
|
|
445
|
+
readResultError(msg, state);
|
|
423
446
|
}
|
|
424
447
|
|
|
425
448
|
// ── Trailing-text fallback dedup ────────────────────────────────────────────
|
|
@@ -241,6 +241,60 @@ interface SubscribeInputs {
|
|
|
241
241
|
abortSignal: AbortSignal;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/** The SSE wire format wraps every event in `{payload: {type, properties}}`. */
|
|
245
|
+
function unwrapSseEvent(
|
|
246
|
+
evt: unknown,
|
|
247
|
+
): { type?: string; properties?: Record<string, unknown> } | undefined {
|
|
248
|
+
if (!evt || typeof evt !== "object") return undefined;
|
|
249
|
+
const payload =
|
|
250
|
+
"payload" in evt ? (evt as { payload?: unknown }).payload : evt;
|
|
251
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
252
|
+
return payload as { type?: string; properties?: Record<string, unknown> };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A `session.error` on the global stream: ignored when it belongs to
|
|
257
|
+
* another session (a heartbeat's error must not pollute this chat's log),
|
|
258
|
+
* stashed as the turn's synthetic error otherwise. Returns whether the
|
|
259
|
+
* event was ours — ours ends the turn, since the server produces nothing
|
|
260
|
+
* more for this prompt.
|
|
261
|
+
*/
|
|
262
|
+
function noteSessionError(
|
|
263
|
+
props: Record<string, unknown>,
|
|
264
|
+
inputs: Pick<SubscribeInputs, "label" | "sessionId" | "state" | "chatId">,
|
|
265
|
+
): "ours" | "other" {
|
|
266
|
+
const evtSessionID =
|
|
267
|
+
typeof props.sessionID === "string" ? props.sessionID : undefined;
|
|
268
|
+
if (evtSessionID && evtSessionID !== inputs.sessionId) return "other";
|
|
269
|
+
const errProp = props.error as
|
|
270
|
+
| { name?: string; message?: string; data?: Record<string, unknown> }
|
|
271
|
+
| undefined;
|
|
272
|
+
// MessageAbortedError is expected for an explicit user interrupt.
|
|
273
|
+
const isOurAbort =
|
|
274
|
+
inputs.state.turnTerminated &&
|
|
275
|
+
(errProp?.name === "MessageAbortedError" ||
|
|
276
|
+
/abort/i.test(errProp?.name ?? "") ||
|
|
277
|
+
/abort/i.test(errProp?.message ?? ""));
|
|
278
|
+
if (errProp && !isOurAbort) {
|
|
279
|
+
const detail = [
|
|
280
|
+
errProp.name && `name=${errProp.name}`,
|
|
281
|
+
errProp.message && `message=${errProp.message}`,
|
|
282
|
+
errProp.data && `data=${JSON.stringify(errProp.data)}`,
|
|
283
|
+
]
|
|
284
|
+
.filter(Boolean)
|
|
285
|
+
.join(" ");
|
|
286
|
+
logWarn(
|
|
287
|
+
"agent",
|
|
288
|
+
`[${inputs.chatId}] ${inputs.label} session.error: ${detail}`,
|
|
289
|
+
);
|
|
290
|
+
// Stash the error message so the handler's delivery branch can
|
|
291
|
+
// surface it as `⚠️ <label>: <message>` instead of silence.
|
|
292
|
+
const msg = errProp.message ?? errProp.name;
|
|
293
|
+
if (msg) inputs.state.syntheticError = msg;
|
|
294
|
+
}
|
|
295
|
+
return "ours";
|
|
296
|
+
}
|
|
297
|
+
|
|
244
298
|
/**
|
|
245
299
|
* Subscribe to the server's global SSE event stream and translate relevant
|
|
246
300
|
* events into stream-state mutations / callback firings. Only events scoped
|
|
@@ -271,61 +325,12 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
271
325
|
try {
|
|
272
326
|
for await (const evt of stream) {
|
|
273
327
|
if (abortSignal.aborted) break;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// The SSE wire format wraps every event in `{payload: {type,
|
|
277
|
-
// properties}}`. Unwrap here so type/properties land where the rest
|
|
278
|
-
// of the loop expects them.
|
|
279
|
-
const payload =
|
|
280
|
-
"payload" in evt ? (evt as { payload?: unknown }).payload : evt;
|
|
281
|
-
if (!payload || typeof payload !== "object") continue;
|
|
282
|
-
const event = payload as {
|
|
283
|
-
type?: string;
|
|
284
|
-
properties?: Record<string, unknown>;
|
|
285
|
-
};
|
|
328
|
+
const event = unwrapSseEvent(evt);
|
|
329
|
+
if (!event) continue;
|
|
286
330
|
|
|
287
|
-
// session.error is observed here for logging; everything else goes
|
|
288
|
-
// through the shared pure helper. The SSE stream is global, so
|
|
289
|
-
// scope-filter to our own sessionId before attributing the error to
|
|
290
|
-
// this chat — a heartbeat session.error would otherwise pollute the
|
|
291
|
-
// chat's log.
|
|
292
331
|
if (event.type === "session.error") {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
typeof props.sessionID === "string" ? props.sessionID : undefined;
|
|
296
|
-
if (evtSessionID && evtSessionID !== sessionId) {
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
const errProp = props.error as
|
|
300
|
-
| {
|
|
301
|
-
name?: string;
|
|
302
|
-
message?: string;
|
|
303
|
-
data?: Record<string, unknown>;
|
|
304
|
-
}
|
|
305
|
-
| undefined;
|
|
306
|
-
// MessageAbortedError is expected for an explicit user interrupt.
|
|
307
|
-
const isOurAbort =
|
|
308
|
-
state.turnTerminated &&
|
|
309
|
-
(errProp?.name === "MessageAbortedError" ||
|
|
310
|
-
/abort/i.test(errProp?.name ?? "") ||
|
|
311
|
-
/abort/i.test(errProp?.message ?? ""));
|
|
312
|
-
if (errProp && !isOurAbort) {
|
|
313
|
-
const detail = [
|
|
314
|
-
errProp.name && `name=${errProp.name}`,
|
|
315
|
-
errProp.message && `message=${errProp.message}`,
|
|
316
|
-
errProp.data && `data=${JSON.stringify(errProp.data)}`,
|
|
317
|
-
]
|
|
318
|
-
.filter(Boolean)
|
|
319
|
-
.join(" ");
|
|
320
|
-
logWarn("agent", `[${chatId}] ${label} session.error: ${detail}`);
|
|
321
|
-
// Stash the error message so the handler's delivery branch can
|
|
322
|
-
// surface it as `⚠️ <label>: <message>` instead of silence.
|
|
323
|
-
const msg = errProp.message ?? errProp.name;
|
|
324
|
-
if (msg) state.syntheticError = msg;
|
|
325
|
-
}
|
|
326
|
-
// session.error for OUR session ends the turn — the server isn't
|
|
327
|
-
// going to produce more events for this prompt.
|
|
328
|
-
return;
|
|
332
|
+
if (noteSessionError(event.properties ?? {}, inputs) === "ours") return;
|
|
333
|
+
continue;
|
|
329
334
|
}
|
|
330
335
|
|
|
331
336
|
const outcome = await processStreamEvent(event, {
|
|
@@ -338,7 +343,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
338
343
|
onTextBlock,
|
|
339
344
|
onToolUse,
|
|
340
345
|
});
|
|
341
|
-
|
|
342
346
|
if (outcome.kind === "terminator_fired") {
|
|
343
347
|
// tool_calls counter increment happens per-tool inside
|
|
344
348
|
// events.ts processPartUpdate. Don't double-count here.
|
|
@@ -347,7 +351,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
347
351
|
// Delivery is already complete, so wait for natural idle instead.
|
|
348
352
|
continue;
|
|
349
353
|
}
|
|
350
|
-
|
|
351
354
|
if (outcome.kind === "stop") {
|
|
352
355
|
if (outcome.reason === "out_of_scope") continue;
|
|
353
356
|
return; // turn.close or idle — stop iterating
|
package/src/bootstrap.ts
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
import { initDream, maybeStartDream } from "./core/background/dream.js";
|
|
36
36
|
import { initHeartbeat } from "./core/background/heartbeat/index.js";
|
|
37
37
|
import { log, logWarn, logDebug } from "./util/log.js";
|
|
38
|
+
import { bootPhase } from "./util/boot-timer.js";
|
|
39
|
+
import { mapConcurrent } from "./util/concurrency.js";
|
|
38
40
|
import type { TalonConfig } from "./util/config.js";
|
|
39
41
|
import { resolveFrontendIdAmong } from "./core/frontend-runtime/routing.js";
|
|
40
42
|
import type { Frontend } from "./core/frontend-runtime/index.js";
|
|
@@ -122,11 +124,11 @@ export async function bootstrap(
|
|
|
122
124
|
const frontends =
|
|
123
125
|
options.frontendNames ??
|
|
124
126
|
(Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
|
|
125
|
-
await loadPlugins(config.plugins, frontends);
|
|
127
|
+
await bootPhase("plugins", () => loadPlugins(config.plugins, frontends));
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
// Built-in plugins (GitHub, MemPalace, mem0, Playwright) — shared with hot-reload
|
|
129
|
-
await loadBuiltinPlugins(config);
|
|
131
|
+
await bootPhase("builtin plugins", () => loadBuiltinPlugins(config));
|
|
130
132
|
|
|
131
133
|
rebuildSystemPrompt(config, getPluginPromptAdditions());
|
|
132
134
|
}
|
|
@@ -143,12 +145,14 @@ export async function bootstrap(
|
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
initWorkspace(config.workspace);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
await bootPhase("stores", () => {
|
|
149
|
+
loadSessions();
|
|
150
|
+
loadChatSettings();
|
|
151
|
+
loadCronJobs();
|
|
152
|
+
loadTriggers();
|
|
153
|
+
loadHistory();
|
|
154
|
+
loadMediaIndex();
|
|
155
|
+
});
|
|
152
156
|
cleanupOldLogs();
|
|
153
157
|
|
|
154
158
|
return { config };
|
|
@@ -165,6 +169,104 @@ export async function bootstrap(
|
|
|
165
169
|
* heartbeat all read through `getActiveBackend()` so a runtime swap
|
|
166
170
|
* via `switchBackend(id, config)` propagates without any re-init.
|
|
167
171
|
*/
|
|
172
|
+
/** The backend-controller surface `reconcileChatBindings` needs. */
|
|
173
|
+
type ChatBindingDeps = {
|
|
174
|
+
isBackendAvailable: (id: string, config: TalonConfig) => boolean;
|
|
175
|
+
releaseChat: (chatId: string) => Promise<void>;
|
|
176
|
+
rebindChat: (
|
|
177
|
+
chatId: string,
|
|
178
|
+
backendId: string,
|
|
179
|
+
config: TalonConfig,
|
|
180
|
+
) => Promise<{ ok: boolean; error?: string }>;
|
|
181
|
+
getBackendIdForChat: (chatId: string) => string;
|
|
182
|
+
getBackendForChat: (chatId: string) => Backend;
|
|
183
|
+
isModelValidForBackend: (backend: Backend, model: string) => Promise<boolean>;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const CHAT_BINDING_CONCURRENCY = 8;
|
|
187
|
+
const REBIND_RETRY_DELAY_MS = 1_500;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Re-establish every chat's stored backend/model override against the
|
|
191
|
+
* backends this boot actually has. Chats are independent, so they are
|
|
192
|
+
* reconciled `CHAT_BINDING_CONCURRENCY` at a time; a shared backend that
|
|
193
|
+
* two chats need at once is initialised exactly once by the pool.
|
|
194
|
+
*/
|
|
195
|
+
export async function reconcileChatBindings(
|
|
196
|
+
config: TalonConfig,
|
|
197
|
+
deps: ChatBindingDeps,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const { getAllChatSettings } = await import("./storage/chat-settings.js");
|
|
200
|
+
await mapConcurrent(
|
|
201
|
+
Object.entries(getAllChatSettings()),
|
|
202
|
+
CHAT_BINDING_CONCURRENCY,
|
|
203
|
+
([cid, settings]) => reconcileChatBinding(cid, settings, config, deps),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function reconcileChatBinding(
|
|
208
|
+
cid: string,
|
|
209
|
+
settings: { backend?: string; model?: string },
|
|
210
|
+
config: TalonConfig,
|
|
211
|
+
deps: ChatBindingDeps,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const { getAllChatSettings, setChatBackend, setChatModel } =
|
|
214
|
+
await import("./storage/chat-settings.js");
|
|
215
|
+
let resetVolatileState = false;
|
|
216
|
+
if (settings.backend) {
|
|
217
|
+
if (!deps.isBackendAvailable(settings.backend, config)) {
|
|
218
|
+
log(
|
|
219
|
+
"bot",
|
|
220
|
+
`Per-chat backend ${settings.backend} for ${cid} is no longer available — resetting chat to default backend`,
|
|
221
|
+
);
|
|
222
|
+
await deps.releaseChat(cid);
|
|
223
|
+
setChatBackend(cid, undefined);
|
|
224
|
+
setChatModel(cid, undefined);
|
|
225
|
+
resetVolatileState = true;
|
|
226
|
+
} else {
|
|
227
|
+
let result = await deps.rebindChat(cid, settings.backend, config);
|
|
228
|
+
if (!result.ok) {
|
|
229
|
+
await new Promise((r) => setTimeout(r, REBIND_RETRY_DELAY_MS));
|
|
230
|
+
result = await deps.rebindChat(cid, settings.backend, config);
|
|
231
|
+
}
|
|
232
|
+
if (!result.ok) {
|
|
233
|
+
log(
|
|
234
|
+
"bot",
|
|
235
|
+
`Per-chat backend rebind failed for ${cid} → ${settings.backend}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const bindingMatchesSetting =
|
|
241
|
+
!settings.backend || deps.getBackendIdForChat(cid) === settings.backend;
|
|
242
|
+
const currentModel = getAllChatSettings()[cid]?.model;
|
|
243
|
+
if (currentModel && bindingMatchesSetting) {
|
|
244
|
+
const be = deps.getBackendForChat(cid);
|
|
245
|
+
try {
|
|
246
|
+
const valid = await deps.isModelValidForBackend(be, currentModel);
|
|
247
|
+
if (!valid) {
|
|
248
|
+
log(
|
|
249
|
+
"bot",
|
|
250
|
+
`Per-chat model ${currentModel} for ${cid} is not valid for its backend — resetting model to default`,
|
|
251
|
+
);
|
|
252
|
+
setChatModel(cid, undefined);
|
|
253
|
+
resetVolatileState = true;
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
log(
|
|
257
|
+
"bot",
|
|
258
|
+
`Per-chat model validation failed for ${cid} (${currentModel}): ${
|
|
259
|
+
err instanceof Error ? err.message : String(err)
|
|
260
|
+
} — keeping stored model`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (resetVolatileState) {
|
|
265
|
+
resetSession(cid);
|
|
266
|
+
clearHistory(cid);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
168
270
|
export async function initBackendAndDispatcher(
|
|
169
271
|
config: TalonConfig,
|
|
170
272
|
frontend: FrontendSelection,
|
|
@@ -258,77 +360,16 @@ export async function initBackendAndDispatcher(
|
|
|
258
360
|
// for the backend that would serve it, clear the override and reset
|
|
259
361
|
// volatile chat state so the next user message starts a fresh default
|
|
260
362
|
// session instead of crashing on an orphaned model id.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
);
|
|
272
|
-
await releaseChat(cid);
|
|
273
|
-
setChatBackend(cid, undefined);
|
|
274
|
-
setChatModel(cid, undefined);
|
|
275
|
-
resetVolatileState = true;
|
|
276
|
-
} else {
|
|
277
|
-
// A boot-time rebind failure is usually TRANSIENT (backend slow to
|
|
278
|
-
// spawn, auth endpoint briefly unreachable) — it must not destroy
|
|
279
|
-
// the user's persisted choice, session, and history. Retry once,
|
|
280
|
-
// then keep the setting and move on: the chat runs on the default
|
|
281
|
-
// backend until a later switch succeeds, and clients keep showing
|
|
282
|
-
// the user's chosen backend from settings (the source of truth).
|
|
283
|
-
let result = await rebindChat(cid, settings.backend, config);
|
|
284
|
-
if (!result.ok) {
|
|
285
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
286
|
-
result = await rebindChat(cid, settings.backend, config);
|
|
287
|
-
}
|
|
288
|
-
if (!result.ok) {
|
|
289
|
-
log(
|
|
290
|
-
"bot",
|
|
291
|
-
`Per-chat backend rebind failed for ${cid} → ${settings.backend}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Only validate the stored model against the backend that will really
|
|
298
|
-
// serve this chat. After a kept-but-unbound override (transient rebind
|
|
299
|
-
// failure above) the chat temporarily runs on the default backend — the
|
|
300
|
-
// stored model belongs to the chosen backend, so validating it against
|
|
301
|
-
// the default would wrongly clear it.
|
|
302
|
-
const bindingMatchesSetting =
|
|
303
|
-
!settings.backend || getBackendIdForChat(cid) === settings.backend;
|
|
304
|
-
const currentModel = getAllChatSettings()[cid]?.model;
|
|
305
|
-
if (currentModel && bindingMatchesSetting) {
|
|
306
|
-
const be = getBackendForChat(cid);
|
|
307
|
-
try {
|
|
308
|
-
const valid = await isModelValidForBackend(be, currentModel);
|
|
309
|
-
if (!valid) {
|
|
310
|
-
log(
|
|
311
|
-
"bot",
|
|
312
|
-
`Per-chat model ${currentModel} for ${cid} is not valid for its backend — resetting model to default`,
|
|
313
|
-
);
|
|
314
|
-
setChatModel(cid, undefined);
|
|
315
|
-
resetVolatileState = true;
|
|
316
|
-
}
|
|
317
|
-
} catch (err) {
|
|
318
|
-
log(
|
|
319
|
-
"bot",
|
|
320
|
-
`Per-chat model validation failed for ${cid} (${currentModel}): ${
|
|
321
|
-
err instanceof Error ? err.message : String(err)
|
|
322
|
-
} — keeping stored model`,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (resetVolatileState) {
|
|
328
|
-
resetSession(cid);
|
|
329
|
-
clearHistory(cid);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
363
|
+
await bootPhase("chat bindings", () =>
|
|
364
|
+
reconcileChatBindings(config, {
|
|
365
|
+
isBackendAvailable,
|
|
366
|
+
releaseChat,
|
|
367
|
+
rebindChat,
|
|
368
|
+
getBackendIdForChat,
|
|
369
|
+
getBackendForChat,
|
|
370
|
+
isModelValidForBackend,
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
332
373
|
|
|
333
374
|
initDispatcher({
|
|
334
375
|
// Dispatcher reads the backend per query so per-chat overrides
|