talon-agent 1.40.0 → 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.0",
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
+ }
@@ -69,6 +69,9 @@ export function startHeartbeatTimer(intervalMinutes: number): void {
69
69
  */
70
70
  function runIfDue(intervalMs: number, startup: boolean): void {
71
71
  if (hb.running) return;
72
+ // Inside a failure backoff window — stay quiet instead of re-firing the
73
+ // same error every due check. (Logged once, when the window was set.)
74
+ if (hb.failureBackoff.active()) return;
72
75
  const lastRun = readHeartbeatState()?.last_run ?? 0;
73
76
  if (lastRun <= 0) {
74
77
  // Never ran on this install — fire now to establish the cadence.
@@ -178,6 +181,7 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
178
181
  status: "idle",
179
182
  run_count: previousRunCount + 1,
180
183
  });
184
+ hb.failureBackoff.succeed();
181
185
  log(
182
186
  "heartbeat",
183
187
  `Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
@@ -199,6 +203,17 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
199
203
  status: "idle",
200
204
  run_count: isTimeout ? previousRunCount + 1 : previousRunCount,
201
205
  });
206
+ // Timeouts advance last_run (budget consumed), so the cadence itself
207
+ // spaces the next attempt. Every other failure retries against the same
208
+ // last_run — back off so the due check doesn't hammer it every minute.
209
+ if (!isTimeout) {
210
+ const until = hb.failureBackoff.fail(err);
211
+ logWarn(
212
+ "heartbeat",
213
+ `Backing off until ${new Date(until).toISOString()} ` +
214
+ `after ${hb.failureBackoff.failures} consecutive failure(s)`,
215
+ );
216
+ }
202
217
  if (trigger === "forced") throw err;
203
218
  } finally {
204
219
  hb.running = false;
@@ -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,6 +72,8 @@ export const hb: {
71
72
  intervalMinutesRef: number;
72
73
  config: HeartbeatConfig | null;
73
74
  logFileSequence: number;
75
+ /** Suppresses auto runs after failures (see failure-backoff.ts). */
76
+ failureBackoff: FailureBackoff;
74
77
  } = {
75
78
  running: false,
76
79
  currentRunPromise: null,
@@ -79,6 +82,7 @@ export const hb: {
79
82
  intervalMinutesRef: 60,
80
83
  config: null,
81
84
  logFileSequence: 0,
85
+ failureBackoff: new FailureBackoff(),
82
86
  };
83
87
 
84
88
  // ── State-file I/O ───────────────────────────────────────────────────────────