talon-agent 5.28.0 → 5.29.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": "5.28.0",
3
+ "version": "5.29.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -111,7 +111,7 @@
111
111
  "@kilocode/sdk": "^7.2.22",
112
112
  "@modelcontextprotocol/sdk": "^1.29.0",
113
113
  "@openai/agents": "^0.18.0",
114
- "@openai/codex-sdk": "^0.155.0",
114
+ "@openai/codex-sdk": "^0.156.1",
115
115
  "@opencode-ai/sdk": "^1.17.4",
116
116
  "@playwright/mcp": "0.0.56",
117
117
  "@types/cross-spawn": "^6.0.6",
@@ -112,7 +112,10 @@ export function initAgents(
112
112
  "agents",
113
113
  `Initialized — maxConcurrent=${capsHolder.caps.maxConcurrent} ` +
114
114
  `maxDepth=${capsHolder.caps.maxDepth} ` +
115
- `timeout=${Math.round(capsHolder.caps.defaultTimeoutMs / 1000)}s`,
115
+ `timeout=${Math.round(capsHolder.caps.defaultTimeoutMs / 1000)}s` +
116
+ (capsHolder.caps.allowedBackends?.length
117
+ ? ` allowedBackends=${capsHolder.caps.allowedBackends.join(",")}`
118
+ : ""),
116
119
  );
117
120
  }
118
121
 
@@ -137,19 +140,37 @@ function inheritedBackendId(parent: AgentParent): string | null {
137
140
  return agentRegistry.get(parent.agentId)?.backendId ?? null;
138
141
  }
139
142
 
143
+ /** Where a spawn lands: backend, the model it inherits, and why. */
144
+ interface SpawnTarget {
145
+ readonly backendId: string | null;
146
+ /** The parent agent's model, inherited by an unpinned child. */
147
+ readonly inheritedModel?: string;
148
+ readonly routing?: string;
149
+ }
150
+
140
151
  /**
141
152
  * Which backend this agent runs on, and why.
142
153
  *
143
154
  * An explicit backend (or model — a model id is backend-specific, so naming
144
- * one pins its backend) is honoured as written. With neither, the run is a
145
- * routing decision: sub-agents are isolated one-shots with no session to
146
- * keep warm, so they are the cheapest work to move onto whichever
147
- * subscription has room.
155
+ * one pins its backend) is honoured as written. A child of another agent
156
+ * with neither inherits its parent's backend *and* model: a tree of agents
157
+ * stays on the backend its root was put on, so a parent that chose (or was
158
+ * told to use) a backend does not see its children wander off to another
159
+ * subscription. A top-level spawn with neither is a routing decision:
160
+ * sub-agents are isolated one-shots with no session to keep warm, so they
161
+ * are the cheapest work to move onto whichever subscription has room.
148
162
  */
149
- async function resolveSpawnBackend(
150
- spec: AgentSpawnSpec,
151
- ): Promise<{ backendId: string | null; routing?: string }> {
163
+ async function resolveSpawnBackend(spec: AgentSpawnSpec): Promise<SpawnTarget> {
152
164
  if (spec.backendId) return { backendId: spec.backendId };
165
+ if (spec.parent.kind === "agent" && !spec.model) {
166
+ const parent = agentRegistry.get(spec.parent.agentId);
167
+ if (parent) {
168
+ return {
169
+ backendId: parent.backendId,
170
+ ...(parent.model ? { inheritedModel: parent.model } : {}),
171
+ };
172
+ }
173
+ }
153
174
  const inherited = inheritedBackendId(spec.parent);
154
175
  if (!inherited) return { backendId: null };
155
176
  const taskClass = taskClassForEffort(spec.reasoningEffort);
@@ -166,12 +187,37 @@ async function resolveSpawnBackend(
166
187
  }
167
188
  : {}),
168
189
  });
190
+ // A routed pick outside the allowlist falls back to the inherited
191
+ // backend rather than refusing a spawn the caller never pinned.
192
+ if (
193
+ decision.routed &&
194
+ !isBackendAllowed(decision.backendId) &&
195
+ isBackendAllowed(inherited)
196
+ ) {
197
+ return { backendId: inherited };
198
+ }
169
199
  return {
170
200
  backendId: decision.backendId,
171
201
  ...(decision.routed ? { routing: decision.reason } : {}),
172
202
  };
173
203
  }
174
204
 
205
+ /** Whether `agents.allowedBackends` (when set) lets an agent run here. */
206
+ function isBackendAllowed(backendId: string): boolean {
207
+ const allowed = capsHolder.caps.allowedBackends;
208
+ return !allowed || allowed.length === 0 || allowed.includes(backendId);
209
+ }
210
+
211
+ /** The tool error for a backend outside `agents.allowedBackends`. */
212
+ function disallowedBackendError(backendId: string): string {
213
+ const allowed = capsHolder.caps.allowedBackends ?? [];
214
+ return (
215
+ `Backend "${backendId}" is not allowed for sub-agents ` +
216
+ `(agents.allowedBackends: ${allowed.join(", ")}). Pass one of those ` +
217
+ `as backend, or leave it unset to inherit.`
218
+ );
219
+ }
220
+
175
221
  /** The chat a run's task belongs to, for `talon ps`. */
176
222
  function taskChatId(parent: AgentParent): string | undefined {
177
223
  if (parent.kind === "chat") return parent.chatId;
@@ -262,6 +308,10 @@ export async function spawnAgent(
262
308
  "Could not resolve a backend for this agent — pass one explicitly.",
263
309
  };
264
310
  }
311
+ if (!isBackendAllowed(backendId)) {
312
+ return { ok: false, error: disallowedBackendError(backendId) };
313
+ }
314
+ const model = spec.model ?? routed.inheritedModel;
265
315
 
266
316
  // Register first: the slot and the depth are claimed synchronously, so two
267
317
  // concurrent spawns can never both slip past maxConcurrent while awaiting
@@ -296,7 +346,7 @@ export async function spawnAgent(
296
346
  };
297
347
  }
298
348
 
299
- const resolved = await resolveRun(acquired.backend, backendId, spec.model);
349
+ const resolved = await resolveRun(acquired.backend, backendId, model);
300
350
  if (!resolved.ok) {
301
351
  agentRegistry.discard(record.id);
302
352
  await acquired.release();
@@ -94,9 +94,16 @@ export interface AgentSpawnSpec {
94
94
  readonly brief: string;
95
95
  readonly label: string;
96
96
  readonly parent: AgentParent;
97
- /** Defaults to the parent's backend. */
97
+ /**
98
+ * Unset: a child of another agent inherits its parent's backend (and,
99
+ * with no model either, its model); a top-level spawn starts from the
100
+ * chat's backend and may be routed.
101
+ */
98
102
  readonly backendId?: string;
99
- /** Defaults to the resolved backend's own default model. */
103
+ /**
104
+ * Defaults to the parent agent's model when the backend is inherited from
105
+ * one, else the resolved backend's own default model.
106
+ */
100
107
  readonly model?: string;
101
108
  readonly reasoningEffort?: ReasoningEffortLevel;
102
109
  /** Hard wall-clock cap. Defaults to `agents.defaultTimeoutMs`. */
@@ -132,4 +139,9 @@ export interface AgentCaps {
132
139
  readonly maxDepth: number;
133
140
  /** Default hard timeout for one run. */
134
141
  readonly defaultTimeoutMs: number;
142
+ /**
143
+ * Backends a sub-agent may run on. Unset or empty = any backend with a
144
+ * background capability. Enforced by `spawnAgent` on the final choice.
145
+ */
146
+ readonly allowedBackends?: readonly string[];
135
147
  }
@@ -643,6 +643,9 @@ const configSchema = z.object({
643
643
  * default) = chat → agent → agent.
644
644
  * - `defaultTimeoutMs` — hard wall-clock cap when a spawn doesn't pass
645
645
  * its own. Per-spawn values are clamped to [30s, 60min].
646
+ * - `allowedBackends` — optional allowlist of backend ids sub-agents
647
+ * may run on. Unset = any backend with a background capability. A
648
+ * spawn that names (or inherits) a backend outside it is refused.
646
649
  */
647
650
  agents: z
648
651
  .object({
@@ -654,6 +657,7 @@ const configSchema = z.object({
654
657
  .min(30_000)
655
658
  .max(3_600_000)
656
659
  .default(15 * 60 * 1000),
660
+ allowedBackends: z.array(z.string().min(1)).optional(),
657
661
  })
658
662
  .optional(),
659
663
  /**
@@ -35,8 +35,8 @@ Bad uses: anything needing a back-and-forth with the user, trivial work you
35
35
  could do in one tool call, or work whose result you need in the next second
36
36
  (spawning costs a model cold-start).
37
37
 
38
- Backend and model default to this chat's; pass them to put the agent
39
- somewhere else (e.g. a cheap model for a mechanical sweep, or a backend with
38
+ Backend and model default to the caller's (a sub-agent's own children
39
+ inherit its backend and model); pass them to put the agent somewhere else (e.g. a cheap model for a mechanical sweep, or a backend with
40
40
  a bigger context window). Returns the agent id immediately.`;
41
41
 
42
42
  export const agentTools: ToolDefinition[] = [
@@ -61,13 +61,13 @@ export const agentTools: ToolDefinition[] = [
61
61
  .string()
62
62
  .optional()
63
63
  .describe(
64
- "Backend id to run on. Unset = this chat's backend (agents inherit their parent's). Must have a background capability.",
64
+ "Backend id to run on. Unset = inherit: an agent's children run on its own backend and model; a chat's spawns start from the chat's backend (and may be routed to one with more headroom). Must have a background capability and, when the deployment sets agents.allowedBackends, be on that list.",
65
65
  ),
66
66
  model: z
67
67
  .string()
68
68
  .optional()
69
69
  .describe(
70
- "Model id on the chosen backend. Unset = that backend's default model. Call list_models for valid ids.",
70
+ "Model id on the chosen backend. Unset = the parent agent's model when the backend is inherited from one, else that backend's default model. Call list_models for valid ids.",
71
71
  ),
72
72
  effort: z
73
73
  .enum(["minimal", "low", "medium", "high", "xhigh"])
@@ -66,15 +66,17 @@ export async function handleMessage(
66
66
  if (msg.attachments.size > 0) {
67
67
  for (const att of msg.attachments.values()) {
68
68
  try {
69
- const savedPath = await downloadAttachment(att, config.workspace);
69
+ const downloadedPath = await downloadAttachment(att, config.workspace);
70
70
  const cls = classifyAttachment(att);
71
- setMessageFilePath(chatId, numericMessageId, savedPath);
72
- addMedia({
71
+ setMessageFilePath(chatId, numericMessageId, downloadedPath);
72
+ // addMedia may dedupe onto an existing identical file and delete
73
+ // the fresh download — prompt with the path it resolves to.
74
+ const savedPath = await addMedia({
73
75
  chatId,
74
76
  msgId: numericMessageId,
75
77
  senderName: sender,
76
78
  type: cls.type,
77
- filePath: savedPath,
79
+ filePath: downloadedPath,
78
80
  caption: cleanedContent || undefined,
79
81
  timestamp: Date.now(),
80
82
  });
@@ -21,6 +21,7 @@ import {
21
21
  getPinnedMessages as userbotPinnedMessages,
22
22
  getOnlineCount as userbotOnlineCount,
23
23
  } from "../userbot.js";
24
+ import { getMediaForMessage } from "../../../storage/media-index.js";
24
25
  import { savePackToLibrary } from "../sticker-library.js";
25
26
  import { toPositiveId } from "./coerce.js";
26
27
  import type { TelegramActionHandlers } from "./types.js";
@@ -308,16 +309,32 @@ export const chatInfoHandlers: TelegramActionHandlers = {
308
309
  },
309
310
 
310
311
  download_media: async (body, chatId) => {
312
+ const msgId = toPositiveId(body.message_id);
313
+ if (!msgId) return { ok: false, error: "Required: message_id" };
314
+ // Media the bot received is already on disk and indexed. Answer from
315
+ // the index first: the userbot can't see messages in the bot's own
316
+ // DMs (message ids there are per-account), so asking it for an
317
+ // inbound photo by id reports "not found".
318
+ const indexed = getMediaForMessage(String(chatId), msgId);
319
+ if (indexed && existsSync(indexed.filePath)) {
320
+ return {
321
+ ok: true,
322
+ text: `Saved at: ${indexed.filePath} (${indexed.type}). Use the Read tool on this path to view the content.`,
323
+ file_path: indexed.filePath,
324
+ };
325
+ }
311
326
  if (isUserClientReady()) {
312
327
  const { downloadMessageMedia } = await import("../userbot.js");
313
328
  return {
314
329
  ok: true,
315
- text: await downloadMessageMedia({
316
- chatId,
317
- messageId: Number(body.message_id),
318
- }),
330
+ text: await downloadMessageMedia({ chatId, messageId: msgId }),
319
331
  };
320
332
  }
321
- return { ok: false, error: "User client not connected." };
333
+ return {
334
+ ok: false,
335
+ error: indexed
336
+ ? `Media for message ${msgId} is no longer on disk and the user client is not connected to re-download it.`
337
+ : "User client not connected.",
338
+ };
322
339
  },
323
340
  };
@@ -72,16 +72,18 @@ async function handleMediaMessage(
72
72
  return;
73
73
  }
74
74
 
75
- const savedPath = await downloadTelegramFile(
75
+ const downloadedPath = await downloadTelegramFile(
76
76
  bot,
77
77
  config,
78
78
  media.fileId,
79
79
  media.fileName,
80
80
  );
81
81
 
82
- // Store file path in history + media index
83
- setMessageFilePath(chatId, ctx.message.message_id, savedPath);
84
- addMedia({
82
+ // Store file path in history + media index. addMedia may dedupe the
83
+ // download onto an identical file already on disk (and delete the
84
+ // fresh one), so the prompt must use the path it resolves to.
85
+ setMessageFilePath(chatId, ctx.message.message_id, downloadedPath);
86
+ const savedPath = await addMedia({
85
87
  chatId,
86
88
  msgId: ctx.message.message_id,
87
89
  senderName: sender,
@@ -93,7 +95,7 @@ async function handleMediaMessage(
93
95
  | "animation"
94
96
  | "audio"
95
97
  | "sticker",
96
- filePath: savedPath,
98
+ filePath: downloadedPath,
97
99
  caption: media.caption,
98
100
  timestamp: Date.now(),
99
101
  });
@@ -96,7 +96,9 @@ export async function saveInboundMedia(
96
96
  writeFileSync(filePath, buffer);
97
97
 
98
98
  const caption = content?.caption ?? undefined;
99
- addMedia({
99
+ // addMedia may dedupe onto an existing identical file and delete
100
+ // this download — report the path it resolves to.
101
+ const savedPath = await addMedia({
100
102
  chatId,
101
103
  msgId,
102
104
  senderName,
@@ -105,7 +107,7 @@ export async function saveInboundMedia(
105
107
  timestamp: Date.now(),
106
108
  ...(caption ? { caption } : {}),
107
109
  });
108
- return { filePath, type, ...(caption ? { caption } : {}) };
110
+ return { filePath: savedPath, type, ...(caption ? { caption } : {}) };
109
111
  } catch (err) {
110
112
  logWarn(
111
113
  "whatsapp",
@@ -73,7 +73,19 @@ function importLegacyMediaIndex(): void {
73
73
 
74
74
  // ── CRUD ────────────────────────────────────────────────────────────────────
75
75
 
76
- export function addMedia(entry: Omit<MediaEntry, "id">): void {
76
+ /**
77
+ * Index a downloaded file and dedupe it against identical content.
78
+ *
79
+ * Resolves to the path the message's media now lives at — the
80
+ * canonical copy when the download duplicated one already on disk
81
+ * (the fresh file is then deleted), otherwise `entry.filePath`.
82
+ * Callers MUST use the resolved path for anything they show the
83
+ * model: the original path may no longer exist once this settles.
84
+ *
85
+ * The row is written synchronously, so it is queryable before the
86
+ * hash finishes.
87
+ */
88
+ export async function addMedia(entry: Omit<MediaEntry, "id">): Promise<string> {
77
89
  try {
78
90
  repo.upsert(entry);
79
91
  } catch (err) {
@@ -85,13 +97,32 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
85
97
  recordError(
86
98
  `Media index write failed: ${err instanceof Error ? err.message : err}`,
87
99
  );
88
- return;
100
+ return entry.filePath;
89
101
  }
90
- // Hash + dedupe off the hot path — the caller is mid-message-handling
91
- // and the row is already queryable without the hash.
92
- void hashAndDedupe(entry).catch((err) =>
93
- logError("media", `Media content hash failed for ${entry.filePath}`, err),
102
+ // Serialize dedupe: two identical files hashed concurrently (e.g. an
103
+ // album) could otherwise each pick the other as canonical and both
104
+ // get unlinked.
105
+ const run = dedupeChain.then(() => hashAndDedupe(entry));
106
+ dedupeChain = run.then(
107
+ () => undefined,
108
+ () => undefined,
94
109
  );
110
+ try {
111
+ return await run;
112
+ } catch (err) {
113
+ logError("media", `Media content hash failed for ${entry.filePath}`, err);
114
+ return entry.filePath;
115
+ }
116
+ }
117
+
118
+ let dedupeChain: Promise<void> = Promise.resolve();
119
+
120
+ /** The indexed entry for a message, if any. */
121
+ export function getMediaForMessage(
122
+ chatId: string,
123
+ msgId: number,
124
+ ): MediaEntry | undefined {
125
+ return repo.byMessage(chatId, msgId);
95
126
  }
96
127
 
97
128
  /**
@@ -101,15 +132,15 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
101
132
  * canonical copy and drop the duplicate file, so re-posted media costs
102
133
  * one copy on disk no matter how many messages carry it.
103
134
  */
104
- async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<void> {
105
- if (!existsSync(entry.filePath)) return; // gone already (expiry, tests)
135
+ async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<string> {
136
+ if (!existsSync(entry.filePath)) return entry.filePath; // gone already (expiry, tests)
106
137
  const hash = await blake3HexFile(entry.filePath);
107
138
  repo.setContentHash(entry.chatId, entry.msgId, hash);
108
139
 
109
140
  const canonical = repo.firstByContentHash(hash, entry.chatId, entry.msgId);
110
- if (!canonical) return;
111
- if (canonical.filePath === entry.filePath) return; // re-download of the same path
112
- if (!existsSync(canonical.filePath)) return; // canonical copy lost — keep ours
141
+ if (!canonical) return entry.filePath;
142
+ if (canonical.filePath === entry.filePath) return entry.filePath; // re-download of the same path
143
+ if (!existsSync(canonical.filePath)) return entry.filePath; // canonical copy lost — keep ours
113
144
 
114
145
  repo.setFilePath(entry.chatId, entry.msgId, canonical.filePath);
115
146
  setMessageFilePath(entry.chatId, entry.msgId, canonical.filePath);
@@ -131,6 +162,7 @@ async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<void> {
131
162
  );
132
163
  }
133
164
  }
165
+ return canonical.filePath;
134
166
  }
135
167
 
136
168
  /** Get recent media for a chat, newest first. */
@@ -33,7 +33,7 @@ export type MediaEntry = {
33
33
  timestamp: number;
34
34
  /**
35
35
  * BLAKE3 hex digest of the file contents (native/blake3-wasm).
36
- * Filled in asynchronously after addMedia; undefined until hashed.
36
+ * Filled in by addMedia before it resolves; undefined until hashed.
37
37
  */
38
38
  contentHash?: string;
39
39
  };
@@ -112,6 +112,17 @@ export function firstByContentHash(
112
112
  return row ? rowToEntry(row) : undefined;
113
113
  }
114
114
 
115
+ /** The entry for one message, if indexed. */
116
+ export function byMessage(
117
+ chatId: string,
118
+ msgId: number,
119
+ ): MediaEntry | undefined {
120
+ const row = getDatabase()
121
+ .prepare(mediaIndexSql.byMessage)
122
+ .get(chatId, msgId) as Row | undefined;
123
+ return row ? rowToEntry(row) : undefined;
124
+ }
125
+
115
126
  /** Entries currently pointing at a file — dedupe makes this > 1. */
116
127
  export function countByFilePath(filePath: string): number {
117
128
  const row = getDatabase()
@@ -43,3 +43,7 @@ ORDER BY timestamp ASC, rowid ASC LIMIT 1
43
43
 
44
44
  -- name: countByFilePath
45
45
  SELECT COUNT(*) AS n FROM media_index WHERE file_path = ?
46
+
47
+ -- name: byMessage
48
+ SELECT chat_id, msg_id, sender_name, type, file_path, caption, timestamp, content_hash
49
+ FROM media_index WHERE chat_id = ? AND msg_id = ?
@@ -767,6 +767,8 @@ FROM media_index
767
767
  WHERE content_hash = ? AND NOT (chat_id = ? AND msg_id = ?)
768
768
  ORDER BY timestamp ASC, rowid ASC LIMIT 1`,
769
769
  countByFilePath: `SELECT COUNT(*) AS n FROM media_index WHERE file_path = ?`,
770
+ byMessage: `SELECT chat_id, msg_id, sender_name, type, file_path, caption, timestamp, content_hash
771
+ FROM media_index WHERE chat_id = ? AND msg_id = ?`,
770
772
  } as const;
771
773
 
772
774
  export const memorySql = {