talon-agent 1.40.1 → 1.41.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.40.1",
3
+ "version": "1.41.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",
@@ -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 ───────────────────────────────────────────────────────────
@@ -18,6 +18,9 @@ export const meshHandlers: SharedActionHandlers = {
18
18
  get_device_history: (body) =>
19
19
  getMeshService().deviceHistory(body.device, body.hours),
20
20
  ring_device: (body) => getMeshService().ringDevice(body.device, body.message),
21
+ // Registry hygiene: drop a stale/superseded device entry. Requires an
22
+ // explicit target — a destructive action must never default-pick a device.
23
+ remove_device: (body) => getMeshService().removeDevice(body.device),
21
24
  get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
22
25
  // Exec + filesystem surface — the substrate teleport routes through.
23
26
  device_exec: (body) =>
@@ -91,10 +91,48 @@ export class MeshRegistry {
91
91
  now,
92
92
  );
93
93
  this.devices.set(device.id, device);
94
+ // Stale-duplicate eviction. A reinstall (or an old app build) carries its
95
+ // own persisted device id, so the same physical machine re-registers
96
+ // under a fresh identity and the old entry lingers forever as an offline
97
+ // ghost. Same name + platform under a different id, currently OFFLINE →
98
+ // superseded install, drop it. An online doppelganger is kept: two live
99
+ // devices can legitimately share a name.
100
+ let evicted = false;
101
+ for (const [id, d] of this.devices) {
102
+ if (id === device.id) continue;
103
+ if (d.name !== device.name || d.platform !== device.platform) continue;
104
+ if (toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS).online) continue;
105
+ this.devices.delete(id);
106
+ this.locations.delete(id);
107
+ this.history.delete(id);
108
+ evicted = true;
109
+ }
94
110
  await this.persistDevices();
111
+ if (evicted) {
112
+ await this.persistLocations();
113
+ await this.persistHistory();
114
+ }
95
115
  return device;
96
116
  }
97
117
 
118
+ /**
119
+ * Drop a device (and its location + history) from the registry. Returns
120
+ * the removed device, or undefined when the id is unknown. Note a still-
121
+ * connected companion re-registers on its next heartbeat — removal is for
122
+ * stale entries.
123
+ */
124
+ async removeDevice(deviceId: string): Promise<DeviceInfo | undefined> {
125
+ const device = this.devices.get(deviceId);
126
+ if (!device) return undefined;
127
+ this.devices.delete(deviceId);
128
+ const hadLocation = this.locations.delete(deviceId);
129
+ const hadHistory = this.history.delete(deviceId);
130
+ await this.persistDevices();
131
+ if (hadLocation) await this.persistLocations();
132
+ if (hadHistory) await this.persistHistory();
133
+ return toDeviceInfo(device, device.lastSeen);
134
+ }
135
+
98
136
  async storeLocation(
99
137
  body: Record<string, unknown>,
100
138
  now = Date.now(),
@@ -340,6 +340,36 @@ export class MeshService {
340
340
  return { ok: true, text: this.locationSummary(target, loc) };
341
341
  }
342
342
 
343
+ /**
344
+ * `remove_device`: drop a stale device from the mesh registry (with its
345
+ * location + history). Destructive, so it requires an explicit target —
346
+ * no "default to the most recent device" like the read tools.
347
+ */
348
+ async removeDevice(query?: unknown): Promise<MeshToolResult> {
349
+ await this.load();
350
+ if (typeof query !== "string" || !query.trim()) {
351
+ return {
352
+ ok: false,
353
+ text: "remove_device needs an explicit device id or name — there is no default target for a destructive operation. See list_devices.",
354
+ };
355
+ }
356
+ const resolved = this.resolveDevice(query);
357
+ if ("error" in resolved) return { ok: false, text: resolved.error };
358
+ const target = resolved.target;
359
+ const removed = await this.registry.removeDevice(target.id);
360
+ if (!removed) {
361
+ return { ok: false, text: this.noSuchDevice(query).text };
362
+ }
363
+ return {
364
+ ok: true,
365
+ text:
366
+ `Removed ${removed.name} [id: ${removed.id}] (${removed.platform}, last seen ${age(Date.now() - removed.lastSeen)}) from the mesh registry.` +
367
+ (target.online
368
+ ? " Note: it was still online — a connected companion re-registers within ~60s, so quit the app first if it keeps coming back."
369
+ : ""),
370
+ };
371
+ }
372
+
343
373
  /** `ring_device`: make the device sound/vibrate so it can be found. */
344
374
  ringDevice(query?: unknown, message?: unknown): Promise<MeshToolResult> {
345
375
  return this.commandTool(query, "ring", {
@@ -60,6 +60,20 @@ export const meshTools: ToolDefinition[] = [
60
60
  execute: (params, bridge) => bridge("ring_device", params),
61
61
  tag: "mesh",
62
62
  },
63
+ {
64
+ name: "remove_device",
65
+ description:
66
+ "Remove a stale device from the mesh registry (with its stored location and history). Use for superseded installs and old duplicates — e.g. a reinstalled companion that re-registered under a new id, leaving the old entry as an offline ghost. Requires an explicit device id or name (no default target). Removing a still-connected device is pointless: it re-registers on its next heartbeat within ~60s.",
67
+ schema: {
68
+ device: z
69
+ .string()
70
+ .describe(
71
+ "Device id, exact name, or unique name fragment of the entry to remove (see list_devices). Prefer the id when duplicates share a name.",
72
+ ),
73
+ },
74
+ execute: (params, bridge) => bridge("remove_device", params),
75
+ tag: "mesh",
76
+ },
63
77
  {
64
78
  name: "get_device_status",
65
79
  description:
@@ -71,7 +85,7 @@ export const meshTools: ToolDefinition[] = [
71
85
  {
72
86
  name: "device_exec",
73
87
  description:
74
- "Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it in its own sandbox (Android: app UID, or elevated via Shizuku if available). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session.",
88
+ "Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it in its own sandbox (Android: app UID, or elevated via Shizuku if available). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session. For persistent streams (log tailing, watchers), background with output redirected to a file — `cmd > /tmp/out.log 2>&1 &` returns immediately; poll the file with device_read_file.",
75
89
  schema: {
76
90
  device: deviceParam,
77
91
  cmd: z.string().describe("The shell command to run on the device."),