talon-agent 1.40.1 → 1.40.2

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.40.1",
3
+ "version": "1.40.2",
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",
@@ -22,6 +22,7 @@ import { getDefaultModel } from "../models/catalog.js";
22
22
  import type { OneShotAgentParams } from "../types.js";
23
23
  import type { Backend } from "../agent-runtime/capabilities.js";
24
24
  import { getSoul } from "../soul/service.js";
25
+ import { FailureBackoff } from "./failure-backoff.js";
25
26
 
26
27
  // ── Types ────────────────────────────────────────────────────────────────────
27
28
 
@@ -53,6 +54,13 @@ const DREAM_LOGS_DIR = resolve(dirs.logs, "dreams");
53
54
  // ── State ────────────────────────────────────────────────────────────────────
54
55
 
55
56
  let dreaming = false; // in-process guard (one dream at a time)
57
+ /**
58
+ * A failed dream does not advance last_run, and maybeStartDream runs on
59
+ * every invocation — without a backoff a broken backend gets re-fired on
60
+ * every message (observed live: a model outage produced 403 identical
61
+ * consolidation failures). Exported for tests.
62
+ */
63
+ export const dreamFailureBackoff = new FailureBackoff();
56
64
  let configRef: {
57
65
  model?: string;
58
66
  dreamModel?: string;
@@ -101,6 +109,9 @@ export function initDream(cfg: {
101
109
  export function maybeStartDream(): void {
102
110
  if (dreaming) return;
103
111
  if (configRef?.enabled === false) return;
112
+ // Inside a failure backoff window — stay quiet instead of re-firing the
113
+ // same error on every invocation. (Logged once, when the window was set.)
114
+ if (dreamFailureBackoff.active()) return;
104
115
 
105
116
  const state = readDreamState();
106
117
  const now = Date.now();
@@ -134,6 +145,7 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
134
145
  try {
135
146
  const dreamLogPath = await runDreamAgent(state?.last_run ?? 0);
136
147
  writeDreamState({ last_run: Date.now(), status: "idle" });
148
+ dreamFailureBackoff.succeed();
137
149
  log(
138
150
  "dream",
139
151
  `Memory consolidation complete (${trigger}), log: ${dreamLogPath}`,
@@ -145,6 +157,14 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
145
157
  } catch (err) {
146
158
  logError("dream", `Memory consolidation failed (${trigger})`, err);
147
159
  writeDreamState({ last_run: state?.last_run ?? 0, status: "idle" });
160
+ // A failed dream keeps the old last_run, so it would re-fire on the very
161
+ // next invocation — back off instead (forceDream bypasses the window).
162
+ const until = dreamFailureBackoff.fail(err);
163
+ logWarn(
164
+ "dream",
165
+ `Backing off until ${new Date(until).toISOString()} ` +
166
+ `after ${dreamFailureBackoff.failures} consecutive failure(s)`,
167
+ );
148
168
  if (trigger === "forced") throw err;
149
169
  } finally {
150
170
  dreaming = false;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Failure backoff for background agents (heartbeat, dream, …).
3
+ *
4
+ * A failed background run typically does NOT advance its `last_run` marker,
5
+ * so whatever cadence check re-evaluates "is a run due?" re-fires the same
6
+ * failing run on every tick — observed live as hundreds of identical
7
+ * failures minutes apart (session-limit nights, model outages, auth
8
+ * breakage). This module centralizes the cure:
9
+ *
10
+ * - generic failures back off exponentially (5 → 10 → 20 → 40 → 60min cap)
11
+ * - session/rate-limit errors that state their own reset time
12
+ * ("… resets 12:20am (UTC)") wait until then (+buffer) instead of
13
+ * guessing
14
+ * - a success clears the window
15
+ *
16
+ * Backoff state is in-memory only: a process restart forgets it, which is
17
+ * fine — the first post-restart attempt either works or re-arms the window.
18
+ */
19
+
20
+ const FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
21
+ const FAILURE_BACKOFF_MAX_MS = 60 * 60 * 1000;
22
+ /** Safety margin added past a parsed limit-reset time (clock skew, rollout). */
23
+ const LIMIT_RESET_BUFFER_MS = 2 * 60 * 1000;
24
+
25
+ /**
26
+ * Parse the reset wall-clock time out of a session/rate-limit error message,
27
+ * e.g. "You've hit your session limit · resets 12:20am (UTC)" or
28
+ * "… resets 3pm (UTC)". Returns the epoch ms of the NEXT occurrence of that
29
+ * UTC time after `now`, or null when the message doesn't carry one.
30
+ */
31
+ export function parseSessionLimitResetMs(
32
+ message: string,
33
+ now: number,
34
+ ): number | null {
35
+ const m = /resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(
36
+ message,
37
+ );
38
+ if (!m) return null;
39
+ const rawHour = Number(m[1]);
40
+ const minute = m[2] ? Number(m[2]) : 0;
41
+ if (rawHour < 1 || rawHour > 12 || minute > 59) return null;
42
+ let hour = rawHour % 12;
43
+ if (m[3].toLowerCase() === "pm") hour += 12;
44
+ const d = new Date(now);
45
+ d.setUTCHours(hour, minute, 0, 0);
46
+ let t = d.getTime();
47
+ if (t <= now) t += 24 * 60 * 60 * 1000;
48
+ return t;
49
+ }
50
+
51
+ /**
52
+ * When to next allow a run after the Nth consecutive failure (1-based).
53
+ * Limit errors with a stated reset time wait until then (+buffer);
54
+ * everything else doubles from 5min up to a 60min cap.
55
+ */
56
+ export function failureBackoffUntil(
57
+ err: unknown,
58
+ consecutiveFailures: number,
59
+ now: number,
60
+ ): number {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ if (/session limit|rate limit/i.test(message)) {
63
+ const reset = parseSessionLimitResetMs(message, now);
64
+ if (reset !== null) return reset + LIMIT_RESET_BUFFER_MS;
65
+ }
66
+ const exp = Math.min(
67
+ FAILURE_BACKOFF_BASE_MS * 2 ** Math.max(0, consecutiveFailures - 1),
68
+ FAILURE_BACKOFF_MAX_MS,
69
+ );
70
+ return now + exp;
71
+ }
72
+
73
+ /**
74
+ * Per-component backoff holder. Cadence checks gate on `active()`; run
75
+ * completions call `succeed()` / `fail(err)`.
76
+ */
77
+ export class FailureBackoff {
78
+ private consecutiveFailures = 0;
79
+ private until = 0;
80
+
81
+ /** True while inside the backoff window — the caller should skip firing. */
82
+ active(now = Date.now()): boolean {
83
+ return now < this.until;
84
+ }
85
+
86
+ /** Record a failure; returns the epoch ms the window is armed until. */
87
+ fail(err: unknown, now = Date.now()): number {
88
+ this.consecutiveFailures += 1;
89
+ this.until = failureBackoffUntil(err, this.consecutiveFailures, now);
90
+ return this.until;
91
+ }
92
+
93
+ /** Record a success — clears the window and the failure streak. */
94
+ succeed(): void {
95
+ this.consecutiveFailures = 0;
96
+ this.until = 0;
97
+ }
98
+
99
+ get failures(): number {
100
+ return this.consecutiveFailures;
101
+ }
102
+ }
@@ -22,65 +22,6 @@ import { HeartbeatTimeoutError, runHeartbeatAgent } from "./agent.js";
22
22
  const STARTUP_DELAY_MS = 5 * 60 * 1000; // 5-minute delay before first run
23
23
  const DUE_CHECK_INTERVAL_MS = 60 * 1000;
24
24
 
25
- // Failure backoff. A failed run does NOT advance last_run, so without a
26
- // backoff the every-minute due check re-fires the heartbeat 60×/hour against
27
- // the same error (observed live: a session-limit night produced hundreds of
28
- // identical failures a minute apart). Generic failures back off
29
- // exponentially; session/rate-limit errors that state their own reset time
30
- // wait for it instead of guessing.
31
- const FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
32
- const FAILURE_BACKOFF_MAX_MS = 60 * 60 * 1000;
33
- /** Safety margin added past a parsed limit-reset time (clock skew, rollout). */
34
- const LIMIT_RESET_BUFFER_MS = 2 * 60 * 1000;
35
-
36
- /**
37
- * Parse the reset wall-clock time out of a session/rate-limit error message,
38
- * e.g. "You've hit your session limit · resets 12:20am (UTC)" or
39
- * "… resets 3pm (UTC)". Returns the epoch ms of the NEXT occurrence of that
40
- * UTC time after `now`, or null when the message doesn't carry one.
41
- */
42
- export function parseSessionLimitResetMs(
43
- message: string,
44
- now: number,
45
- ): number | null {
46
- const m = /resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(
47
- message,
48
- );
49
- if (!m) return null;
50
- const rawHour = Number(m[1]);
51
- const minute = m[2] ? Number(m[2]) : 0;
52
- if (rawHour < 1 || rawHour > 12 || minute > 59) return null;
53
- let hour = rawHour % 12;
54
- if (m[3].toLowerCase() === "pm") hour += 12;
55
- const d = new Date(now);
56
- d.setUTCHours(hour, minute, 0, 0);
57
- let t = d.getTime();
58
- if (t <= now) t += 24 * 60 * 60 * 1000;
59
- return t;
60
- }
61
-
62
- /**
63
- * When to next allow an auto heartbeat after the Nth consecutive failure
64
- * (1-based). Limit errors with a stated reset time wait until then (+buffer);
65
- * everything else doubles from 5min up to a 60min cap.
66
- */
67
- export function failureBackoffUntil(
68
- err: unknown,
69
- consecutiveFailures: number,
70
- now: number,
71
- ): number {
72
- const message = err instanceof Error ? err.message : String(err);
73
- if (/session limit|rate limit/i.test(message)) {
74
- const reset = parseSessionLimitResetMs(message, now);
75
- if (reset !== null) return reset + LIMIT_RESET_BUFFER_MS;
76
- }
77
- const exp = Math.min(
78
- FAILURE_BACKOFF_BASE_MS * 2 ** Math.max(0, consecutiveFailures - 1),
79
- FAILURE_BACKOFF_MAX_MS,
80
- );
81
- return now + exp;
82
- }
83
-
84
25
  export function initHeartbeat(cfg: HeartbeatConfig): void {
85
26
  hb.config = cfg;
86
27
  }
@@ -130,7 +71,7 @@ function runIfDue(intervalMs: number, startup: boolean): void {
130
71
  if (hb.running) return;
131
72
  // Inside a failure backoff window — stay quiet instead of re-firing the
132
73
  // same error every due check. (Logged once, when the window was set.)
133
- if (Date.now() < hb.backoffUntil) return;
74
+ if (hb.failureBackoff.active()) return;
134
75
  const lastRun = readHeartbeatState()?.last_run ?? 0;
135
76
  if (lastRun <= 0) {
136
77
  // Never ran on this install — fire now to establish the cadence.
@@ -240,8 +181,7 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
240
181
  status: "idle",
241
182
  run_count: previousRunCount + 1,
242
183
  });
243
- hb.consecutiveFailures = 0;
244
- hb.backoffUntil = 0;
184
+ hb.failureBackoff.succeed();
245
185
  log(
246
186
  "heartbeat",
247
187
  `Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
@@ -267,16 +207,11 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
267
207
  // spaces the next attempt. Every other failure retries against the same
268
208
  // last_run — back off so the due check doesn't hammer it every minute.
269
209
  if (!isTimeout) {
270
- hb.consecutiveFailures += 1;
271
- hb.backoffUntil = failureBackoffUntil(
272
- err,
273
- hb.consecutiveFailures,
274
- Date.now(),
275
- );
210
+ const until = hb.failureBackoff.fail(err);
276
211
  logWarn(
277
212
  "heartbeat",
278
- `Backing off until ${new Date(hb.backoffUntil).toISOString()} ` +
279
- `after ${hb.consecutiveFailures} consecutive failure(s)`,
213
+ `Backing off until ${new Date(until).toISOString()} ` +
214
+ `after ${hb.failureBackoff.failures} consecutive failure(s)`,
280
215
  );
281
216
  }
282
217
  if (trigger === "forced") throw err;
@@ -10,6 +10,7 @@ import { files as pathFiles } from "../../../util/paths.js";
10
10
  import { kvGet, kvSet } from "../../../storage/kv.js";
11
11
  import { importLegacyJson } from "../../../storage/legacy-import.js";
12
12
  import type { Backend } from "../../agent-runtime/capabilities.js";
13
+ import { FailureBackoff } from "../failure-backoff.js";
13
14
 
14
15
  export type HeartbeatState = {
15
16
  /** Unix millisecond timestamp of the last successfully completed heartbeat run. */
@@ -71,10 +72,8 @@ export const hb: {
71
72
  intervalMinutesRef: number;
72
73
  config: HeartbeatConfig | null;
73
74
  logFileSequence: number;
74
- /** Consecutive failed runs drives the failure backoff curve. */
75
- consecutiveFailures: number;
76
- /** Epoch ms before which auto runs are suppressed after a failure. */
77
- backoffUntil: number;
75
+ /** Suppresses auto runs after failures (see failure-backoff.ts). */
76
+ failureBackoff: FailureBackoff;
78
77
  } = {
79
78
  running: false,
80
79
  currentRunPromise: null,
@@ -83,8 +82,7 @@ export const hb: {
83
82
  intervalMinutesRef: 60,
84
83
  config: null,
85
84
  logFileSequence: 0,
86
- consecutiveFailures: 0,
87
- backoffUntil: 0,
85
+ failureBackoff: new FailureBackoff(),
88
86
  };
89
87
 
90
88
  // ── State-file I/O ───────────────────────────────────────────────────────────