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.
Files changed (195) hide show
  1. package/README.md +2 -1
  2. package/package.json +2 -2
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/one-shot.ts +32 -2
  8. package/src/backend/claude-sdk/stream.ts +2 -2
  9. package/src/backend/codex/auth.ts +1 -1
  10. package/src/backend/codex/handler/message.ts +11 -11
  11. package/src/backend/codex/init.ts +4 -9
  12. package/src/backend/codex/mcp-config.ts +1 -2
  13. package/src/backend/codex/oauth-incompat.ts +8 -4
  14. package/src/backend/codex/one-shot.ts +1 -1
  15. package/src/backend/openai-agents/builtins.ts +55 -27
  16. package/src/backend/openai-agents/factory.ts +3 -3
  17. package/src/backend/openai-agents/handler/message.ts +3 -5
  18. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  19. package/src/backend/remote-server/chat-turn.ts +6 -6
  20. package/src/backend/remote-server/events.ts +1 -5
  21. package/src/backend/remote-server/index.ts +0 -1
  22. package/src/backend/remote-server/messages.ts +3 -7
  23. package/src/backend/remote-server/one-shot.ts +1 -3
  24. package/src/backend/remote-server/session-helpers.ts +1 -4
  25. package/src/backend/remote-server/sse-stream.ts +8 -9
  26. package/src/backend/runtime/metrics.ts +7 -13
  27. package/src/backend/runtime/sleep.ts +1 -2
  28. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  29. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  30. package/src/bootstrap.ts +9 -1
  31. package/src/cli/doctor.ts +3 -0
  32. package/src/cli/index.ts +8 -10
  33. package/src/cli/logs.ts +148 -9
  34. package/src/cli/setup.ts +9 -11
  35. package/src/cli/status.ts +29 -0
  36. package/src/core/agent-runtime/README.md +5 -19
  37. package/src/core/agent-runtime/events.ts +3 -39
  38. package/src/core/agent-runtime/model-ref.ts +0 -8
  39. package/src/core/agents/registry.ts +65 -2
  40. package/src/core/auth/expiry-monitor.ts +9 -1
  41. package/src/core/auth/login-flow.ts +9 -1
  42. package/src/core/auth/status.ts +31 -3
  43. package/src/core/background/cron/scheduler.ts +25 -5
  44. package/src/core/background/dream/index.ts +29 -10
  45. package/src/core/background/failure-backoff.ts +30 -0
  46. package/src/core/background/heartbeat/agent.ts +2 -27
  47. package/src/core/background/heartbeat/index.ts +0 -2
  48. package/src/core/background/heartbeat/scheduler.ts +21 -13
  49. package/src/core/background/heartbeat/state.ts +10 -2
  50. package/src/core/background/isolated-agent.ts +6 -2
  51. package/src/core/background/pulse/pulse.ts +9 -0
  52. package/src/core/background/triggers/exit.ts +54 -0
  53. package/src/core/background/triggers/index.ts +1 -3
  54. package/src/core/background/triggers/resume.ts +2 -4
  55. package/src/core/backup/archive/tar.ts +14 -4
  56. package/src/core/backup/plan.ts +1 -0
  57. package/src/core/backup/restore.ts +21 -13
  58. package/src/core/backup/scheduler.ts +28 -9
  59. package/src/core/backup/snapshot.ts +43 -10
  60. package/src/core/backup/store.ts +7 -19
  61. package/src/core/backup/targets.ts +69 -17
  62. package/src/core/config/index.ts +14 -0
  63. package/src/core/daemon/crash-marker.ts +141 -0
  64. package/src/core/daemon/crash.ts +9 -2
  65. package/src/core/daemon/handoff.ts +15 -0
  66. package/src/core/daemon/health-alerts.ts +297 -0
  67. package/src/core/daemon/log-reader.ts +289 -0
  68. package/src/core/doctor/index.ts +18 -2
  69. package/src/core/doctor/logs.ts +124 -0
  70. package/src/core/doctor/types.ts +1 -1
  71. package/src/core/engine/backend-controller/index.ts +1 -13
  72. package/src/core/engine/backend-router/router.ts +1 -1
  73. package/src/core/engine/dispatcher.ts +55 -2
  74. package/src/core/engine/fault-text.ts +40 -0
  75. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  76. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  77. package/src/core/engine/gateway-actions/history.ts +2 -4
  78. package/src/core/engine/gateway-actions/mesh.ts +26 -14
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  81. package/src/core/engine/gateway.ts +60 -1
  82. package/src/core/engine/turn-health.ts +222 -0
  83. package/src/core/errors.ts +2 -2
  84. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  85. package/src/core/frontend-runtime/alerts.ts +130 -0
  86. package/src/core/mcp-hub/children.ts +78 -29
  87. package/src/core/mcp-hub/index.ts +21 -18
  88. package/src/core/mcp-hub/proxy-server.ts +8 -4
  89. package/src/core/mcp-hub/talon-server.ts +5 -12
  90. package/src/core/mesh/credentials/store.ts +16 -1
  91. package/src/core/mesh/devices/registry.ts +1 -17
  92. package/src/core/mesh/devices/service.ts +49 -17
  93. package/src/core/mesh/devices/teleport.ts +14 -2
  94. package/src/core/mesh/links/node-binaries.ts +13 -6
  95. package/src/core/mesh/persist.ts +22 -10
  96. package/src/core/mesh/transfers/device-files.ts +5 -23
  97. package/src/core/mesh/transfers/transfers.ts +16 -3
  98. package/src/core/models/active-model.ts +2 -55
  99. package/src/core/plugin/actions.ts +19 -20
  100. package/src/core/plugin/builtins.ts +80 -90
  101. package/src/core/plugin/index.ts +1 -4
  102. package/src/core/plugin/loader.ts +25 -33
  103. package/src/core/plugin/mcp.ts +3 -5
  104. package/src/core/plugin/registry.ts +19 -35
  105. package/src/core/plugin/types.ts +2 -5
  106. package/src/core/prompt/assemble.ts +15 -3
  107. package/src/core/scripts/lua.ts +6 -2
  108. package/src/core/tasks/table.ts +8 -2
  109. package/src/core/tools/bridge.ts +2 -4
  110. package/src/core/tools/chat/cross-send.ts +1 -1
  111. package/src/core/tools/chat/messaging.ts +1 -1
  112. package/src/core/tools/index.ts +2 -2
  113. package/src/core/tools/mcp-env.ts +2 -59
  114. package/src/core/tools/ops/agents.ts +22 -1
  115. package/src/core/tools/schemas.ts +4 -9
  116. package/src/core/vfs/fusefs.ts +0 -5
  117. package/src/core/vfs/index.ts +9 -2
  118. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  119. package/src/core/vfs/mounts/proc.ts +17 -1
  120. package/src/core/vfs/workspace.ts +7 -3
  121. package/src/core/weaver/shuttle.ts +8 -1
  122. package/src/core/weaver/turn-log.ts +320 -0
  123. package/src/core/weaver/weaver.ts +48 -5
  124. package/src/frontend/discord/actions/index.ts +8 -1
  125. package/src/frontend/discord/diagnostics.ts +82 -6
  126. package/src/frontend/discord/handlers/index.ts +0 -2
  127. package/src/frontend/discord/middleware.ts +7 -13
  128. package/src/frontend/discord/runtime.ts +1 -3
  129. package/src/frontend/health/delivery.ts +115 -0
  130. package/src/frontend/health/outage.ts +116 -0
  131. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  132. package/src/frontend/native/bridge/server.ts +106 -21
  133. package/src/frontend/native/index.ts +1 -1
  134. package/src/frontend/native/media/media.ts +5 -1
  135. package/src/frontend/native/runtime.ts +12 -7
  136. package/src/frontend/native/surface/handlers.ts +1 -1
  137. package/src/frontend/native/surface/memory.ts +1 -1
  138. package/src/frontend/native/surface/models.ts +3 -3
  139. package/src/frontend/native/surface/settings.ts +20 -8
  140. package/src/frontend/native/turn/context.ts +6 -8
  141. package/src/frontend/native/turn/turn-meta.ts +2 -5
  142. package/src/frontend/native/turn/turn.ts +8 -10
  143. package/src/frontend/presentation/format.ts +2 -4
  144. package/src/frontend/presentation/session-status.ts +2 -6
  145. package/src/frontend/teams/actions.ts +8 -1
  146. package/src/frontend/teams/graph.ts +0 -1
  147. package/src/frontend/teams/index.ts +1 -4
  148. package/src/frontend/teams/poll.ts +40 -2
  149. package/src/frontend/teams/runtime.ts +14 -5
  150. package/src/frontend/telegram/actions/index.ts +4 -1
  151. package/src/frontend/telegram/actions/send.ts +8 -0
  152. package/src/frontend/telegram/handlers/context.ts +13 -2
  153. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  154. package/src/frontend/telegram/handlers/index.ts +0 -2
  155. package/src/frontend/telegram/index.ts +35 -9
  156. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  157. package/src/frontend/telegram/userbot.ts +100 -36
  158. package/src/frontend/terminal/builtins/session.ts +2 -2
  159. package/src/frontend/terminal/index.ts +1 -3
  160. package/src/frontend/terminal/renderer.ts +2 -18
  161. package/src/frontend/whatsapp/actions/index.ts +12 -1
  162. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  163. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  164. package/src/frontend/whatsapp/connection/health.ts +89 -0
  165. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  166. package/src/frontend/whatsapp/runtime.ts +11 -5
  167. package/src/native/blake3.ts +28 -2
  168. package/src/native/fusefs.ts +23 -5
  169. package/src/native/registry.ts +1 -1
  170. package/src/native/warden.ts +33 -5
  171. package/src/plugins/github/index.ts +0 -1
  172. package/src/plugins/mempalace/index.ts +9 -3
  173. package/src/plugins/playwright/index.ts +2 -4
  174. package/src/plugins/playwright/provision.ts +12 -4
  175. package/src/storage/chat-settings.ts +6 -1
  176. package/src/storage/cron.ts +29 -4
  177. package/src/storage/daily-log.ts +43 -47
  178. package/src/storage/db.ts +61 -33
  179. package/src/storage/history.ts +6 -1
  180. package/src/storage/journal.ts +9 -2
  181. package/src/storage/kv.ts +19 -6
  182. package/src/storage/media-index.ts +28 -6
  183. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  184. package/src/storage/repositories/sessions-repo.ts +10 -3
  185. package/src/storage/scripts.ts +24 -13
  186. package/src/storage/sessions.ts +11 -2
  187. package/src/storage/skills.ts +21 -2
  188. package/src/storage/stickers.ts +17 -3
  189. package/src/storage/triggers.ts +8 -3
  190. package/src/storage/turn-meta.ts +25 -7
  191. package/src/util/log.ts +189 -6
  192. package/src/util/logging/turn-scope.ts +85 -0
  193. package/src/util/time.ts +3 -3
  194. package/src/util/watchdog.ts +30 -0
  195. 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: "Live daemon state: task table and event bus ring",
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
- writeFileSync(dst, pkg);
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
- writeFileSync(dst, pkg);
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
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
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
+ }