@agentprojectcontext/apx 1.79.0 → 1.80.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": "@agentprojectcontext/apx",
3
- "version": "1.79.0",
3
+ "version": "1.80.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -123,6 +123,9 @@ export async function runAgent({
123
123
  system,
124
124
  prompt,
125
125
  previousMessages = [],
126
+ // Files that arrived with this turn: [{ kind, mime, data (base64), path }].
127
+ // Channel handlers (e.g. an inbound Telegram photo) populate it.
128
+ attachments = [],
126
129
  overrideModel = null,
127
130
  // Content-routed model for this turn (selectModelByRules). Unlike
128
131
  // overrideModel it is health-checked and falls back down the chain.
@@ -263,7 +266,19 @@ export async function runAgent({
263
266
  }
264
267
  };
265
268
 
266
- const conversation = [...previousMessages, { role: "user", content: prompt }];
269
+ // Attachments ride on THIS turn's user message. Only images are forwarded to
270
+ // the model (a multimodal engine renders them; the rest ignore the field);
271
+ // every other kind is already described in the prompt text by the channel
272
+ // handler, with its local path, so the agent can reach it with file tools.
273
+ const turnImages = attachments.filter((a) => a?.data && /^image\//.test(a.mime || ""));
274
+ const conversation = [
275
+ ...previousMessages,
276
+ {
277
+ role: "user",
278
+ content: prompt,
279
+ ...(turnImages.length > 0 ? { images: turnImages } : {}),
280
+ },
281
+ ];
267
282
  const trace = [];
268
283
  const totalUsage = { input_tokens: 0, output_tokens: 0 };
269
284
  let lastText = "";
@@ -33,6 +33,8 @@ export async function runSuperAgent({
33
33
  // Pre-rendered "who you're talking to" block (see buildRelationshipBlock).
34
34
  relationshipBlock = "",
35
35
  previousMessages = [],
36
+ // Files that arrived with this turn; forwarded to runAgent verbatim.
37
+ attachments = [],
36
38
  overrideModel = null,
37
39
  onEvent = null,
38
40
  signal,
@@ -146,6 +148,7 @@ export async function runSuperAgent({
146
148
  system,
147
149
  prompt: turnPrompt,
148
150
  previousMessages: history,
151
+ attachments,
149
152
  overrideModel,
150
153
  preferredModel: contentRoute?.model || null,
151
154
  toolSchemas,
@@ -64,6 +64,15 @@ export function extractPseudoToolCalls(text) {
64
64
  // run-agent loop then treats them identically.
65
65
  const llamaCalls = extractLlamaDottedFunctionCalls(text);
66
66
 
67
+ // `[tool call: NAME] {…json…}` — APX's own internal transcription of a tool
68
+ // call, which used to be written into Gemini history when a turn had no
69
+ // thought signature to replay. Models copied the format out of their own
70
+ // history and started writing calls instead of making them, and the line
71
+ // was delivered to the user verbatim. The history no longer contains it,
72
+ // but a model that already learned the shape (or any model that invents it)
73
+ // must have the call EXECUTED rather than printed.
74
+ const bracketCalls = extractBracketToolCalls(text);
75
+
67
76
  // Second pass: balanced `{name, arguments}` JSON anywhere in the text.
68
77
  const jsonCalls = [];
69
78
  for (let i = 0; i < text.length; i++) {
@@ -86,11 +95,11 @@ export function extractPseudoToolCalls(text) {
86
95
  parsed.arguments !== null &&
87
96
  !Array.isArray(parsed.arguments)
88
97
  ) {
89
- // Skip JSON that is actually the args object inside a dotted-function
90
- // wrapper we already captured — otherwise we'd double-fire the tool.
91
- const insideLlamaWrap = llamaCalls.some(
92
- (lc) => lc._rawStart <= i && balanced.end <= lc._rawEnd
93
- );
98
+ // Skip JSON that is actually the args object inside a wrapper we already
99
+ // captured — otherwise we'd double-fire the tool.
100
+ const insideLlamaWrap =
101
+ llamaCalls.some((lc) => lc._rawStart <= i && balanced.end <= lc._rawEnd) ||
102
+ bracketCalls.some((bc) => bc._rawStart <= i && balanced.end <= bc._rawEnd);
94
103
  if (insideLlamaWrap) {
95
104
  i = balanced.end - 1;
96
105
  continue;
@@ -108,10 +117,45 @@ export function extractPseudoToolCalls(text) {
108
117
  // Strip internal markers used to dedupe against JSON pass.
109
118
  return [
110
119
  ...llamaCalls.map(({ _rawStart, _rawEnd, ...rest }) => rest),
120
+ ...bracketCalls.map(({ _rawStart, _rawEnd, ...rest }) => rest),
111
121
  ...jsonCalls,
112
122
  ];
113
123
  }
114
124
 
125
+ // Parse `[tool call: NAME] {…}` — see the note in extractPseudoToolCalls.
126
+ // `_raw` spans the bracket AND its argument object so the whole line is
127
+ // removed from the visible text, not just the JSON half.
128
+ function extractBracketToolCalls(text) {
129
+ const out = [];
130
+ const re = /\[tool[ _]call:\s*([a-zA-Z_][a-zA-Z0-9_]*)\]\s*/g;
131
+ let m;
132
+ while ((m = re.exec(text)) !== null) {
133
+ const argsStart = m.index + m[0].length;
134
+ let args = {};
135
+ let end = argsStart;
136
+ if (text[argsStart] === "{") {
137
+ const balanced = readBalancedJson(text, argsStart);
138
+ if (balanced.ok) {
139
+ try {
140
+ const parsed = JSON.parse(text.slice(argsStart, balanced.end));
141
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed;
142
+ } catch { /* keep {} — a malformed arg blob still beats printing it */ }
143
+ end = balanced.end;
144
+ }
145
+ }
146
+ out.push({
147
+ id: nextId(),
148
+ function: { name: m[1], arguments: args },
149
+ _pseudo: true,
150
+ _raw: text.slice(m.index, end),
151
+ _rawStart: m.index,
152
+ _rawEnd: end,
153
+ });
154
+ re.lastIndex = end;
155
+ }
156
+ return out;
157
+ }
158
+
115
159
  // Parse the dotted-function format emitted by some Llama instructions:
116
160
  //
117
161
  // <function.send_telegram({"text": "hi"})</function>
@@ -248,6 +292,10 @@ export function cleanTextOfPseudoToolCalls(text, knownNames) {
248
292
  if (call._raw) out = out.replace(call._raw, "");
249
293
  }
250
294
  out = out.replace(/\[tool result:\s*[^\]]+\]\s*/gi, "");
295
+ // …and the history annotation the message store substitutes for a stale
296
+ // answer (`sanitizeAssistantForContext`). It describes a turn; it is never
297
+ // something to say to the user.
298
+ out = out.replace(/\[omitted:[^\]]*\]\s*/gi, "");
251
299
  // Some models emit a stray `</function>` after the args without the
252
300
  // opening tag — sweep those too.
253
301
  out = out.replace(/<\/?function(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?>/gi, "");
@@ -14,10 +14,33 @@ import { CHANNELS } from "#core/constants/channels.js";
14
14
  import { SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
15
15
  import { applyNudgeCallback } from "#core/nudge/index.js";
16
16
 
17
+ /**
18
+ * The label the user actually tapped, recovered from the keyboard attached to
19
+ * the message. `callback_data` is a routing slug ("mover_workspace_hoy"); the
20
+ * label is what the human read ("Mover al workspace de hoy"), and that is the
21
+ * better thing to hand an agent.
22
+ */
23
+ export function buttonLabelFor(callbackQuery) {
24
+ const rows = callbackQuery?.message?.reply_markup?.inline_keyboard || [];
25
+ const data = callbackQuery?.data;
26
+ for (const row of rows) {
27
+ for (const btn of row || []) {
28
+ if (btn?.callback_data === data && btn?.text) return String(btn.text);
29
+ }
30
+ }
31
+ return "";
32
+ }
33
+
17
34
  /**
18
35
  * Route an inbound callback_query. ask_questions button presses are handled
19
- * here; everything else falls through to the confirmation adapter. Both use
20
- * `apx:<verb>:...` namespacing but the ask flow owns its own state.
36
+ * here; `apx:`-namespaced presses belong to an APX flow (ask / nudge /
37
+ * confirmation) and never reach the agent. Anything else is a button someone
38
+ * else put in the chat, and is treated as a user turn.
39
+ *
40
+ * TELEGRAM CONTRACT: every callback_query must be answered, handled or not.
41
+ * Until it is, the client keeps the button spinning and the tap looks dead —
42
+ * which is exactly how "the inline buttons do nothing" is reported. Nothing
43
+ * below may return without an `_answerCallback`.
21
44
  */
22
45
  export async function handleCallbackQuery(self, callbackQuery) {
23
46
  const data = callbackQuery.data || "";
@@ -35,9 +58,53 @@ export async function handleCallbackQuery(self, callbackQuery) {
35
58
  pendingStore: getConfirmStore(),
36
59
  });
37
60
  const handled = await adapter.handleCallbackQuery(callbackQuery);
38
- if (!handled) {
39
- self.log(`telegram[${self.channel.name}] unhandled callback_query: ${callbackQuery.data}`);
61
+ if (handled) return;
62
+
63
+ // `apx:noop` is a deliberately dead button (a disabled confirmation, an
64
+ // expired panel). Ack it so the spinner clears and stop there — replaying it
65
+ // to the agent would answer a question that is already closed.
66
+ if (data === "apx:noop" || data.startsWith("apx:")) {
67
+ await self._answerCallback({ callback_query_id: callbackQuery.id });
68
+ self.log(`telegram[${self.channel.name}] stale apx callback: ${data}`);
69
+ await clearKeyboard(self, callbackQuery);
70
+ return;
40
71
  }
72
+
73
+ // A button APX did not send — an agent-authored keyboard, another tool
74
+ // posting into this chat. Ack first (never leave it spinning), then let the
75
+ // press be a turn: re-enter the normal inbound path with the button's label
76
+ // as the text, so identity, routing and the agent loop all apply unchanged.
77
+ await self._answerCallback({ callback_query_id: callbackQuery.id });
78
+ const text = buttonLabelFor(callbackQuery) || data;
79
+ const chat = callbackQuery.message?.chat;
80
+ if (!chat?.id || !text) {
81
+ self.log(`telegram[${self.channel.name}] unhandled callback_query: ${data}`);
82
+ return;
83
+ }
84
+ self.log(`telegram[${self.channel.name}] button press → turn: ${data} (${text})`);
85
+ await self._handleUpdate({
86
+ update_id: callbackQuery.id,
87
+ message: {
88
+ message_id: callbackQuery.message?.message_id,
89
+ from: callbackQuery.from,
90
+ chat,
91
+ date: Math.floor(Date.now() / 1000),
92
+ text,
93
+ },
94
+ });
95
+ }
96
+
97
+ /** Best-effort: take the keyboard off a message whose buttons are now dead. */
98
+ async function clearKeyboard(self, callbackQuery) {
99
+ const chatId = callbackQuery.message?.chat?.id;
100
+ if (!chatId) return;
101
+ try {
102
+ await self._editKeyboard({
103
+ chat_id: chatId,
104
+ message_id: callbackQuery.message?.message_id,
105
+ reply_markup: { inline_keyboard: [] },
106
+ });
107
+ } catch { /* best-effort */ }
41
108
  }
42
109
 
43
110
  /**
@@ -111,9 +178,22 @@ export async function handleAskCallback(self, callbackQuery) {
111
178
  const chatId = callbackQuery.message?.chat?.id;
112
179
  if (!chatId) return;
113
180
  const result = askFlow.applyCallback(chatId, callbackQuery.data || "");
114
- // Ack the press regardless — keeps the spinner from hanging client-side.
181
+ if (!result) {
182
+ // The flow is gone: ask state is process-local (see ask.js), so a daemon
183
+ // restart or the 30-min TTL kills it while its keyboard stays in the chat
184
+ // looking live. Acking silently is what makes the button read as broken —
185
+ // the tap "does nothing" and the user keeps tapping. Say so, and take the
186
+ // dead keyboard away so the message stops offering a choice it can't take.
187
+ await self._answerCallback({
188
+ callback_query_id: callbackQuery.id,
189
+ text: "Esa consulta ya expiró — escribime de nuevo y la retomamos.",
190
+ });
191
+ self.log(`telegram[${self.channel.name}] stale ask callback: ${callbackQuery.data}`);
192
+ await clearKeyboard(self, callbackQuery);
193
+ return;
194
+ }
195
+ // Ack the press — keeps the spinner from hanging client-side.
115
196
  await self._answerCallback({ callback_query_id: callbackQuery.id });
116
- if (!result) return; // stale or unknown — adapter already ack'd.
117
197
 
118
198
  if (result.action === "redraw") {
119
199
  // Multi-select toggle: refresh the keyboard on the SAME message.
@@ -26,6 +26,7 @@ import * as askFlow from "./ask.js";
26
26
  import { telegramAuthorLabel } from "./helpers.js";
27
27
  import { handleIncomingPhoto } from "./inbound/photo.js";
28
28
  import { handleIncomingAudio } from "./inbound/audio.js";
29
+ import { handleIncomingFile, detectIncomingFile } from "./inbound/file.js";
29
30
  import { buildStreamHandler, runTelegramSuperAgent, telegramErrorText, sendFinalReply, runFollowupTurn } from "./reply.js";
30
31
  import { t, resolveLang } from "#core/i18n/index.js";
31
32
 
@@ -87,16 +88,28 @@ export async function handleUpdate(self, u) {
87
88
  // ── Incoming media ────────────────────────────────────────────────────
88
89
  // Photo and voice/audio each download + archive the file and rewrite `text`
89
90
  // so the rest of the pipeline treats them like a typed message. The handlers
90
- // live in ./inbound/ to keep this dispatcher focused on routing. Photos have
91
- // no vision yet, so the handler injects an `[image]` marker (never silent);
92
- // audio injects its `[audio]` transcript.
91
+ // live in ./inbound/ to keep this dispatcher focused on routing. Each one
92
+ // injects a marker so a caption-less attachment is never an empty turn:
93
+ // photos an `[image]` marker (plus the pixels, as an attachment, for a
94
+ // multimodal engine), audio its `[audio]` transcript, files a description
95
+ // with the local path.
96
+ const attachments = [];
93
97
  if (msg.photo && msg.photo.length > 0) {
94
- ({ text } = await handleIncomingPhoto(self, { msg, u, author, chat_id, text }));
98
+ let attachment;
99
+ ({ text, attachment } = await handleIncomingPhoto(self, { msg, u, author, chat_id, text }));
100
+ if (attachment) attachments.push(attachment);
95
101
  }
96
102
  const incomingAudio = msg.voice || msg.audio;
97
103
  if (incomingAudio && incomingAudio.file_id) {
98
104
  ({ text } = await handleIncomingAudio(self, { msg, u, author, chat_id, text, incomingAudio }));
99
105
  }
106
+ // Documents, video, video notes and GIFs. Without this a file sent with no
107
+ // caption left `text` empty, the turn was dropped, and the bot answered
108
+ // nothing at all.
109
+ const incomingFile = detectIncomingFile(msg);
110
+ if (incomingFile) {
111
+ ({ text } = await handleIncomingFile(self, { msg, u, author, chat_id, text, incoming: incomingFile }));
112
+ }
100
113
 
101
114
  // If there's a pending ask_questions flow for this chat AND the current
102
115
  // question is free-text, treat this message as the answer rather than a
@@ -319,6 +332,7 @@ export async function handleUpdate(self, u) {
319
332
  try {
320
333
  const sa = await runTelegramSuperAgent(self, {
321
334
  chat_id,
335
+ attachments,
322
336
  prompt: slashed.handled ? slashed.prompt : text,
323
337
  previousMessages,
324
338
  target,
@@ -0,0 +1,108 @@
1
+ // Inbound Telegram FILES: document, video, video_note, animation.
2
+ //
3
+ // Same shape as ./photo.js and ./audio.js — take the poller (`self`) plus the
4
+ // parsed update, download and archive the file, and return the (rewritten)
5
+ // `text` the rest of the pipeline runs with.
6
+ //
7
+ // Why this exists: dispatch only recognised photo and voice/audio. Every other
8
+ // attachment fell through to `text = msg.caption || ""`, and a file sent with
9
+ // no caption produced an EMPTY text — so the turn was dropped and the bot said
10
+ // nothing at all. A user who sends a file and gets silence cannot tell the
11
+ // difference between "not supported" and "broken".
12
+ //
13
+ // The reply is model-authored, as everywhere else: the marker states what
14
+ // arrived and where it landed, and the agent puts that in its own words. No
15
+ // canned "file received" string.
16
+ import { appendGlobalMessage } from "#core/stores/messages.js";
17
+ import { CHANNELS } from "#core/constants/channels.js";
18
+ import { resolveBotToken, telegramMediaDir } from "../helpers.js";
19
+ import { downloadTelegramFile } from "../media.js";
20
+
21
+ /**
22
+ * The attachment kinds handled here, in the order Telegram nests them. A
23
+ * `video_note` (the round selfie clip) has no file_name; a `document` usually
24
+ * does and it is the one worth preserving.
25
+ */
26
+ const FILE_KINDS = [
27
+ { key: "document", label: "document", type: "document" },
28
+ { key: "video", label: "video", type: "video" },
29
+ { key: "video_note", label: "video note", type: "video" },
30
+ { key: "animation", label: "animation (GIF)", type: "animation" },
31
+ ];
32
+
33
+ /** The first file-like attachment on a message, or null. */
34
+ export function detectIncomingFile(msg) {
35
+ for (const kind of FILE_KINDS) {
36
+ const file = msg?.[kind.key];
37
+ if (file?.file_id) return { ...kind, file };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function humanSize(bytes) {
43
+ const n = Number(bytes);
44
+ if (!Number.isFinite(n) || n <= 0) return "";
45
+ if (n < 1024) return `${n} B`;
46
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
47
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
48
+ }
49
+
50
+ /**
51
+ * @param {object} self poller instance (uses self.log, self.channel)
52
+ * @param {object} ctx { msg, u, author, chat_id, text, incoming }
53
+ * @returns {Promise<{ text: string }>}
54
+ */
55
+ export async function handleIncomingFile(self, { msg, u, author, chat_id, text, incoming }) {
56
+ const { file, label, type } = incoming;
57
+ const token = resolveBotToken(self.channel);
58
+ const mediaDir = telegramMediaDir();
59
+ const declaredName = file.file_name || "";
60
+
61
+ let localPath = null;
62
+ let failure = "";
63
+ try {
64
+ localPath = await downloadTelegramFile(token, file.file_id, mediaDir, {
65
+ preferredName: declaredName,
66
+ });
67
+ self.log(`telegram[${self.channel.name}] ${label} saved: ${localPath}`);
68
+ } catch (e) {
69
+ failure = e.message;
70
+ // Telegram refuses getFile over 20 MB for bots; say which failure it was
71
+ // rather than leaving the agent to guess.
72
+ self.log(`telegram[${self.channel.name}] ${label} download failed: ${e.message}`);
73
+ }
74
+
75
+ // Archive regardless of download outcome, so the history records the file
76
+ // even when the fetch failed.
77
+ appendGlobalMessage({
78
+ channel: CHANNELS.TELEGRAM,
79
+ direction: "in",
80
+ type,
81
+ actor_id: msg.from?.id ? String(msg.from.id) : author,
82
+ external_id: String(u.update_id),
83
+ author,
84
+ body: text || `[${label}]`,
85
+ meta: {
86
+ chat_id,
87
+ user_id: msg.from?.id || null,
88
+ message_id: msg.message_id,
89
+ tg_channel: self.channel.name,
90
+ local_path: localPath,
91
+ file_id: file.file_id,
92
+ file_name: declaredName || null,
93
+ mime_type: file.mime_type || null,
94
+ file_size: file.file_size || null,
95
+ duration: file.duration || null,
96
+ },
97
+ });
98
+
99
+ const bits = [declaredName || label];
100
+ const size = humanSize(file.file_size);
101
+ if (size) bits.push(size);
102
+ if (file.mime_type) bits.push(file.mime_type);
103
+ const marker = localPath
104
+ ? `[${label} received: ${bits.join(", ")} — saved to ${localPath}. You can open it with your file tools.]`
105
+ : `[${label} received: ${bits.join(", ")} — the download FAILED (${failure || "unknown error"}), so there is no local copy. Say so; files over 20 MB cannot be fetched by a bot.]`;
106
+
107
+ return { text: text ? `${marker} ${text}` : marker };
108
+ }
@@ -3,12 +3,15 @@
3
3
  // instance (`self`, for logging + channel) plus the parsed update context, and
4
4
  // returns the (possibly rewritten) `text` the rest of the pipeline should run.
5
5
  //
6
- // Vision note: we do NOT have image understanding yet the engine layer can't
7
- // pass image content to the model. So we download + archive the photo and then
8
- // inject an internal `[image]` marker into `text` so the agent ALWAYS produces a
9
- // reply in its own words (never goes silent on a no-caption photo). The reply is
10
- // model-authored; the marker only tells the model an image arrived and that it
11
- // can't see the pixels yet. Mirrors the `[audio]` marker convention.
6
+ // Vision: the photo is downloaded, archived, and returned as an `attachment`
7
+ // that dispatch threads onto the turn. A multimodal engine (Gemini) receives it
8
+ // as real image content; engines without vision ignore it and still get the
9
+ // `[image]` marker, which names the local path so the agent can reach the file
10
+ // with its tools. The marker also guarantees a no-caption photo never produces
11
+ // an empty turn — the reply is always model-authored, never canned. Mirrors the
12
+ // `[audio]` marker convention.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
12
15
  import { appendGlobalMessage } from "#core/stores/messages.js";
13
16
  import { CHANNELS } from "#core/constants/channels.js";
14
17
  import { resolveBotToken, telegramMediaDir } from "../helpers.js";
@@ -55,9 +58,37 @@ export async function handleIncomingPhoto(self, { msg, u, author, chat_id, text
55
58
  },
56
59
  });
57
60
 
58
- // Guard: never go silent. Hand the agent an internal marker so it replies in
59
- // its own words. No vision yet say so, in-band, so the model doesn't
60
- // hallucinate "seeing" the image.
61
- const marker = "[image attached — you cannot see its contents yet]";
62
- return { text: text ? `${marker} ${text}` : marker };
61
+ // Hand the pixels to the turn. A multimodal engine (Gemini) renders them as
62
+ // an inlineData part; the others ignore the field and still have the marker
63
+ // and the path, so nothing regresses for them.
64
+ let attachment = null;
65
+ if (localPath) {
66
+ try {
67
+ attachment = {
68
+ kind: "image",
69
+ mime: mimeFromPath(localPath),
70
+ data: fs.readFileSync(localPath).toString("base64"),
71
+ path: localPath,
72
+ };
73
+ } catch (e) {
74
+ self.log(`telegram[${self.channel.name}] photo read-back failed: ${e.message}`);
75
+ }
76
+ }
77
+
78
+ // Guard: never go silent. The marker states what arrived and where it is,
79
+ // and stays neutral about visibility — a vision model can describe the image
80
+ // it was given, and one without it still has the path and its file tools.
81
+ const marker = localPath
82
+ ? `[image attached — saved to ${localPath}]`
83
+ : "[image attached — the download failed, there is no local copy]";
84
+ return { text: text ? `${marker} ${text}` : marker, attachment };
85
+ }
86
+
87
+ function mimeFromPath(p) {
88
+ const ext = path.extname(p).toLowerCase();
89
+ if (ext === ".png") return "image/png";
90
+ if (ext === ".webp") return "image/webp";
91
+ if (ext === ".gif") return "image/gif";
92
+ if (ext === ".heic") return "image/heic";
93
+ return "image/jpeg";
63
94
  }
@@ -142,14 +142,42 @@ export async function sendAudio(token, chatId, audio, { caption, title, performe
142
142
  * Download a file from Telegram servers.
143
143
  * Returns the local file path where it was saved.
144
144
  */
145
- export async function downloadTelegramFile(token, fileId, destDir) {
145
+ /**
146
+ * Keep a user-supplied filename usable as a leaf name: no directory escape, no
147
+ * separators, no leading dot, bounded length. Returns "" when nothing usable
148
+ * survives, so callers fall back to the generated name.
149
+ */
150
+ export function safeFileBase(name) {
151
+ const base = path.basename(String(name || "")).replace(/\.[^.]*$/, "");
152
+ const cleaned = base
153
+ .replace(/[/\\]/g, "")
154
+ .replace(/[^\p{L}\p{N}._ -]/gu, "")
155
+ .replace(/\s+/g, " ")
156
+ .replace(/^[.\s]+/, "")
157
+ .trim()
158
+ .slice(0, 60);
159
+ return cleaned;
160
+ }
161
+
162
+ /**
163
+ * Download a file by file_id into destDir. Returns the absolute local path.
164
+ *
165
+ * `preferredName` (a document's own `file_name`) is honoured so the archive is
166
+ * browsable and the agent can tell the user "I saved informe.pdf" rather than
167
+ * a generated id. It is sanitised and suffixed with part of the file_id, so a
168
+ * second "informe.pdf" never overwrites the first.
169
+ */
170
+ export async function downloadTelegramFile(token, fileId, destDir, { preferredName } = {}) {
146
171
  // Step 1: get file path from Telegram
147
172
  const infoRes = await fetch(`${API_BASE}/bot${token}/getFile?file_id=${fileId}`);
148
173
  const infoJson = await infoRes.json();
149
174
  if (!infoJson.ok) throw new Error(`getFile failed: ${infoJson.description}`);
150
175
  const filePath = infoJson.result.file_path; // e.g. "photos/file_123.jpg"
151
- const ext = path.extname(filePath) || ".jpg";
152
- const fileName = `tg_${fileId.slice(-8)}_${Date.now()}${ext}`;
176
+ const ext = path.extname(preferredName || "") || path.extname(filePath) || ".jpg";
177
+ const base = safeFileBase(preferredName);
178
+ const fileName = base
179
+ ? `${base}-${fileId.slice(-6)}${ext}`
180
+ : `tg_${fileId.slice(-8)}_${Date.now()}${ext}`;
153
181
  const localPath = path.join(destDir, fileName);
154
182
 
155
183
  // Step 2: download
@@ -111,6 +111,7 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
111
111
  export function runTelegramSuperAgent(self, {
112
112
  chat_id, prompt, previousMessages, target, author, authorId, relationshipBlock,
113
113
  allowedTools, contextNote, signal, onEvent, backgroundResultSink = null,
114
+ attachments = [],
114
115
  }) {
115
116
  const confirmAdapter = createTelegramConfirmAdapter({
116
117
  token: resolveBotToken(self.channel),
@@ -126,6 +127,7 @@ export function runTelegramSuperAgent(self, {
126
127
  registries: self.registries,
127
128
  prompt,
128
129
  previousMessages,
130
+ attachments,
129
131
  channel: CHANNELS.TELEGRAM,
130
132
  relationshipBlock,
131
133
  allowedTools,
@@ -207,9 +207,14 @@ function toGeminiContents(messages, { model = "", config = {} } = {}) {
207
207
  const name = m.name || m.tool_name || "tool";
208
208
  const id = m.tool_call_id || m.id;
209
209
  if (id && degraded.has(id)) {
210
+ // The call this answers was dropped, so it cannot be a functionResponse
211
+ // (Gemini rejects a response to a call it cannot see). Carry the result
212
+ // as an observation on the user side. Phrased as a plain report, never
213
+ // as call syntax — anything that looks like a callable format in the
214
+ // history gets imitated instead of executed.
210
215
  out.push({
211
216
  role: "user",
212
- parts: [{ text: `[tool result: ${name}] ${asText(m.content)}` }],
217
+ parts: [{ text: `Resultado de ${name}: ${asText(m.content)}` }],
213
218
  });
214
219
  continue;
215
220
  }
@@ -262,25 +267,38 @@ function toGeminiContents(messages, { model = "", config = {} } = {}) {
262
267
  const sig = callSignatureOf(tc);
263
268
  if (requireSignatures && !turnHasSignature) {
264
269
  // Nothing to replay: sending this as a functionCall is a guaranteed
265
- // 400. Narrate it instead so the model keeps the context without the
266
- // API rejecting the turn.
270
+ // 400. DROP the call from the model turn never transcribe it into
271
+ // text. A model turn that reads "[tool call: run_shell] {...}" is a
272
+ // worked example of writing calls as prose, and the model copies it:
273
+ // it stops emitting functionCall parts, the loop sees no tool_calls,
274
+ // and the transcript is delivered to the user as the final answer.
275
+ // The call itself is not what the model needs to continue — the
276
+ // RESULT is, and that still arrives (see the tool branch above).
267
277
  if (tc.id) degraded.add(tc.id);
268
- parts.push({ text: `[tool call: ${name}] ${JSON.stringify(callArgsOf(tc))}` });
269
278
  continue;
270
279
  }
271
280
  const part = { functionCall: { name, args: callArgsOf(tc) } };
272
281
  if (sig) part.thoughtSignature = sig;
273
282
  parts.push(part);
274
283
  }
275
- if (parts.length === 0) parts.push({ text: "" });
284
+ // Every call was dropped and the turn said nothing else: emit no turn at
285
+ // all rather than an empty model message.
286
+ if (parts.length === 0) continue;
276
287
  out.push({ role: "model", parts });
277
288
  continue;
278
289
  }
279
290
 
280
- out.push({
281
- role: m.role === "assistant" ? "model" : "user",
282
- parts: [{ text: asText(m.content) }],
283
- });
291
+ // A plain turn. A user turn may carry images (Telegram photos, etc.);
292
+ // Gemini takes them as inlineData parts beside the text. Non-multimodal
293
+ // engines ignore the field entirely, so carrying it costs them nothing.
294
+ const parts = [{ text: asText(m.content) }];
295
+ if (m.role !== "assistant" && Array.isArray(m.images)) {
296
+ for (const img of m.images) {
297
+ if (!img?.data || !img?.mime) continue;
298
+ parts.push({ inlineData: { mimeType: img.mime, data: img.data } });
299
+ }
300
+ }
301
+ out.push({ role: m.role === "assistant" ? "model" : "user", parts });
284
302
  }
285
303
  return out;
286
304
  }
@@ -358,7 +358,12 @@ function sanitizeAssistantForContext(content) {
358
358
  ];
359
359
  for (const re of FACTUAL_PATTERNS) {
360
360
  if (re.test(content)) {
361
- return "(I answered with data here. Re-call the tool to get the current values — do not paraphrase from memory.)";
361
+ // Third person, and visibly an annotation ABOUT the turn rather than the
362
+ // turn itself. Written in the first person ("I answered with data here…")
363
+ // this read as something the assistant had said, and after a few of them
364
+ // in a row the model copied the sentence and sent it to the user as its
365
+ // reply. History the model can mistake for its own voice gets imitated.
366
+ return "[omitted: this turn contained data that may be stale — call the tool again instead of repeating it]";
362
367
  }
363
368
  }
364
369
  // Otherwise it's conversational small-talk; keep up to 200 chars.