talon-agent 5.18.2 → 5.19.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/README.md +2 -1
- package/package.json +2 -2
- package/prompts/system/agent-brief.md +20 -3
- package/src/app.ts +13 -0
- package/src/backend/claude-sdk/handler.ts +4 -4
- package/src/backend/claude-sdk/mcp-ready.ts +16 -2
- package/src/backend/claude-sdk/one-shot.ts +32 -2
- package/src/backend/claude-sdk/stream.ts +2 -2
- package/src/backend/codex/auth.ts +1 -1
- package/src/backend/codex/handler/message.ts +11 -11
- package/src/backend/codex/init.ts +4 -9
- package/src/backend/codex/mcp-config.ts +1 -2
- package/src/backend/codex/oauth-incompat.ts +8 -4
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/openai-agents/builtins.ts +55 -27
- package/src/backend/openai-agents/factory.ts +3 -3
- package/src/backend/openai-agents/handler/message.ts +3 -5
- package/src/backend/openai-agents/mcp-pool.ts +10 -27
- package/src/backend/remote-server/chat-turn.ts +6 -6
- package/src/backend/remote-server/events.ts +1 -5
- package/src/backend/remote-server/index.ts +0 -1
- package/src/backend/remote-server/messages.ts +3 -7
- package/src/backend/remote-server/one-shot.ts +1 -3
- package/src/backend/remote-server/session-helpers.ts +1 -4
- package/src/backend/remote-server/sse-stream.ts +8 -9
- package/src/backend/runtime/metrics.ts +7 -13
- package/src/backend/runtime/sleep.ts +1 -2
- package/src/backend/runtime/turn/handle-retry.ts +48 -2
- package/src/backend/runtime/turn/handler-to-events.ts +3 -4
- package/src/bootstrap.ts +9 -1
- package/src/cli/doctor.ts +3 -0
- package/src/cli/index.ts +8 -10
- package/src/cli/logs.ts +148 -9
- package/src/cli/setup.ts +9 -11
- package/src/cli/status.ts +29 -0
- package/src/core/agent-runtime/README.md +5 -19
- package/src/core/agent-runtime/events.ts +3 -39
- package/src/core/agent-runtime/model-ref.ts +0 -8
- package/src/core/agents/registry.ts +65 -2
- package/src/core/auth/expiry-monitor.ts +9 -1
- package/src/core/auth/login-flow.ts +9 -1
- package/src/core/auth/status.ts +31 -3
- package/src/core/background/cron/scheduler.ts +25 -5
- package/src/core/background/dream/index.ts +29 -10
- package/src/core/background/failure-backoff.ts +30 -0
- package/src/core/background/heartbeat/agent.ts +2 -27
- package/src/core/background/heartbeat/index.ts +0 -2
- package/src/core/background/heartbeat/scheduler.ts +21 -13
- package/src/core/background/heartbeat/state.ts +10 -2
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/background/pulse/pulse.ts +9 -0
- package/src/core/background/triggers/exit.ts +54 -0
- package/src/core/background/triggers/index.ts +1 -3
- package/src/core/background/triggers/resume.ts +2 -4
- package/src/core/backup/archive/tar.ts +14 -4
- package/src/core/backup/plan.ts +1 -0
- package/src/core/backup/restore.ts +21 -13
- package/src/core/backup/scheduler.ts +28 -9
- package/src/core/backup/snapshot.ts +43 -10
- package/src/core/backup/store.ts +7 -19
- package/src/core/backup/targets.ts +69 -17
- package/src/core/config/index.ts +14 -0
- package/src/core/daemon/crash-marker.ts +141 -0
- package/src/core/daemon/crash.ts +9 -2
- package/src/core/daemon/handoff.ts +15 -0
- package/src/core/daemon/health-alerts.ts +297 -0
- package/src/core/daemon/log-reader.ts +289 -0
- package/src/core/doctor/index.ts +18 -2
- package/src/core/doctor/logs.ts +124 -0
- package/src/core/doctor/types.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -13
- package/src/core/engine/backend-router/router.ts +1 -1
- package/src/core/engine/dispatcher.ts +55 -2
- package/src/core/engine/fault-text.ts +40 -0
- package/src/core/engine/gateway-actions/agents/index.ts +3 -2
- package/src/core/engine/gateway-actions/agents/report.ts +62 -0
- package/src/core/engine/gateway-actions/history.ts +2 -4
- package/src/core/engine/gateway-actions/mesh.ts +26 -14
- package/src/core/engine/gateway-actions/native/exec.ts +13 -16
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway.ts +60 -1
- package/src/core/engine/turn-health.ts +222 -0
- package/src/core/errors.ts +2 -2
- package/src/core/frontend-runtime/admin-notify.ts +1 -1
- package/src/core/frontend-runtime/alerts.ts +130 -0
- package/src/core/mcp-hub/children.ts +78 -29
- package/src/core/mcp-hub/index.ts +21 -18
- package/src/core/mcp-hub/proxy-server.ts +8 -4
- package/src/core/mcp-hub/talon-server.ts +5 -12
- package/src/core/mesh/credentials/store.ts +16 -1
- package/src/core/mesh/devices/registry.ts +1 -17
- package/src/core/mesh/devices/service.ts +49 -17
- package/src/core/mesh/devices/teleport.ts +14 -2
- package/src/core/mesh/links/node-binaries.ts +13 -6
- package/src/core/mesh/persist.ts +22 -10
- package/src/core/mesh/transfers/device-files.ts +5 -23
- package/src/core/mesh/transfers/transfers.ts +16 -3
- package/src/core/models/active-model.ts +2 -55
- package/src/core/plugin/actions.ts +19 -20
- package/src/core/plugin/builtins.ts +80 -90
- package/src/core/plugin/index.ts +1 -4
- package/src/core/plugin/loader.ts +25 -33
- package/src/core/plugin/mcp.ts +3 -5
- package/src/core/plugin/registry.ts +19 -35
- package/src/core/plugin/types.ts +2 -5
- package/src/core/prompt/assemble.ts +15 -3
- package/src/core/scripts/lua.ts +6 -2
- package/src/core/tasks/table.ts +8 -2
- package/src/core/tools/bridge.ts +2 -4
- package/src/core/tools/chat/cross-send.ts +1 -1
- package/src/core/tools/chat/messaging.ts +1 -1
- package/src/core/tools/index.ts +2 -2
- package/src/core/tools/mcp-env.ts +2 -59
- package/src/core/tools/ops/agents.ts +22 -1
- package/src/core/tools/schemas.ts +4 -9
- package/src/core/vfs/fusefs.ts +0 -5
- package/src/core/vfs/index.ts +9 -2
- package/src/core/vfs/mounts/diagnostics.ts +109 -0
- package/src/core/vfs/mounts/proc.ts +17 -1
- package/src/core/vfs/workspace.ts +7 -3
- package/src/core/weaver/shuttle.ts +8 -1
- package/src/core/weaver/turn-log.ts +320 -0
- package/src/core/weaver/weaver.ts +48 -5
- package/src/frontend/discord/actions/index.ts +8 -1
- package/src/frontend/discord/diagnostics.ts +82 -6
- package/src/frontend/discord/handlers/index.ts +0 -2
- package/src/frontend/discord/middleware.ts +7 -13
- package/src/frontend/discord/runtime.ts +1 -3
- package/src/frontend/health/delivery.ts +115 -0
- package/src/frontend/health/outage.ts +116 -0
- package/src/frontend/native/bridge/routes/chats.ts +3 -5
- package/src/frontend/native/bridge/server.ts +106 -21
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/media/media.ts +5 -1
- package/src/frontend/native/runtime.ts +12 -7
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/frontend/native/surface/memory.ts +1 -1
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/surface/settings.ts +20 -8
- package/src/frontend/native/turn/context.ts +6 -8
- package/src/frontend/native/turn/turn-meta.ts +2 -5
- package/src/frontend/native/turn/turn.ts +8 -10
- package/src/frontend/presentation/format.ts +2 -4
- package/src/frontend/presentation/session-status.ts +2 -6
- package/src/frontend/teams/actions.ts +8 -1
- package/src/frontend/teams/graph.ts +0 -1
- package/src/frontend/teams/index.ts +1 -4
- package/src/frontend/teams/poll.ts +40 -2
- package/src/frontend/teams/runtime.ts +14 -5
- package/src/frontend/telegram/actions/index.ts +4 -1
- package/src/frontend/telegram/actions/send.ts +8 -0
- package/src/frontend/telegram/handlers/context.ts +13 -2
- package/src/frontend/telegram/handlers/delivery.ts +12 -9
- package/src/frontend/telegram/handlers/index.ts +0 -2
- package/src/frontend/telegram/index.ts +35 -9
- package/src/frontend/telegram/polling/poll-health.ts +110 -0
- package/src/frontend/telegram/userbot.ts +100 -36
- package/src/frontend/terminal/builtins/session.ts +2 -2
- package/src/frontend/terminal/index.ts +1 -3
- package/src/frontend/terminal/renderer.ts +2 -18
- package/src/frontend/whatsapp/actions/index.ts +12 -1
- package/src/frontend/whatsapp/actions/messaging.ts +7 -2
- package/src/frontend/whatsapp/connection/connection.ts +29 -9
- package/src/frontend/whatsapp/connection/health.ts +89 -0
- package/src/frontend/whatsapp/connection/identity.ts +4 -4
- package/src/frontend/whatsapp/runtime.ts +11 -5
- package/src/native/blake3.ts +28 -2
- package/src/native/fusefs.ts +23 -5
- package/src/native/registry.ts +1 -1
- package/src/native/warden.ts +33 -5
- package/src/plugins/github/index.ts +0 -1
- package/src/plugins/mempalace/index.ts +9 -3
- package/src/plugins/playwright/index.ts +2 -4
- package/src/plugins/playwright/provision.ts +12 -4
- package/src/storage/chat-settings.ts +6 -1
- package/src/storage/cron.ts +29 -4
- package/src/storage/daily-log.ts +43 -47
- package/src/storage/db.ts +61 -33
- package/src/storage/history.ts +6 -1
- package/src/storage/journal.ts +9 -2
- package/src/storage/kv.ts +19 -6
- package/src/storage/media-index.ts +28 -6
- package/src/storage/repositories/chat-settings-repo.ts +8 -2
- package/src/storage/repositories/sessions-repo.ts +10 -3
- package/src/storage/scripts.ts +24 -13
- package/src/storage/sessions.ts +11 -2
- package/src/storage/skills.ts +21 -2
- package/src/storage/stickers.ts +17 -3
- package/src/storage/triggers.ts +8 -3
- package/src/storage/turn-meta.ts +25 -7
- package/src/util/log.ts +189 -6
- package/src/util/logging/turn-scope.ts +85 -0
- package/src/util/time.ts +3 -3
- package/src/util/watchdog.ts +30 -0
- package/src/core/engine/backend-controller/legacy.ts +0 -111
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic proc views — "what went wrong" without knowing a path:
|
|
3
|
+
*
|
|
4
|
+
* proc/log the last lines of talon.log, one human line each
|
|
5
|
+
* proc/errors recent warn/error/fatal entries, with stack frames
|
|
6
|
+
* proc/alerts the operator alerts currently raised
|
|
7
|
+
*
|
|
8
|
+
* Rendered text, not pino JSON, so `cat` answers the question directly.
|
|
9
|
+
* The log views read a bounded tail of talon.log (plus its newest
|
|
10
|
+
* rotated generation when the live file is young) — a regular file
|
|
11
|
+
* outside ~/.talon/ns, so the FUSE deadlock rule is not in play — and
|
|
12
|
+
* memoize for a second: FUSE stats a node before reading it, and both
|
|
13
|
+
* should see the same text without reading the log twice.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
formatLogRecord,
|
|
18
|
+
readLogRecords,
|
|
19
|
+
type LogRecord,
|
|
20
|
+
} from "../../daemon/log-reader.js";
|
|
21
|
+
|
|
22
|
+
export type AlertView = {
|
|
23
|
+
key: string;
|
|
24
|
+
severity: string;
|
|
25
|
+
message: string;
|
|
26
|
+
since: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface DiagnosticViewDeps {
|
|
30
|
+
/** talon.log. */
|
|
31
|
+
logPath: string;
|
|
32
|
+
/** The live alert table (`activeAlerts`). */
|
|
33
|
+
alerts: () => ReadonlyArray<AlertView>;
|
|
34
|
+
now?: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const LOG_LINES = 200;
|
|
38
|
+
const ERROR_ENTRIES = 100;
|
|
39
|
+
const MEMO_MS = 1_000;
|
|
40
|
+
/** Two generations: the live file and the one it rotated from. */
|
|
41
|
+
const READ_OPTS = { maxFiles: 2, maxBytesPerFile: 1024 * 1024 } as const;
|
|
42
|
+
|
|
43
|
+
function memo(render: () => string, now: () => number): () => string {
|
|
44
|
+
let cached: { at: number; text: string } | null = null;
|
|
45
|
+
return () => {
|
|
46
|
+
const t = now();
|
|
47
|
+
if (cached && t - cached.at < MEMO_MS) return cached.text;
|
|
48
|
+
cached = { at: t, text: render() };
|
|
49
|
+
return cached.text;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function body(records: LogRecord[], stackLines: number, max: number): string {
|
|
54
|
+
return records
|
|
55
|
+
.map((r) => formatLogRecord(r, { stackLines, maxMsgChars: max }))
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderLogView(logPath: string): string {
|
|
60
|
+
const records = readLogRecords(logPath, { limit: LOG_LINES, ...READ_OPTS });
|
|
61
|
+
if (records.length === 0) return `# ${logPath} is empty or missing\n`;
|
|
62
|
+
return (
|
|
63
|
+
`# last ${records.length} lines of ${logPath}, newest last ` +
|
|
64
|
+
`(older: ${logPath}.1…, or \`talon logs --since 1h\`)\n` +
|
|
65
|
+
`${body(records, 0, 400)}\n`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderErrorsView(logPath: string): string {
|
|
70
|
+
const records = readLogRecords(logPath, {
|
|
71
|
+
limit: ERROR_ENTRIES,
|
|
72
|
+
filter: { minLevel: "warn" },
|
|
73
|
+
...READ_OPTS,
|
|
74
|
+
});
|
|
75
|
+
if (records.length === 0) {
|
|
76
|
+
return `# no warnings or errors in the recent ${logPath}\n`;
|
|
77
|
+
}
|
|
78
|
+
return (
|
|
79
|
+
`# last ${records.length} warn/error entries from ${logPath}, newest last\n` +
|
|
80
|
+
`${body(records, 5, 500)}\n`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderAlertsView(
|
|
85
|
+
alerts: ReadonlyArray<AlertView>,
|
|
86
|
+
now: number,
|
|
87
|
+
): string {
|
|
88
|
+
if (alerts.length === 0) return "# no active alerts\n";
|
|
89
|
+
const lines = alerts.map((a) => {
|
|
90
|
+
const mins = Math.max(0, Math.round((now - a.since) / 60_000));
|
|
91
|
+
return (
|
|
92
|
+
`[${a.severity}] ${a.key} since ${new Date(a.since).toISOString()} ` +
|
|
93
|
+
`(${mins} min)\n ${a.message}`
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
return `# ${alerts.length} active alert(s)\n${lines.join("\n")}\n`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The proc views, keyed by file name — wired by the vfs index. */
|
|
100
|
+
export function createDiagnosticViews(
|
|
101
|
+
deps: DiagnosticViewDeps,
|
|
102
|
+
): Record<string, () => string> {
|
|
103
|
+
const now = deps.now ?? Date.now;
|
|
104
|
+
return {
|
|
105
|
+
log: memo(() => renderLogView(deps.logPath), now),
|
|
106
|
+
errors: memo(() => renderErrorsView(deps.logPath), now),
|
|
107
|
+
alerts: () => renderAlertsView(deps.alerts(), now()),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* proc/
|
|
6
6
|
* tasks/<id> one task table record, pretty JSON
|
|
7
7
|
* events the event bus ring, JSON Lines (newest last)
|
|
8
|
+
* <view> any extra text views the index wires (log, errors,
|
|
9
|
+
* alerts — see ./diagnostics.ts)
|
|
8
10
|
*
|
|
9
11
|
* Providers are injected so the mount is a pure projection — the index
|
|
10
12
|
* wires the real task table and bus, tests wire fixtures. Sizes and
|
|
@@ -20,6 +22,8 @@ import { vfsError, vfsOk } from "../types.js";
|
|
|
20
22
|
export interface ProcMountDeps {
|
|
21
23
|
tasks: () => readonly TaskRecord[];
|
|
22
24
|
events: () => readonly PublishedEvent[];
|
|
25
|
+
/** Extra read-only text files at the proc root, name → renderer. */
|
|
26
|
+
views?: Readonly<Record<string, () => string>>;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
const DIR = (path: string, name: string): VfsStat => ({
|
|
@@ -54,6 +58,13 @@ function renderEvents(events: readonly PublishedEvent[]): string {
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
export function createProcMount(deps: ProcMountDeps): VfsMount {
|
|
61
|
+
const views: Readonly<Record<string, () => string>> = Object.assign(
|
|
62
|
+
Object.create(null) as Record<string, () => string>,
|
|
63
|
+
deps.views,
|
|
64
|
+
);
|
|
65
|
+
const viewStats = (): VfsStat[] =>
|
|
66
|
+
Object.keys(views).map((name) => fileStat(name, views[name]()));
|
|
67
|
+
|
|
57
68
|
function taskById(id: string): TaskRecord | undefined {
|
|
58
69
|
if (!/^\d+$/.test(id)) return undefined;
|
|
59
70
|
return deps.tasks().find((task) => task.id === Number(id));
|
|
@@ -66,6 +77,7 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
|
|
|
66
77
|
const events = deps.events();
|
|
67
78
|
return vfsOk(fileStat("events", renderEvents(events), events.at(-1)?.at));
|
|
68
79
|
}
|
|
80
|
+
if (views[rel]) return vfsOk(fileStat(rel, views[rel]()));
|
|
69
81
|
const taskId = rel.startsWith("tasks/") ? rel.slice("tasks/".length) : null;
|
|
70
82
|
if (taskId !== null && !taskId.includes("/")) {
|
|
71
83
|
const task = taskById(taskId);
|
|
@@ -78,7 +90,9 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
|
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
return {
|
|
81
|
-
description:
|
|
93
|
+
description: deps.views
|
|
94
|
+
? "Live daemon state: task table, event bus ring, daemon log views"
|
|
95
|
+
: "Live daemon state: task table and event bus ring",
|
|
82
96
|
writable: false,
|
|
83
97
|
|
|
84
98
|
stat: resolveNode,
|
|
@@ -89,6 +103,7 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
|
|
|
89
103
|
return vfsOk([
|
|
90
104
|
DIR("tasks", "tasks"),
|
|
91
105
|
fileStat("events", renderEvents(events), events.at(-1)?.at),
|
|
106
|
+
...viewStats(),
|
|
92
107
|
]);
|
|
93
108
|
}
|
|
94
109
|
if (rel === "tasks") {
|
|
@@ -112,6 +127,7 @@ export function createProcMount(deps: ProcMountDeps): VfsMount {
|
|
|
112
127
|
|
|
113
128
|
read(rel) {
|
|
114
129
|
if (rel === "events") return vfsOk(renderEvents(deps.events()));
|
|
130
|
+
if (views[rel]) return vfsOk(views[rel]());
|
|
115
131
|
if (rel.startsWith("tasks/")) {
|
|
116
132
|
const task = taskById(rel.slice("tasks/".length));
|
|
117
133
|
if (!task) return vfsError("not-found");
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "node:fs";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { join, resolve } from "node:path";
|
|
23
|
+
import writeFileAtomic from "write-file-atomic";
|
|
23
24
|
import { log } from "../../util/log.js";
|
|
24
25
|
import { dirs, files as pathFiles } from "../../util/paths.js";
|
|
25
26
|
import { listSeedPrompts, readPromptAsset } from "#prompt-assets";
|
|
@@ -259,8 +260,11 @@ function seedPrompts(): void {
|
|
|
259
260
|
const pkgHash = sha256(pkg);
|
|
260
261
|
|
|
261
262
|
if (!existsSync(dst)) {
|
|
263
|
+
// Atomic: a write cut short (ENOSPC, crash) must leave no file
|
|
264
|
+
// rather than a truncated one, which the next boot could not tell
|
|
265
|
+
// from a user edit and would keep forever.
|
|
262
266
|
try {
|
|
263
|
-
|
|
267
|
+
writeFileAtomic.sync(dst, pkg);
|
|
264
268
|
} catch (err) {
|
|
265
269
|
log(
|
|
266
270
|
"workspace",
|
|
@@ -293,7 +297,7 @@ function seedPrompts(): void {
|
|
|
293
297
|
} else if (seededHash !== undefined && curHash === seededHash) {
|
|
294
298
|
// Pristine seeded copy + package changed → refresh on upgrade.
|
|
295
299
|
try {
|
|
296
|
-
|
|
300
|
+
writeFileAtomic.sync(dst, pkg);
|
|
297
301
|
} catch {
|
|
298
302
|
continue; // manifest keeps the old hash — retried next boot
|
|
299
303
|
}
|
|
@@ -308,7 +312,7 @@ function seedPrompts(): void {
|
|
|
308
312
|
|
|
309
313
|
if (manifestDirty) {
|
|
310
314
|
try {
|
|
311
|
-
|
|
315
|
+
writeFileAtomic.sync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
312
316
|
} catch {
|
|
313
317
|
/* non-fatal — worst case we re-derive next boot */
|
|
314
318
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type AgentResult,
|
|
28
28
|
} from "../agent-runtime/events.js";
|
|
29
29
|
import { recordTurnActivity } from "../../util/watchdog.js";
|
|
30
|
+
import type { TurnToolLog } from "./turn-log.js";
|
|
30
31
|
|
|
31
32
|
export type EventSink = (event: AgentEvent) => void | Promise<void>;
|
|
32
33
|
|
|
@@ -45,12 +46,14 @@ export const startShuttleTiming = (): ShuttleTiming => ({ deliveryMs: 0 });
|
|
|
45
46
|
/**
|
|
46
47
|
* Pump the stream to completion. Returns the `completed` event's
|
|
47
48
|
* result (if the backend emitted one); throws `AgentRunError` when the
|
|
48
|
-
* stream terminates with an `error` event.
|
|
49
|
+
* stream terminates with an `error` event. `tools`, when given, sees
|
|
50
|
+
* every tool call/result pair for the turn's `tool.call` log lines.
|
|
49
51
|
*/
|
|
50
52
|
export async function carryTurnEvents(
|
|
51
53
|
stream: AsyncIterable<AgentEvent>,
|
|
52
54
|
onEvent?: EventSink,
|
|
53
55
|
timing?: ShuttleTiming,
|
|
56
|
+
tools?: TurnToolLog,
|
|
54
57
|
): Promise<AgentResult | undefined> {
|
|
55
58
|
let agentResult: AgentResult | undefined;
|
|
56
59
|
const sink: EventSink | undefined =
|
|
@@ -73,6 +76,10 @@ export async function carryTurnEvents(
|
|
|
73
76
|
}
|
|
74
77
|
if (event.type === "completed") {
|
|
75
78
|
agentResult = event.result;
|
|
79
|
+
} else if (event.type === "tool_call") {
|
|
80
|
+
tools?.onCall(event);
|
|
81
|
+
} else if (event.type === "tool_result") {
|
|
82
|
+
tools?.onResult(event);
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
if (event.type === "assistant_message" && event.deliveryAck) {
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn log — the greppable lifecycle lines for one turn, so talon.log
|
|
3
|
+
* alone can answer "what happened in that conversation":
|
|
4
|
+
*
|
|
5
|
+
* turn.queued turn=… chat=… trigger=… depth=… (behind a running turn)
|
|
6
|
+
* turn.start turn=… chat=… frontend=… backend=… model=… trigger=… queue=… wait_ms=…
|
|
7
|
+
* tool.start turn=… chat=… name=… id=… args=<≤80> (debug)
|
|
8
|
+
* tool.slow turn=… chat=… name=… id=… running_ms=… (still running at 60s)
|
|
9
|
+
* tool.call turn=… chat=… name=… ms=… ok=… [bytes=…] [err=…]
|
|
10
|
+
* turn.error turn=… chat=… class=auth|quota|timeout|tool|backend|unknown msg=…
|
|
11
|
+
* turn.end turn=… chat=… … ms=… outcome=ok|error|aborted|timeout|refused tools=N in_tokens=…
|
|
12
|
+
*
|
|
13
|
+
* Every other line written during the turn carries `turn=<id>` too, via
|
|
14
|
+
* the log context (util/logging/turn-scope.ts). Free text (`msg=`, `err=`) is
|
|
15
|
+
* always the LAST field, collapsed to one line and capped, so the
|
|
16
|
+
* key=value fields before it stay machine-parseable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AgentRunError, type AgentEvent } from "../agent-runtime/events.js";
|
|
20
|
+
import { classify, type TalonError } from "../errors.js";
|
|
21
|
+
import { resolveOwnerFrontendId } from "../frontend-runtime/routing.js";
|
|
22
|
+
import { log, logDebug, logWarn } from "../../util/log.js";
|
|
23
|
+
|
|
24
|
+
type TurnOutcome = "ok" | "error" | "aborted" | "timeout" | "refused";
|
|
25
|
+
|
|
26
|
+
type TurnErrorClass =
|
|
27
|
+
"auth" | "quota" | "timeout" | "tool" | "backend" | "aborted" | "unknown";
|
|
28
|
+
|
|
29
|
+
/** A tool still running at this age gets a `tool.slow` line. */
|
|
30
|
+
const SLOW_TOOL_MS = 60_000;
|
|
31
|
+
/** Cap for free-text fields (error messages). */
|
|
32
|
+
const MAX_TEXT = 200;
|
|
33
|
+
/** Cap for the debug-level tool args preview. */
|
|
34
|
+
const MAX_ARGS_PREVIEW = 80;
|
|
35
|
+
|
|
36
|
+
/** One line, capped — safe to put at the end of a key=value log line. */
|
|
37
|
+
function oneLine(text: string, max = MAX_TEXT): string {
|
|
38
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
39
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errorText(err: unknown): string {
|
|
43
|
+
return oneLine(err instanceof Error ? err.message : String(err));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Serialized size of a tool result, or undefined when there is none. */
|
|
47
|
+
function resultBytes(result: unknown): number | undefined {
|
|
48
|
+
if (result === undefined) return undefined;
|
|
49
|
+
if (typeof result === "string") return Buffer.byteLength(result);
|
|
50
|
+
try {
|
|
51
|
+
return Buffer.byteLength(JSON.stringify(result) ?? "");
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function argsPreview(input: unknown): string {
|
|
58
|
+
try {
|
|
59
|
+
return oneLine(JSON.stringify(input) ?? "", MAX_ARGS_PREVIEW);
|
|
60
|
+
} catch {
|
|
61
|
+
return "[unserializable]";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type OpenToolCall = {
|
|
66
|
+
name: string;
|
|
67
|
+
startedAt: number;
|
|
68
|
+
slowTimer: ReturnType<typeof setTimeout>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Per-turn tool-call ledger, fed by the Shuttle from the canonical
|
|
73
|
+
* `tool_call` / `tool_result` events — the one place every backend's
|
|
74
|
+
* tools (SDK built-ins and Talon's MCP tools alike) pass through.
|
|
75
|
+
*/
|
|
76
|
+
export class TurnToolLog {
|
|
77
|
+
private readonly turnId: string;
|
|
78
|
+
private readonly chatId: string;
|
|
79
|
+
private readonly slowMs: number;
|
|
80
|
+
private readonly open = new Map<string, OpenToolCall>();
|
|
81
|
+
private calls = 0;
|
|
82
|
+
|
|
83
|
+
constructor(turnId: string, chatId: string, slowMs = SLOW_TOOL_MS) {
|
|
84
|
+
this.turnId = turnId;
|
|
85
|
+
this.chatId = chatId;
|
|
86
|
+
this.slowMs = slowMs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Tool calls seen this turn. */
|
|
90
|
+
get count(): number {
|
|
91
|
+
return this.calls;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onCall(event: Extract<AgentEvent, { type: "tool_call" }>): void {
|
|
95
|
+
this.calls++;
|
|
96
|
+
const prefix = `turn=${this.turnId} chat=${this.chatId} name=${event.name} id=${event.id}`;
|
|
97
|
+
logDebug(
|
|
98
|
+
"dispatcher",
|
|
99
|
+
`tool.start ${prefix} args=${argsPreview(event.input)}`,
|
|
100
|
+
);
|
|
101
|
+
const startedAt = Date.now();
|
|
102
|
+
const slowTimer = setTimeout(() => {
|
|
103
|
+
log(
|
|
104
|
+
"dispatcher",
|
|
105
|
+
`tool.slow ${prefix} running_ms=${Date.now() - startedAt}`,
|
|
106
|
+
);
|
|
107
|
+
}, this.slowMs);
|
|
108
|
+
// A tool that outlives its turn must not hold the process open.
|
|
109
|
+
slowTimer.unref?.();
|
|
110
|
+
const previous = this.open.get(event.id);
|
|
111
|
+
if (previous) clearTimeout(previous.slowTimer);
|
|
112
|
+
this.open.set(event.id, { name: event.name, startedAt, slowTimer });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onResult(event: Extract<AgentEvent, { type: "tool_result" }>): void {
|
|
116
|
+
const call = this.open.get(event.id);
|
|
117
|
+
if (call) {
|
|
118
|
+
clearTimeout(call.slowTimer);
|
|
119
|
+
this.open.delete(event.id);
|
|
120
|
+
}
|
|
121
|
+
const ms = call ? Date.now() - call.startedAt : 0;
|
|
122
|
+
const bytes = resultBytes(event.result);
|
|
123
|
+
const line =
|
|
124
|
+
`tool.call turn=${this.turnId} chat=${this.chatId} name=${event.name} ms=${ms} ` +
|
|
125
|
+
`ok=${event.error === undefined}` +
|
|
126
|
+
(bytes === undefined ? "" : ` bytes=${bytes}`);
|
|
127
|
+
if (event.error === undefined) log("dispatcher", line);
|
|
128
|
+
else logWarn("dispatcher", `${line} err=${oneLine(event.error)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Turn settled: stop the slow-tool timers, note calls that never resolved. */
|
|
132
|
+
close(): void {
|
|
133
|
+
for (const [id, call] of this.open) {
|
|
134
|
+
clearTimeout(call.slowTimer);
|
|
135
|
+
logDebug(
|
|
136
|
+
"dispatcher",
|
|
137
|
+
`tool.unsettled turn=${this.turnId} chat=${this.chatId} name=${call.name} id=${id} ms=${Date.now() - call.startedAt}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
this.open.clear();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What one turn's lifecycle lines report — filled in as the turn resolves. */
|
|
145
|
+
export type TurnTrace = {
|
|
146
|
+
readonly turnId: string;
|
|
147
|
+
readonly chatId: string;
|
|
148
|
+
readonly trigger: string;
|
|
149
|
+
readonly frontend: string;
|
|
150
|
+
/** Turns queued or running ahead of this one when it was enqueued. */
|
|
151
|
+
readonly queuedBehind: number;
|
|
152
|
+
readonly enqueuedAt: number;
|
|
153
|
+
readonly tools: TurnToolLog;
|
|
154
|
+
startedAt?: number;
|
|
155
|
+
backend?: string;
|
|
156
|
+
model?: string;
|
|
157
|
+
/** Why the turn was answered with a refusal instead of a backend run. */
|
|
158
|
+
refused?: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function head(trace: TurnTrace): string {
|
|
162
|
+
return `turn=${trace.turnId} chat=${trace.chatId}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The trace for a turn being enqueued. `queuedBehind` is how many turns
|
|
167
|
+
* the chat's FIFO already holds; when non-zero the turn has to wait, and
|
|
168
|
+
* that is logged now — the wait is otherwise invisible until it starts.
|
|
169
|
+
*/
|
|
170
|
+
export function createTurnTrace(
|
|
171
|
+
turnId: string,
|
|
172
|
+
params: { chatId: string; source: string },
|
|
173
|
+
queuedBehind: number,
|
|
174
|
+
): TurnTrace {
|
|
175
|
+
const trace: TurnTrace = {
|
|
176
|
+
turnId,
|
|
177
|
+
chatId: params.chatId,
|
|
178
|
+
trigger: params.source,
|
|
179
|
+
frontend:
|
|
180
|
+
resolveOwnerFrontendId(params.chatId, { includeNonMessaging: true }) ??
|
|
181
|
+
"unknown",
|
|
182
|
+
queuedBehind,
|
|
183
|
+
enqueuedAt: Date.now(),
|
|
184
|
+
tools: new TurnToolLog(turnId, params.chatId),
|
|
185
|
+
};
|
|
186
|
+
if (queuedBehind > 0) logTurnQueued(trace);
|
|
187
|
+
return trace;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A turn is waiting behind others in its chat's FIFO. */
|
|
191
|
+
function logTurnQueued(trace: TurnTrace): void {
|
|
192
|
+
log(
|
|
193
|
+
"dispatcher",
|
|
194
|
+
`turn.queued ${head(trace)} trigger=${trace.trigger} depth=${trace.queuedBehind}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The turn resolved its model and is about to reach the backend (or
|
|
200
|
+
* refuse): bind the resolution onto the trace and write `turn.start`.
|
|
201
|
+
*/
|
|
202
|
+
export function logTurnStart(
|
|
203
|
+
trace: TurnTrace,
|
|
204
|
+
warp: { backendId: string; model?: string },
|
|
205
|
+
): void {
|
|
206
|
+
trace.backend = warp.backendId;
|
|
207
|
+
trace.model = warp.model;
|
|
208
|
+
const waitMs = (trace.startedAt ?? trace.enqueuedAt) - trace.enqueuedAt;
|
|
209
|
+
log(
|
|
210
|
+
"dispatcher",
|
|
211
|
+
`turn.start ${head(trace)} frontend=${trace.frontend} ` +
|
|
212
|
+
`backend=${trace.backend ?? "?"} model=${trace.model ?? "none"} ` +
|
|
213
|
+
`trigger=${trace.trigger} queue=${trace.queuedBehind} wait_ms=${waitMs}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type TurnUsage = {
|
|
218
|
+
inputTokens: number;
|
|
219
|
+
outputTokens: number;
|
|
220
|
+
cacheRead: number;
|
|
221
|
+
cacheWrite: number;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The turn returned normally: `ok`, a refusal, or a kill the backend
|
|
226
|
+
* honoured with a clean early completion.
|
|
227
|
+
*/
|
|
228
|
+
export function logTurnSettled(
|
|
229
|
+
trace: TurnTrace,
|
|
230
|
+
killed: boolean,
|
|
231
|
+
usage: TurnUsage,
|
|
232
|
+
): void {
|
|
233
|
+
if (killed) logTurnEnd(trace, "aborted", { usage, reason: "killed" });
|
|
234
|
+
else if (trace.refused)
|
|
235
|
+
logTurnEnd(trace, "refused", { usage, reason: trace.refused });
|
|
236
|
+
else logTurnEnd(trace, "ok", { usage });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The single closing line of every turn, whatever its outcome. */
|
|
240
|
+
export function logTurnEnd(
|
|
241
|
+
trace: TurnTrace,
|
|
242
|
+
outcome: TurnOutcome,
|
|
243
|
+
opts: { usage?: TurnUsage; reason?: string } = {},
|
|
244
|
+
): void {
|
|
245
|
+
const ms = Date.now() - (trace.startedAt ?? trace.enqueuedAt);
|
|
246
|
+
const usage = opts.usage;
|
|
247
|
+
const line =
|
|
248
|
+
`turn.end ${head(trace)} backend=${trace.backend ?? "?"} ` +
|
|
249
|
+
`model=${trace.model ?? "none"} trigger=${trace.trigger} ms=${ms} ` +
|
|
250
|
+
`outcome=${outcome} tools=${trace.tools.count}` +
|
|
251
|
+
(usage
|
|
252
|
+
? ` in_tokens=${usage.inputTokens} out_tokens=${usage.outputTokens} ` +
|
|
253
|
+
`cache_read=${usage.cacheRead} cache_write=${usage.cacheWrite}`
|
|
254
|
+
: "") +
|
|
255
|
+
(opts.reason ? ` reason=${opts.reason}` : "");
|
|
256
|
+
log("dispatcher", line);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const AGENT_KIND_CLASS: Record<AgentRunError["kind"], TurnErrorClass> = {
|
|
260
|
+
context_overflow: "backend",
|
|
261
|
+
rate_limit: "quota",
|
|
262
|
+
overload: "backend",
|
|
263
|
+
session_expired: "backend",
|
|
264
|
+
auth: "auth",
|
|
265
|
+
model_unsupported: "backend",
|
|
266
|
+
tool_failure: "tool",
|
|
267
|
+
subprocess_exit: "backend",
|
|
268
|
+
timeout: "timeout",
|
|
269
|
+
aborted: "aborted",
|
|
270
|
+
// Re-read through classify(): the message may still say more.
|
|
271
|
+
unknown: "unknown",
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const REASON_CLASS: Record<TalonError["reason"], TurnErrorClass> = {
|
|
275
|
+
rate_limit: "quota",
|
|
276
|
+
usage_limit: "quota",
|
|
277
|
+
overloaded: "backend",
|
|
278
|
+
network: "backend",
|
|
279
|
+
auth: "auth",
|
|
280
|
+
forbidden: "auth",
|
|
281
|
+
context_length: "backend",
|
|
282
|
+
session_expired: "backend",
|
|
283
|
+
bad_request: "backend",
|
|
284
|
+
telegram_api: "unknown",
|
|
285
|
+
stopped: "aborted",
|
|
286
|
+
unknown: "unknown",
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Bucket a turn failure into the coarse class `turn.error` reports. */
|
|
290
|
+
export function classifyTurnError(err: unknown): TurnErrorClass {
|
|
291
|
+
if (err instanceof AgentRunError && err.kind !== "unknown") {
|
|
292
|
+
return AGENT_KIND_CLASS[err.kind];
|
|
293
|
+
}
|
|
294
|
+
if (err instanceof Error && err.name === "TimeoutError") return "timeout";
|
|
295
|
+
return REASON_CLASS[classify(err).reason];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A turn threw. A kill the user asked for closes as `aborted` with no
|
|
300
|
+
* `turn.error` (nothing went wrong); anything else gets the classified
|
|
301
|
+
* `turn.error` line, then `turn.end`.
|
|
302
|
+
*/
|
|
303
|
+
export function logTurnFailure(
|
|
304
|
+
trace: TurnTrace,
|
|
305
|
+
err: unknown,
|
|
306
|
+
killed: boolean,
|
|
307
|
+
): void {
|
|
308
|
+
if (killed) {
|
|
309
|
+
logTurnEnd(trace, "aborted", { reason: "killed" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const cls = classifyTurnError(err);
|
|
313
|
+
logWarn(
|
|
314
|
+
"dispatcher",
|
|
315
|
+
`turn.error ${head(trace)} backend=${trace.backend ?? "?"} class=${cls} msg=${errorText(err)}`,
|
|
316
|
+
);
|
|
317
|
+
const outcome: TurnOutcome =
|
|
318
|
+
cls === "timeout" ? "timeout" : cls === "aborted" ? "aborted" : "error";
|
|
319
|
+
logTurnEnd(trace, outcome);
|
|
320
|
+
}
|