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 +1 -1
- package/src/backend/claude-sdk/handler.ts +13 -0
- package/src/backend/claude-sdk/stream.ts +33 -0
- package/src/core/agent-runtime/events.ts +4 -0
- package/src/core/errors.ts +62 -4
- package/src/frontend/native/index.ts +4 -1
- package/src/frontend/native/logs.ts +95 -0
- package/src/frontend/native/protocol.ts +34 -0
- package/src/frontend/native/server.ts +25 -0
package/package.json
CHANGED
|
@@ -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",
|
package/src/core/errors.ts
CHANGED
|
@@ -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
|
-
//
|
|
218
|
-
if (
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|