talon-agent 1.9.0 → 1.9.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Regression + audit tests — ID-shaped tool params use the strict
3
+ * shared `idSchema` from `core/tools/schemas.ts`.
4
+ *
5
+ * Background: some MCP transport / model paths deliver `message_id`,
6
+ * `user_id`, `reply_to`, `offset_id` as JSON strings ("2081") rather
7
+ * than numbers (2081). Plain `z.number()` rejects those with
8
+ * `expected number, received string`, which manifested as
9
+ * `react`/`pin_message`/`get_member_info`/`download_media` failing
10
+ * out of nowhere even when the model formatted the call correctly
11
+ * for a number-typed JSON Schema field.
12
+ *
13
+ * Initial fix used `z.coerce.number().int()`, but per Copilot review
14
+ * that was too lax — `""`/`null` coerce to 0 and `true` to 1, which
15
+ * then pass `.int()` and reach the bot API. The current `idSchema` is
16
+ * a union: `z.number().int().positive()` OR a digit-only string that
17
+ * gets transformed to a positive integer. Everything else is rejected.
18
+ */
19
+ import { describe, it, expect } from "vitest";
20
+ import { z } from "zod";
21
+ import { ALL_TOOLS } from "../core/tools/index.js";
22
+
23
+ const ID_FIELD_NAMES = new Set([
24
+ "message_id",
25
+ "user_id",
26
+ "reply_to",
27
+ "offset_id",
28
+ ]);
29
+
30
+ function getIdSchema(toolName: string, field: string): z.ZodTypeAny {
31
+ const tool = ALL_TOOLS.find((t) => t.name === toolName);
32
+ if (!tool) throw new Error(`tool ${toolName} not found`);
33
+ const schema = (tool.schema as Record<string, z.ZodTypeAny>)[field];
34
+ if (!schema) throw new Error(`field ${field} not found on ${toolName}`);
35
+ return schema;
36
+ }
37
+
38
+ describe("ID-shaped tool params accept stringified numbers", () => {
39
+ // Spot-check a representative set across all four files.
40
+ const cases: Array<[string, string]> = [
41
+ ["react", "message_id"],
42
+ ["edit_message", "message_id"],
43
+ ["delete_message", "message_id"],
44
+ ["forward_message", "message_id"],
45
+ ["pin_message", "message_id"],
46
+ ["unpin_message", "message_id"],
47
+ ["stop_poll", "message_id"],
48
+ ["send", "reply_to"],
49
+ ["get_member_info", "user_id"],
50
+ ["create_sticker_set", "user_id"],
51
+ ["add_sticker_to_set", "user_id"],
52
+ ["read_chat_history", "offset_id"],
53
+ ["get_message_by_id", "message_id"],
54
+ ["download_media", "message_id"],
55
+ ];
56
+
57
+ for (const [tool, field] of cases) {
58
+ it(`${tool}.${field} accepts a number`, () => {
59
+ const s = getIdSchema(tool, field);
60
+ const out = s.parse(2081);
61
+ expect(out).toBe(2081);
62
+ });
63
+
64
+ it(`${tool}.${field} accepts a numeric string and coerces it`, () => {
65
+ const s = getIdSchema(tool, field);
66
+ const out = s.parse("2081");
67
+ expect(out).toBe(2081);
68
+ });
69
+
70
+ it(`${tool}.${field} rejects a non-numeric string`, () => {
71
+ const s = getIdSchema(tool, field);
72
+ expect(() => s.parse("abc")).toThrow();
73
+ });
74
+
75
+ it(`${tool}.${field} rejects a non-integer`, () => {
76
+ const s = getIdSchema(tool, field);
77
+ expect(() => s.parse(1.5)).toThrow();
78
+ });
79
+ }
80
+ });
81
+
82
+ describe("Audit: every ID-shaped field in tool schemas is the strict idSchema", () => {
83
+ /**
84
+ * For each ID field on every tool, this test asserts the full
85
+ * accept/reject contract of `idSchema` — not just "accepts a
86
+ * string." That guards against a future replacement that passes
87
+ * the loose check (`safeParse("123")` succeeds) but doesn't
88
+ * actually return a positive integer (e.g. `z.string()` would
89
+ * accept "123" and return the string "123").
90
+ */
91
+ for (const tool of ALL_TOOLS) {
92
+ const schema = tool.schema as Record<string, unknown>;
93
+ for (const field of Object.keys(schema)) {
94
+ if (!ID_FIELD_NAMES.has(field)) continue;
95
+ const zSchema = schema[field] as z.ZodTypeAny;
96
+
97
+ it(`${tool.name}.${field}: accepts a positive integer number`, () => {
98
+ const r = zSchema.safeParse(2081);
99
+ expect(r.success).toBe(true);
100
+ if (r.success) expect(r.data).toBe(2081);
101
+ });
102
+
103
+ it(`${tool.name}.${field}: accepts a digit string and returns a number`, () => {
104
+ const r = zSchema.safeParse("2081");
105
+ expect(r.success).toBe(true);
106
+ if (r.success) {
107
+ expect(typeof r.data).toBe("number");
108
+ expect(Number.isInteger(r.data as number)).toBe(true);
109
+ expect(r.data).toBe(2081);
110
+ }
111
+ });
112
+
113
+ // Reject the inputs that bare `z.coerce.number().int()` was too
114
+ // permissive about — empty/whitespace/null/booleans coerce to
115
+ // 0/0/0/1 and would pass `.int()`. The strict union rejects all.
116
+ const rejectCases: Array<[unknown, string]> = [
117
+ ["", "empty string"],
118
+ [" ", "whitespace string"],
119
+ ["abc", "non-numeric string"],
120
+ ["2081abc", "mixed string"],
121
+ [null, "null"],
122
+ [true, "true"],
123
+ [false, "false"],
124
+ [0, "zero"],
125
+ [-1, "negative integer"],
126
+ [1.5, "non-integer"],
127
+ ];
128
+ for (const [bad, label] of rejectCases) {
129
+ it(`${tool.name}.${field}: rejects ${label}`, () => {
130
+ const r = zSchema.safeParse(bad);
131
+ expect(r.success).toBe(false);
132
+ });
133
+ }
134
+ }
135
+ }
136
+ });
@@ -227,10 +227,9 @@ export async function handleMessage(
227
227
  // ── Build result ──────────────────────────────────────────────────────────
228
228
 
229
229
  state.allResponseText += state.currentBlockText;
230
- const totalPrompt =
231
- state.sdkInputTokens + state.sdkCacheRead + state.sdkCacheWrite;
230
+ const cacheTotal = state.sdkInputTokens + state.sdkCacheRead;
232
231
  const cacheHitPct =
233
- totalPrompt > 0 ? Math.round((state.sdkCacheRead / totalPrompt) * 100) : 0;
232
+ cacheTotal > 0 ? Math.round((state.sdkCacheRead / cacheTotal) * 100) : 0;
234
233
 
235
234
  log(
236
235
  "agent",
@@ -21,6 +21,11 @@ export const DISALLOWED_TOOLS_CORE = [
21
21
  "TaskOutput",
22
22
  "TaskStop",
23
23
  "AskUserQuestion",
24
+ // ScheduleWakeup is a /loop-skill-only tool. Calling it outside /loop dynamic
25
+ // mode registers a wakeup the runtime never fires, leaving the dispatcher
26
+ // wedged with the chat lock held until manual restart. Confirmed root cause
27
+ // of a 35-minute hang on 2026-04-27 (talon.log [e2589f7e]).
28
+ "ScheduleWakeup",
24
29
  ] as const;
25
30
 
26
31
  /** Disallowed tools for background agents — dream and heartbeat (core + Agent). */
@@ -267,9 +267,19 @@ export class Gateway {
267
267
  res.end(json);
268
268
  } catch (err) {
269
269
  if (res.headersSent) return;
270
- const msg = err instanceof Error ? err.message : String(err);
270
+ // Log full error (incl. stack via logError's structured `stack`
271
+ // field) on the server; return a generic message to the client so
272
+ // we don't leak implementation details.
273
+ // CodeQL: js/stack-trace-exposure (alert #4).
274
+ logError(
275
+ "gateway",
276
+ `Unhandled error on ${req.method} ${req.url}`,
277
+ err,
278
+ );
271
279
  res.writeHead(500, { "Content-Type": "application/json" });
272
- res.end(JSON.stringify({ ok: false, error: msg }));
280
+ res.end(
281
+ JSON.stringify({ ok: false, error: "Internal server error" }),
282
+ );
273
283
  }
274
284
  },
275
285
  );
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const historyTools: ToolDefinition[] = [
9
10
  {
@@ -19,7 +20,7 @@ export const historyTools: ToolDefinition[] = [
19
20
  .string()
20
21
  .optional()
21
22
  .describe("Fetch messages before this date (ISO format)"),
22
- offset_id: z.number().optional().describe("Fetch before this message ID"),
23
+ offset_id: idSchema.optional().describe("Fetch before this message ID"),
23
24
  },
24
25
  execute: (params, bridge) =>
25
26
  bridge("read_history", {
@@ -58,7 +59,7 @@ export const historyTools: ToolDefinition[] = [
58
59
  {
59
60
  name: "get_message_by_id",
60
61
  description: "Get a specific message by ID.",
61
- schema: { message_id: z.number() },
62
+ schema: { message_id: idSchema },
62
63
  execute: (params, bridge) => bridge("get_message_by_id", params),
63
64
  frontends: ["telegram"],
64
65
  tag: "history",
@@ -69,9 +70,9 @@ export const historyTools: ToolDefinition[] = [
69
70
  description:
70
71
  "Download a photo, document, or other media from a message by its ID. Saves the file to the workspace and returns the file path so you can read/analyze it. Use this when you see a [photo] or [document] in chat history but don't have the file.",
71
72
  schema: {
72
- message_id: z
73
- .number()
74
- .describe("Message ID containing the media to download"),
73
+ message_id: idSchema.describe(
74
+ "Message ID containing the media to download",
75
+ ),
75
76
  },
76
77
  execute: (params, bridge) => bridge("download_media", params),
77
78
  frontends: ["telegram"],
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const memberTools: ToolDefinition[] = [
9
10
  {
@@ -19,7 +20,7 @@ export const memberTools: ToolDefinition[] = [
19
20
  {
20
21
  name: "get_member_info",
21
22
  description: "Get detailed info about a user by ID.",
22
- schema: { user_id: z.number() },
23
+ schema: { user_id: idSchema },
23
24
  execute: (params, bridge) => bridge("get_member_info", params),
24
25
  frontends: ["telegram"],
25
26
  tag: "members",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const messagingTools: ToolDefinition[] = [
9
10
  // ── Telegram unified send ─────────────────────────────────────────────
@@ -43,7 +44,7 @@ Examples:
43
44
  .string()
44
45
  .optional()
45
46
  .describe("Message text (for type=text). Supports Markdown."),
46
- reply_to: z.number().optional().describe("Message ID to reply to"),
47
+ reply_to: idSchema.optional().describe("Message ID to reply to"),
47
48
  file_path: z
48
49
  .string()
49
50
  .optional()
@@ -227,7 +228,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
227
228
  description:
228
229
  "Add an emoji reaction to a message. Valid: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉 🤩 🤮 💩 🙏 👌 🕊 🤡 🥱 🥴 😍 🐳 ❤‍🔥 🌚 🌭 💯 🤣 ⚡ 🍌 🏆 💔 🤨 😐 🍓 🍾 💋 🖕 😈 😴 😭 🤓 👻 👨‍💻 👀 🎃 🙈 😇 😨 🤝 ✍ 🤗 🫡 🎅 🎄 ☃ 💅 🤪 🗿 🆒 💘 🙉 🦄 😘 💊 🙊 😎 👾 🤷 🤷‍♂ 🤷‍♀ 😡",
229
230
  schema: {
230
- message_id: z.number().describe("Message ID"),
231
+ message_id: idSchema.describe("Message ID"),
231
232
  emoji: z.string().describe("Reaction emoji"),
232
233
  },
233
234
  execute: (params, bridge) => bridge("react", params),
@@ -239,7 +240,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
239
240
  {
240
241
  name: "edit_message",
241
242
  description: "Edit a previously sent message.",
242
- schema: { message_id: z.number(), text: z.string() },
243
+ schema: { message_id: idSchema, text: z.string() },
243
244
  execute: (params, bridge) => bridge("edit_message", params),
244
245
  frontends: ["telegram"],
245
246
  tag: "messaging",
@@ -249,7 +250,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
249
250
  {
250
251
  name: "delete_message",
251
252
  description: "Delete a message.",
252
- schema: { message_id: z.number() },
253
+ schema: { message_id: idSchema },
253
254
  execute: (params, bridge) => bridge("delete_message", params),
254
255
  frontends: ["telegram"],
255
256
  tag: "messaging",
@@ -259,7 +260,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
259
260
  {
260
261
  name: "forward_message",
261
262
  description: "Forward a message within the chat.",
262
- schema: { message_id: z.number() },
263
+ schema: { message_id: idSchema },
263
264
  execute: (params, bridge) => bridge("forward_message", params),
264
265
  frontends: ["telegram"],
265
266
  tag: "messaging",
@@ -269,7 +270,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
269
270
  {
270
271
  name: "pin_message",
271
272
  description: "Pin a message.",
272
- schema: { message_id: z.number() },
273
+ schema: { message_id: idSchema },
273
274
  execute: (params, bridge) => bridge("pin_message", params),
274
275
  frontends: ["telegram"],
275
276
  tag: "messaging",
@@ -279,7 +280,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
279
280
  {
280
281
  name: "unpin_message",
281
282
  description: "Unpin a message.",
282
- schema: { message_id: z.number().optional() },
283
+ schema: { message_id: idSchema.optional() },
283
284
  execute: (params, bridge) => bridge("unpin_message", params),
284
285
  frontends: ["telegram"],
285
286
  tag: "messaging",
@@ -291,7 +292,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
291
292
  description:
292
293
  "Stop an active poll and get the final results. Returns vote counts for each option.",
293
294
  schema: {
294
- message_id: z.number().describe("Message ID of the poll to stop"),
295
+ message_id: idSchema.describe("Message ID of the poll to stop"),
295
296
  },
296
297
  execute: (params, bridge) => bridge("stop_poll", params),
297
298
  frontends: ["telegram"],
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared zod schema fragments for tool input definitions.
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * Telegram-style positive integer ID (message_id, user_id, reply_to,
9
+ * offset_id, etc.).
10
+ *
11
+ * Accepts:
12
+ * - actual numbers that are positive integers (`2081`)
13
+ * - digit-only strings (`"2081"`) which are transformed into numbers
14
+ *
15
+ * Rejects:
16
+ * - non-numeric strings (`"abc"`, `"2081abc"`, `""`, `" "`)
17
+ * - booleans, `null`, `undefined` (would otherwise coerce to 0/1)
18
+ * - non-integer numbers (`1.5`)
19
+ * - zero and negatives
20
+ *
21
+ * Use this instead of `z.number()` or `z.coerce.number()` for any ID
22
+ * field on tool input schemas. The plain coercion path was too lax —
23
+ * `z.coerce.number().int()` happily turns `null`/`""` into `0` and
24
+ * `true` into `1`, both of which the Telegram bot API would then
25
+ * dispatch to. The strict union avoids that.
26
+ */
27
+ export const idSchema = z.union([
28
+ z.number().int().positive(),
29
+ z
30
+ .string()
31
+ .regex(/^\d+$/, "must be a positive integer")
32
+ .transform((s) => Number(s))
33
+ .pipe(z.number().int().positive()),
34
+ ]);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const stickerTools: ToolDefinition[] = [
9
10
  {
@@ -56,7 +57,7 @@ The set name will automatically get "_by_<botname>" appended if needed.
56
57
 
57
58
  Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers", file_path="/path/to/sticker.png", emoji_list=["😎"])`,
58
59
  schema: {
59
- user_id: z.number().describe("Telegram user ID who will own the pack"),
60
+ user_id: idSchema.describe("Telegram user ID who will own the pack"),
60
61
  name: z
61
62
  .string()
62
63
  .describe(
@@ -85,7 +86,7 @@ Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers"
85
86
  description:
86
87
  "Add a new sticker to an existing sticker pack created by the bot.",
87
88
  schema: {
88
- user_id: z.number().describe("Telegram user ID who owns the pack"),
89
+ user_id: idSchema.describe("Telegram user ID who owns the pack"),
89
90
  name: z.string().describe("Sticker set name (including _by_<botname>)"),
90
91
  file_path: z.string().describe("Path to the sticker image file"),
91
92
  emoji_list: z
@@ -529,10 +529,9 @@ export function registerCommands(
529
529
  "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
530
530
  const contextWarn = ctxPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
531
531
 
532
- const totalPrompt =
533
- displayInputTokens + displayCacheRead + displayCacheWrite;
532
+ const cacheTotal = displayInputTokens + displayCacheRead;
534
533
  const cacheHitPct =
535
- totalPrompt > 0 ? Math.round((displayCacheRead / totalPrompt) * 100) : 0;
534
+ cacheTotal > 0 ? Math.round((displayCacheRead / cacheTotal) * 100) : 0;
536
535
 
537
536
  const avgResponseMs =
538
537
  info.turns > 0 && u.totalResponseMs
@@ -8,10 +8,6 @@ import type { TalonConfig } from "../../util/config.js";
8
8
  import { markdownToTelegramHtml, escapeHtml } from "./formatting.js";
9
9
  import { execute } from "../../core/dispatcher.js";
10
10
  import { classify, friendlyMessage } from "../../core/errors.js";
11
- import {
12
- enrichDMPrompt,
13
- enrichGroupPrompt,
14
- } from "../../core/prompt-builder.js";
15
11
  import { writeFileSync, mkdirSync, existsSync } from "node:fs";
16
12
  import { resolve } from "node:path";
17
13
  import {
@@ -759,19 +755,15 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
759
755
  stream,
760
756
  );
761
757
 
762
- // Enrich prompt with sender context
763
- let enrichedPrompt = prompt;
764
- if (!isGroup && senderName) {
765
- enrichedPrompt = enrichDMPrompt(prompt, senderName, senderUsername);
766
- if (senderId) trackDmUser(senderId, senderName, senderUsername);
767
- } else if (isGroup && senderId) {
768
- enrichedPrompt = enrichGroupPrompt(prompt, String(chatId), senderId);
758
+ // Track first-time DM users for logging (no prompt mutation).
759
+ if (!isGroup && senderName && senderId) {
760
+ trackDmUser(senderId, senderName, senderUsername);
769
761
  }
770
762
 
771
763
  const result = await execute({
772
764
  chatId: String(chatId),
773
765
  numericChatId,
774
- prompt: enrichedPrompt,
766
+ prompt,
775
767
  senderName,
776
768
  isGroup,
777
769
  messageId,
package/src/util/log.ts CHANGED
@@ -128,7 +128,10 @@ export function logError(
128
128
  err?: unknown,
129
129
  ): void {
130
130
  if (err instanceof Error) {
131
- logger.error({ component, err: err.message }, message);
131
+ // Capture both the concise message (for log consumers that look at `err`)
132
+ // and the full stack (for diagnostics). pino-pretty renders the `stack`
133
+ // field on its own line; JSON consumers can read either field.
134
+ logger.error({ component, err: err.message, stack: err.stack }, message);
132
135
  } else if (err !== undefined) {
133
136
  logger.error({ component, err: String(err) }, message);
134
137
  } else {