talon-agent 1.33.0 → 1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.33.0",
3
+ "version": "1.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",
@@ -384,6 +384,19 @@ export async function* runChatTurn(
384
384
  armPostResultWatchdog();
385
385
  }
386
386
  }
387
+
388
+ // The SDK doesn't throw on API errors — it converts them into a
389
+ // synthetic assistant message and finishes the turn with an error-
390
+ // flagged result (usage limits, 429s, auth failures all land here).
391
+ // Rethrow the captured error text so this turn takes the SAME path
392
+ // as a thrown SDK error: retry decision, failed-turn accounting, and
393
+ // an `error` event carrying the real message — instead of being
394
+ // treated as a normal reply (which, with no delivery tool call,
395
+ // would trip the flow-violation re-prompt loop and burn more turns
396
+ // against an already-exhausted limit).
397
+ if (state.resultErrorText) {
398
+ throw new Error(state.resultErrorText);
399
+ }
387
400
  } catch (err) {
388
401
  if (!postResultForceClosed) {
389
402
  const buildRetryStream = (
@@ -75,6 +75,17 @@ export type StreamState = {
75
75
  * doesn't poison the visible-text delta buffer.
76
76
  */
77
77
  unflushedThinkingDelta: string;
78
+ /**
79
+ * Set when the SDK's final result message reported a failed turn —
80
+ * either an error subtype (`error_during_execution`, `error_max_turns`,
81
+ * …) or `is_error: true` on a `success` result. The latter is how API
82
+ * errors surface: the SDK converts them (usage limits, 429s, auth
83
+ * failures) into a synthetic assistant message and finishes the turn
84
+ * "successfully" with the error text in `result` — it never throws.
85
+ * Carries the best error text available so the handler can fail the
86
+ * turn with the REAL message instead of treating it as a normal reply.
87
+ */
88
+ resultErrorText: string | undefined;
78
89
  };
79
90
 
80
91
  export function createStreamState(): StreamState {
@@ -96,6 +107,7 @@ export function createStreamState(): StreamState {
96
107
  turnTerminated: false,
97
108
  unflushedTextDelta: "",
98
109
  unflushedThinkingDelta: "",
110
+ resultErrorText: undefined,
99
111
  };
100
112
  }
101
113
 
@@ -358,6 +370,27 @@ export function processResultMessage(
358
370
  ) {
359
371
  state.currentBlockText = msg.result;
360
372
  }
373
+
374
+ // Failed turn. Two shapes (see StreamState.resultErrorText):
375
+ // - error subtype: no `result` field, but `errors[]` carries diagnostics.
376
+ // - success subtype with is_error: `result` (and the trailing assistant
377
+ // text) IS the API error message, e.g. "You've hit your weekly limit
378
+ // · resets Jul 10, 9am". Prefer that text — it's the user-facing one.
379
+ if (msg.subtype !== "success") {
380
+ const diagnostics = (msg.errors ?? [])
381
+ .filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
382
+ .join("; ")
383
+ .slice(0, 500);
384
+ state.resultErrorText =
385
+ state.lastTrailingText.trim() ||
386
+ diagnostics ||
387
+ `Claude SDK turn failed (${msg.subtype})`;
388
+ } else if (msg.is_error) {
389
+ state.resultErrorText =
390
+ (typeof msg.result === "string" && msg.result.trim()) ||
391
+ state.lastTrailingText.trim() ||
392
+ "Claude SDK turn failed (API error)";
393
+ }
361
394
  }
362
395
 
363
396
  // ── Trailing-text fallback dedup ────────────────────────────────────────────
@@ -259,6 +259,10 @@ export function addUsage(a: UsageSnapshot, b: UsageSnapshot): UsageSnapshot {
259
259
  */
260
260
  const REASON_TO_KIND: Partial<Record<TalonError["reason"], AgentErrorKind>> = {
261
261
  rate_limit: "rate_limit",
262
+ // Subscription usage limits share the rate_limit kind — same family for
263
+ // event consumers; `retryable: false` (carried through) is what
264
+ // distinguishes them from a transient 429.
265
+ usage_limit: "rate_limit",
262
266
  overloaded: "overload",
263
267
  context_length: "context_overflow",
264
268
  session_expired: "session_expired",
@@ -9,6 +9,7 @@
9
9
 
10
10
  type ErrorReason =
11
11
  | "rate_limit"
12
+ | "usage_limit"
12
13
  | "overloaded"
13
14
  | "network"
14
15
  | "auth"
@@ -19,6 +20,19 @@ type ErrorReason =
19
20
  | "telegram_api"
20
21
  | "unknown";
21
22
 
23
+ /**
24
+ * Claude subscription usage-limit messages, as produced by Claude Code /
25
+ * the Agent SDK (see rateLimitMessages.ts upstream: "You've hit your
26
+ * weekly limit · resets Jul 10, 9am", "You're out of extra usage", …)
27
+ * plus the legacy "Claude AI usage limit reached|<ts>" format. These are
28
+ * already user-facing text — classification marks them `usage_limit` so
29
+ * `friendlyMessage` passes them through instead of collapsing them to a
30
+ * generic template. Unlike `rate_limit` (a transient 429), a usage limit
31
+ * doesn't clear on a short retry, so `retryable` stays false.
32
+ */
33
+ const USAGE_LIMIT_RE =
34
+ /you['’]ve hit your .{0,40}limit|you['’]re out of extra usage|claude ai usage limit reached|usage limit reached/i;
35
+
22
36
  // ── TalonError class ────────────────────────────────────────────────────────
23
37
 
24
38
  export class TalonError extends Error {
@@ -74,6 +88,18 @@ export function classify(err: unknown): TalonError {
74
88
  const statusMatch = msg.match(/\b([45]\d{2})\b/);
75
89
  const status = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
76
90
 
91
+ // Subscription usage limit — checked before the generic rate-limit
92
+ // branch ("usage limit reached" would otherwise never be reached, and
93
+ // "You've hit your…" carries no 429/rate-limit marker at all).
94
+ if (USAGE_LIMIT_RE.test(msg)) {
95
+ return new TalonError(msg, {
96
+ reason: "usage_limit",
97
+ retryable: false,
98
+ status: status ?? 429,
99
+ cause,
100
+ });
101
+ }
102
+
77
103
  // Rate limit
78
104
  if (/rate.?limit|429|too many requests/i.test(msg)) {
79
105
  const retryMatch = msg.match(/retry.?after[:\s]*(\d+)/i);
@@ -190,6 +216,7 @@ export function classify(err: unknown): TalonError {
190
216
 
191
217
  const FRIENDLY_MESSAGES: Record<ErrorReason, string> = {
192
218
  rate_limit: "Rate limited. Try again in a moment.",
219
+ usage_limit: "Usage limit reached. Try again after it resets.",
193
220
  overloaded:
194
221
  "Upstream model is busy right now. Retrying with a faster fallback...",
195
222
  network: "Connection issue. Retrying shortly.",
@@ -203,8 +230,30 @@ const FRIENDLY_MESSAGES: Record<ErrorReason, string> = {
203
230
  unknown: "Something went wrong. Try again or /reset.",
204
231
  };
205
232
 
233
+ /**
234
+ * Reasons whose template alone tells the user nothing actionable — the
235
+ * underlying error detail is appended so "Something went wrong" always
236
+ * says WHAT went wrong. Templated reasons like `network`/`overloaded`
237
+ * stay terse: they're transient and the detail is just transport noise.
238
+ */
239
+ const DETAIL_REASONS: ReadonlySet<ErrorReason> = new Set([
240
+ "auth",
241
+ "bad_request",
242
+ "forbidden",
243
+ "unknown",
244
+ ]);
245
+
246
+ /** Collapse whitespace and clip the raw error for inline display. */
247
+ function clipDetail(msg: string, max = 300): string {
248
+ const flat = msg.replace(/\s+/g, " ").trim();
249
+ if (!flat || flat === "[non-stringifiable error]") return "";
250
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
251
+ }
252
+
206
253
  /**
207
254
  * Get a user-friendly error message. For rate limits, includes retry timing.
255
+ * Usage-limit and session-expired messages pass through verbatim (they're
256
+ * already user-facing); generic reasons carry the underlying detail.
208
257
  */
209
258
  export function friendlyMessage(err: unknown): string {
210
259
  const classified = err instanceof TalonError ? err : classify(err);
@@ -214,10 +263,19 @@ export function friendlyMessage(err: unknown): string {
214
263
  return `Rate limited. Try again in ${seconds} seconds.`;
215
264
  }
216
265
 
217
- // Session expired messages are already user-friendly from the backend
218
- if (classified.reason === "session_expired") {
219
- return classified.message;
266
+ // Already user-friendly from the backend — pass through as-is.
267
+ if (
268
+ classified.reason === "session_expired" ||
269
+ classified.reason === "usage_limit"
270
+ ) {
271
+ return classified.message || FRIENDLY_MESSAGES[classified.reason];
220
272
  }
221
273
 
222
- return FRIENDLY_MESSAGES[classified.reason];
274
+ const base = FRIENDLY_MESSAGES[classified.reason];
275
+ if (DETAIL_REASONS.has(classified.reason)) {
276
+ const detail = clipDetail(classified.message);
277
+ // Skip when the detail adds nothing over the template itself.
278
+ if (detail && detail !== base) return `${base}\n\nDetail: ${detail}`;
279
+ }
280
+ return base;
223
281
  }
@@ -22,7 +22,7 @@ import { mkdir, writeFile } from "node:fs/promises";
22
22
  import { basename, dirname, join, resolve } from "node:path";
23
23
  import { spawn } from "node:child_process";
24
24
  import { log, logError } from "../../util/log.js";
25
- import { dirs } from "../../util/paths.js";
25
+ import { dirs, files } from "../../util/paths.js";
26
26
  import { PKG_ROOT } from "../../cli/context.js";
27
27
  import { getSessionInfo } from "../../storage/sessions.js";
28
28
  import { buildContextDisplay } from "../shared/status-context.js";
@@ -66,6 +66,7 @@ import { extractSessionName } from "../../backend/shared/session-name.js";
66
66
  import { BridgeServer, type BridgeServerHandlers } from "./server.js";
67
67
  import { createNativeActionHandler } from "./actions.js";
68
68
  import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
69
+ import { readLogEntries } from "./logs.js";
69
70
  import {
70
71
  BRIDGE_PROTOCOL_VERSION,
71
72
  BOT_SENDER_ID,
@@ -1144,6 +1145,8 @@ export function createNativeFrontend(
1144
1145
  return snap;
1145
1146
  },
1146
1147
  control,
1148
+ logs: ({ lines, minLevel, component }) =>
1149
+ readLogEntries(files.log, { limit: lines, minLevel, component }),
1147
1150
  liveTurnEvents,
1148
1151
  mediaPath: (id) => media.get(id) ?? null,
1149
1152
  };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Daemon log access for the client bridge.
3
+ *
4
+ * Parses the pino JSON log file (`~/.talon/talon.log`, one object per
5
+ * line — see util/log.ts) into the wire `LogEntry` shape so clients can
6
+ * render the daemon's log without shipping a pino parser of their own.
7
+ * Pure functions over a raw tail string; file I/O stays with the caller.
8
+ */
9
+
10
+ import { tailFile } from "../../util/tail-file.js";
11
+ import type { LogEntry, LogLevel } from "./protocol.js";
12
+
13
+ /** pino numeric levels → wire level names. */
14
+ const PINO_LEVELS: Record<number, LogLevel> = {
15
+ 10: "trace",
16
+ 20: "debug",
17
+ 30: "info",
18
+ 40: "warn",
19
+ 50: "error",
20
+ 60: "fatal",
21
+ };
22
+
23
+ const LEVEL_RANK: Record<LogLevel, number> = {
24
+ trace: 10,
25
+ debug: 20,
26
+ info: 30,
27
+ warn: 40,
28
+ error: 50,
29
+ fatal: 60,
30
+ };
31
+
32
+ export type LogQuery = {
33
+ /** Max entries returned (most recent last). */
34
+ limit: number;
35
+ /** Minimum severity — entries below it are dropped. */
36
+ minLevel?: LogLevel;
37
+ /** Exact component match (e.g. "agent", "native"). */
38
+ component?: string;
39
+ };
40
+
41
+ /**
42
+ * Parse a raw tail of the pino log file into wire entries, newest last.
43
+ * Malformed lines (partial first line of the byte window, rotation
44
+ * artifacts) are skipped rather than failing the whole request.
45
+ */
46
+ export function parseLogTail(raw: string, query: LogQuery): LogEntry[] {
47
+ const minRank = query.minLevel ? LEVEL_RANK[query.minLevel] : 0;
48
+ const entries: LogEntry[] = [];
49
+ for (const line of raw.split("\n")) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed.startsWith("{")) continue;
52
+ let obj: Record<string, unknown>;
53
+ try {
54
+ obj = JSON.parse(trimmed) as Record<string, unknown>;
55
+ } catch {
56
+ continue;
57
+ }
58
+ const levelNum = typeof obj.level === "number" ? obj.level : 30;
59
+ if (levelNum < minRank) continue;
60
+ const component =
61
+ typeof obj.component === "string" ? obj.component : undefined;
62
+ if (query.component && component !== query.component) continue;
63
+ entries.push({
64
+ ts: typeof obj.time === "number" ? obj.time : 0,
65
+ level: PINO_LEVELS[levelNum] ?? "info",
66
+ ...(component ? { component } : {}),
67
+ msg: typeof obj.msg === "string" ? obj.msg : "",
68
+ ...(typeof obj.err === "string" ? { err: obj.err } : {}),
69
+ ...(typeof obj.stack === "string" ? { stack: obj.stack } : {}),
70
+ });
71
+ }
72
+ return entries.slice(-query.limit);
73
+ }
74
+
75
+ /**
76
+ * Read + parse the newest `query.limit` entries from `path`. The byte
77
+ * window scales with the request so filtered queries (level/component)
78
+ * still have material to fill from, capped so a hostile `lines` value
79
+ * can't make the daemon slurp the whole file.
80
+ */
81
+ export function readLogEntries(path: string, query: LogQuery): LogEntry[] {
82
+ const maxBytes = Math.min(
83
+ 2 * 1024 * 1024,
84
+ Math.max(64 * 1024, query.limit * 2048),
85
+ );
86
+ let raw: string;
87
+ try {
88
+ // Line budget is generous vs `limit` so level-filtered queries can
89
+ // still return `limit` matches from a mostly-info tail.
90
+ raw = tailFile(path, query.limit * 20, maxBytes);
91
+ } catch {
92
+ return [];
93
+ }
94
+ return parseLogTail(raw, query);
95
+ }
@@ -142,6 +142,40 @@ export type BridgeStatus = {
142
142
  startedAt: string;
143
143
  };
144
144
 
145
+ /** Wire severity for a daemon log entry (mirrors pino's level names). */
146
+ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
147
+
148
+ const LOG_LEVELS: readonly LogLevel[] = [
149
+ "trace",
150
+ "debug",
151
+ "info",
152
+ "warn",
153
+ "error",
154
+ "fatal",
155
+ ];
156
+
157
+ /** Narrow an untrusted query-param string to a wire log level. */
158
+ export function isLogLevel(v: string): v is LogLevel {
159
+ return (LOG_LEVELS as readonly string[]).includes(v);
160
+ }
161
+
162
+ /**
163
+ * One daemon log line, served by `GET /logs` for the client's log viewer
164
+ * (additive in v1 — older clients simply never call the endpoint).
165
+ */
166
+ export type LogEntry = {
167
+ /** Epoch milliseconds. */
168
+ ts: number;
169
+ level: LogLevel;
170
+ /** Subsystem tag (e.g. "agent", "native", "gateway"), when present. */
171
+ component?: string;
172
+ msg: string;
173
+ /** Concise error message, when the line logged one. */
174
+ err?: string;
175
+ /** Full stack trace, when the line logged one. */
176
+ stack?: string;
177
+ };
178
+
145
179
  /** A selectable model for the picker. */
146
180
  export type ModelOption = {
147
181
  id: string;
@@ -24,11 +24,14 @@ import { extname } from "node:path";
24
24
  import { log, logError, logDebug } from "../../util/log.js";
25
25
  import {
26
26
  BRIDGE_PROTOCOL_VERSION,
27
+ isLogLevel,
27
28
  type BackendOption,
28
29
  type BridgeEvent,
29
30
  type BridgeStatus,
30
31
  type ClientChat,
31
32
  type ClientMessage,
33
+ type LogEntry,
34
+ type LogLevel,
32
35
  type ModelOption,
33
36
  type SearchResult,
34
37
  } from "./protocol.js";
@@ -92,6 +95,12 @@ export type BridgeServerHandlers = {
92
95
  setConfig(update: Record<string, unknown>): ConfigSnapshot;
93
96
  /** Fire a daemon-level control action (e.g. "restart", "dream"). */
94
97
  control(action: string): Promise<{ ok: boolean; message: string }>;
98
+ /** Newest daemon log entries (for the client's log viewer). */
99
+ logs(opts: {
100
+ lines: number;
101
+ minLevel?: LogLevel;
102
+ component?: string;
103
+ }): LogEntry[];
95
104
  /** Events reconstructing any in-progress turns, for a just-connected client. */
96
105
  liveTurnEvents(): BridgeEvent[];
97
106
  /** Resolve a media id to an absolute file path (or null if unknown). */
@@ -407,6 +416,22 @@ export class BridgeServer {
407
416
  return await this.serveMedia(res, id);
408
417
  }
409
418
 
419
+ if (method === "GET" && path === "/logs") {
420
+ const lines = Math.min(
421
+ asPositiveInt(url.searchParams.get("lines")) ?? 200,
422
+ 1000,
423
+ );
424
+ const level = url.searchParams.get("level") ?? "";
425
+ const component = url.searchParams.get("component") ?? undefined;
426
+ return this.json(res, 200, {
427
+ entries: this.handlers.logs({
428
+ lines,
429
+ minLevel: isLogLevel(level) ? level : undefined,
430
+ component,
431
+ }),
432
+ });
433
+ }
434
+
410
435
  if (method === "GET" && path === "/config")
411
436
  return this.json(res, 200, this.handlers.getConfig());
412
437