talon-agent 3.12.0 → 3.12.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": "3.12.0",
3
+ "version": "3.12.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",
@@ -102,7 +102,7 @@
102
102
  "@grammyjs/transformer-throttler": "^1.2.1",
103
103
  "@kilocode/sdk": "^7.2.22",
104
104
  "@modelcontextprotocol/sdk": "^1.29.0",
105
- "@openai/agents": "^0.13.0",
105
+ "@openai/agents": "^0.14.0",
106
106
  "@openai/codex-sdk": "^0.145.0",
107
107
  "@opencode-ai/sdk": "^1.17.4",
108
108
  "@playwright/mcp": "0.0.78",
@@ -31,7 +31,7 @@ import { scriptHandlers } from "./scripts.js";
31
31
  import { skillHandlers } from "./skills.js";
32
32
  import { pluginHandlers } from "./plugins.js";
33
33
  import { modelHandlers } from "./models.js";
34
- import { meshHandlers } from "./mesh.js";
34
+ import { meshHandlers, chatFreeActions } from "./mesh.js";
35
35
  import { nativeHandlers } from "./native.js";
36
36
 
37
37
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
@@ -62,4 +62,29 @@ export async function handleSharedAction(
62
62
  return handler(body, chatId, backend);
63
63
  }
64
64
 
65
+ /**
66
+ * True when the action needs no chat context at all (see `chatFreeActions`).
67
+ * The gateway checks this before resolving a chat so mesh tools stay reachable
68
+ * from heartbeat/background runs.
69
+ */
70
+ export function isChatFreeAction(action: string): boolean {
71
+ return chatFreeActions.has(action);
72
+ }
73
+
74
+ /**
75
+ * Dispatch a chat-free action. The handler signature still takes a chatId
76
+ * (they all share one type); chat-free handlers ignore it, and `0` is passed
77
+ * as an explicit "no chat" sentinel rather than a real id.
78
+ */
79
+ export async function handleChatFreeAction(
80
+ body: Record<string, unknown>,
81
+ ): Promise<ActionResult | null> {
82
+ const action = body.action as string;
83
+ if (!isChatFreeAction(action)) return null;
84
+ const handler = handlers[action];
85
+ if (!handler) return null;
86
+ return handler(body, 0);
87
+ }
88
+
89
+ export { chatFreeActions };
65
90
  export type { SharedActionHandler, SharedActionHandlers } from "./types.js";
@@ -78,3 +78,36 @@ export const meshHandlers: SharedActionHandlers = {
78
78
  body.bridge_url,
79
79
  ),
80
80
  };
81
+
82
+ /**
83
+ * Mesh actions are chat-free: every handler above reads `body` and the
84
+ * daemon-wide mesh registry, and not one of them takes the `chatId` the
85
+ * gateway threads through. Requiring an active chat context to reach them is
86
+ * therefore incidental to the transport, not a property of the action — and
87
+ * it makes the whole mesh unreachable from heartbeat/background runs, which
88
+ * have no ambient chat and no `chat_id` parameter on these tools to promote.
89
+ *
90
+ * The gateway consults this set before chat resolution so `list_devices`,
91
+ * `device_exec` and friends work from any run mode. Kept as an explicit
92
+ * export (rather than `Object.keys(meshHandlers)`) so adding a handler that
93
+ * *does* need a chat is a deliberate opt-out rather than a silent inclusion.
94
+ */
95
+ export const chatFreeActions: ReadonlySet<string> = new Set([
96
+ "list_devices",
97
+ "get_device_location",
98
+ "get_device_history",
99
+ "ring_device",
100
+ "remove_device",
101
+ "get_device_status",
102
+ "device_exec",
103
+ "device_list_dir",
104
+ "device_stat",
105
+ "device_read_file",
106
+ "device_write_file",
107
+ "device_pull_file",
108
+ "device_push_file",
109
+ "update_device",
110
+ "update_node",
111
+ "get_node_binary",
112
+ "make_node_install_link",
113
+ ]);
@@ -20,7 +20,11 @@ import { Loom, getActiveLoom, type ContextRegistry } from "../weaver/index.js";
20
20
  import { getHealthStatus } from "../../util/watchdog.js";
21
21
  import { getActiveSessionCount } from "../../storage/sessions.js";
22
22
  import { log, logError, logDebug } from "../../util/log.js";
23
- import { handleSharedAction } from "./gateway-actions/index.js";
23
+ import {
24
+ handleSharedAction,
25
+ handleChatFreeAction,
26
+ isChatFreeAction,
27
+ } from "./gateway-actions/index.js";
24
28
  import {
25
29
  handleHubRequest,
26
30
  getHubSessionCount,
@@ -249,6 +253,31 @@ export class Gateway {
249
253
  // active-context-required check — the action handler will reach the
250
254
  // chat directly via the Telegram Bot API. The legacy context-required
251
255
  // path remains for chat-mode calls where `chat_id` is absent.
256
+ // Chat-free actions (the device mesh) short-circuit routing entirely:
257
+ // they read daemon-wide state, ignore chatId, and are the only command
258
+ // channel a heartbeat run has to a remote box. Gating them behind an
259
+ // active chat made the whole mesh unreachable from background runs —
260
+ // and unlike send/react they carry no `chat_id` param to promote.
261
+ const requestedAction =
262
+ typeof body.action === "string" ? body.action : undefined;
263
+ if (requestedAction && isChatFreeAction(requestedAction)) {
264
+ const t0 = Date.now();
265
+ try {
266
+ const result = await handleChatFreeAction(body);
267
+ if (result) {
268
+ logDebug(
269
+ "gateway",
270
+ `${requestedAction} chat=none ${Date.now() - t0}ms (chat-free)`,
271
+ );
272
+ return result;
273
+ }
274
+ } catch (err) {
275
+ const msg = err instanceof Error ? err.message : String(err);
276
+ logError("gateway", `${requestedAction} (chat-free) failed: ${msg}`);
277
+ return { ok: false, error: `${requestedAction}: ${msg}` };
278
+ }
279
+ }
280
+
252
281
  const rawChatId = body._chatId ? String(body._chatId) : "";
253
282
  const numericId = Number(rawChatId);
254
283
  const explicitChatIdProvided = typeof body.chat_id !== "undefined";
@@ -15,7 +15,12 @@ import {
15
15
  MAX_OVERDUE_MS,
16
16
  type ScheduledMessage,
17
17
  } from "../../../storage/scheduled-store.js";
18
- import { sendText, toPositiveId } from "./shared.js";
18
+ import {
19
+ noteRichMessageFailure,
20
+ richMessagesAvailable,
21
+ sendText,
22
+ toPositiveId,
23
+ } from "./shared.js";
19
24
  import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
20
25
 
21
26
  // ── Inline keyboards ─────────────────────────────────────────────────────────
@@ -135,10 +140,8 @@ async function fireScheduled(bot: Bot, entry: ScheduledMessage): Promise<void> {
135
140
  return;
136
141
  }
137
142
  const keyboard = built.keyboard;
138
- await bot.api.sendMessage(chatId, markdownToTelegramHtml(entry.text), {
139
- parse_mode: "HTML",
140
- reply_markup: { inline_keyboard: keyboard },
141
- reply_parameters: replyTo ? { message_id: replyTo } : undefined,
143
+ await sendText(bot, chatId, entry.text, replyTo, {
144
+ inline_keyboard: keyboard,
142
145
  });
143
146
  } else {
144
147
  await sendText(bot, chatId, entry.text, replyTo);
@@ -246,12 +249,24 @@ export const messagingHandlers: TelegramActionHandlers = {
246
249
  ok: false,
247
250
  error: `Text too long (max ${TELEGRAM_MAX_TEXT})`,
248
251
  };
249
- const html = markdownToTelegramHtml(text);
250
252
  await withRetry(async () => {
253
+ if (richMessagesAvailable()) {
254
+ try {
255
+ await bot.api.editMessageText(chatId, Number(body.message_id), {
256
+ markdown: text,
257
+ });
258
+ return;
259
+ } catch (richErr) {
260
+ noteRichMessageFailure(richErr, `edit chat=${chatId}`);
261
+ }
262
+ }
251
263
  try {
252
- await bot.api.editMessageText(chatId, Number(body.message_id), html, {
253
- parse_mode: "HTML",
254
- });
264
+ await bot.api.editMessageText(
265
+ chatId,
266
+ Number(body.message_id),
267
+ markdownToTelegramHtml(text),
268
+ { parse_mode: "HTML" },
269
+ );
255
270
  } catch {
256
271
  await bot.api.editMessageText(chatId, Number(body.message_id), text);
257
272
  }
@@ -309,24 +324,15 @@ export const messagingHandlers: TelegramActionHandlers = {
309
324
  const text = String(body.text ?? "");
310
325
  if (text.length > TELEGRAM_MAX_TEXT)
311
326
  return { ok: false, error: `Text too long` };
312
- const html = markdownToTelegramHtml(text);
313
327
  const rows = body.rows as ButtonSpec[][];
314
328
  const built = buildInlineKeyboard(rows);
315
329
  if ("error" in built) return { ok: false, error: built.error };
316
330
  const keyboard = built.keyboard;
317
331
  gateway.incrementMessages(chatId);
318
- try {
319
- const sent = await bot.api.sendMessage(chatId, html, {
320
- parse_mode: "HTML",
321
- reply_markup: { inline_keyboard: keyboard },
322
- });
323
- return { ok: true, message_id: sent.message_id };
324
- } catch {
325
- const sent = await bot.api.sendMessage(chatId, text, {
326
- reply_markup: { inline_keyboard: keyboard },
327
- });
328
- return { ok: true, message_id: sent.message_id };
329
- }
332
+ const messageId = await sendText(bot, chatId, text, undefined, {
333
+ inline_keyboard: keyboard,
334
+ });
335
+ return { ok: true, message_id: messageId };
330
336
  },
331
337
 
332
338
  schedule_message: (body, chatId, { bot, scheduledMessages }) => {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared helpers for Telegram action handlers: reply-parameter extraction and
3
- * the HTML-with-plain-fallback text sender.
3
+ * native Rich Markdown delivery with legacy fallbacks.
4
4
  */
5
5
 
6
6
  import type { Bot } from "grammy";
@@ -27,31 +27,97 @@ export function toPositiveId(v: unknown): number | undefined {
27
27
  return Number.isInteger(n) && n > 0 ? n : undefined;
28
28
  }
29
29
 
30
+ /**
31
+ * Rich Messages arrived in Bot API 10.2. A self-hosted Bot API server (or a
32
+ * deployment pinned to an older release) rejects `sendRichMessage` outright,
33
+ * and retrying it per message would cost a doomed round-trip plus a warning
34
+ * line on every single send. Probe once and latch, mirroring the
35
+ * `draftsSupported` capability probe in `handlers/delivery.ts`.
36
+ */
37
+ let richMessagesSupported = true;
38
+
39
+ /** Telegram's shape for "this build doesn't have that method". */
40
+ const METHOD_UNAVAILABLE_RE =
41
+ /method not found|not supported|unknown method|unsupported method/i;
42
+
43
+ /** True while native Rich Markdown delivery is still worth attempting. */
44
+ export function richMessagesAvailable(): boolean {
45
+ return richMessagesSupported;
46
+ }
47
+
48
+ /**
49
+ * Log a failed Rich Message call and latch the capability off when the failure
50
+ * says the method itself is missing. Payload-level rejections (a markdown
51
+ * string Telegram won't parse) stay one-off — the next message may be fine.
52
+ */
53
+ export function noteRichMessageFailure(err: unknown, context: string): void {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ if (METHOD_UNAVAILABLE_RE.test(msg)) {
56
+ richMessagesSupported = false;
57
+ logWarn(
58
+ "bot",
59
+ `Rich Messages unavailable on this Bot API server — using legacy HTML from now on (${context}): ${msg}`,
60
+ );
61
+ return;
62
+ }
63
+ logWarn(
64
+ "bot",
65
+ `Rich Markdown failed; falling back to HTML (${context}): ${msg}`,
66
+ );
67
+ }
68
+
69
+ /** Test seam: re-arm the capability probe between cases. */
70
+ export function resetRichMessageSupport(): void {
71
+ richMessagesSupported = true;
72
+ }
73
+
30
74
  export async function sendText(
31
75
  bot: Bot,
32
76
  chatId: number,
33
77
  text: string,
34
78
  replyTo?: number,
79
+ replyMarkup?: NonNullable<
80
+ Parameters<Bot["api"]["sendRichMessage"]>[2]
81
+ >["reply_markup"],
35
82
  ): Promise<number> {
36
83
  if (text.length > TELEGRAM_MAX_TEXT) {
37
84
  throw new Error(
38
85
  `Message too long (${text.length} chars, max ${TELEGRAM_MAX_TEXT}).`,
39
86
  );
40
87
  }
88
+
89
+ if (richMessagesSupported) {
90
+ try {
91
+ const sent = await bot.api.sendRichMessage(
92
+ chatId,
93
+ { markdown: text },
94
+ {
95
+ reply_parameters: replyTo ? { message_id: replyTo } : undefined,
96
+ reply_markup: replyMarkup,
97
+ },
98
+ );
99
+ return sent.message_id;
100
+ } catch (err) {
101
+ noteRichMessageFailure(err, `send chat=${chatId}`);
102
+ }
103
+ }
104
+
41
105
  const html = markdownToTelegramHtml(text);
42
106
  try {
43
107
  const sent = await bot.api.sendMessage(chatId, html, {
44
108
  parse_mode: "HTML",
45
109
  reply_parameters: replyTo ? { message_id: replyTo } : undefined,
110
+ reply_markup: replyMarkup,
46
111
  });
47
112
  return sent.message_id;
48
113
  } catch (err) {
49
114
  logWarn(
50
115
  "bot",
51
- `sendText with parse_mode=HTML failed; retrying without parse_mode (chat=${chatId}): ${err instanceof Error ? err.message : err}`,
116
+ `Legacy HTML send failed; retrying as plain text (chat=${chatId}): ${err instanceof Error ? err.message : err}`,
52
117
  );
53
118
  const sent = await bot.api.sendMessage(chatId, text, {
54
119
  reply_parameters: replyTo ? { message_id: replyTo } : undefined,
120
+ reply_markup: replyMarkup,
55
121
  });
56
122
  return sent.message_id;
57
123
  }
@@ -5,12 +5,12 @@
5
5
 
6
6
  import type { Bot } from "grammy";
7
7
  import type { TalonConfig } from "../../../util/config.js";
8
- import { markdownToTelegramHtml } from "../formatting.js";
9
8
  import { execute } from "../../../core/engine/dispatcher.js";
10
9
  import { toolInputToRecord } from "../../../core/agent-runtime/events.js";
11
10
  import { appendDailyLogResponse } from "../../../storage/daily-log.js";
12
11
  import { stripMcpPrefix } from "../../../core/tools/index.js";
13
12
  import { logWarn } from "../../../util/log.js";
13
+ import { sendText } from "../actions/shared.js";
14
14
  import { trackDmUser } from "./access.js";
15
15
 
16
16
  export async function sendHtml(
@@ -112,7 +112,7 @@ function createStreamCallbacks(
112
112
  };
113
113
 
114
114
  const onTextBlock = async (text: string) => {
115
- await sendHtml(bot, chatId, markdownToTelegramHtml(text), _replyToId);
115
+ await sendText(bot, chatId, text, _replyToId);
116
116
  appendDailyLogResponse("Talon", text, { chatTitle });
117
117
  state.lastSentLength = 0;
118
118
  state.sentTextBlock = true;