@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 +1 -1
- package/src/core/agent/run-agent.js +16 -1
- package/src/core/agent/super-agent.js +3 -0
- package/src/core/agent/tools/tool-call-parser.js +53 -5
- package/src/core/channels/telegram/ask-callbacks.js +86 -6
- package/src/core/channels/telegram/dispatch.js +18 -4
- package/src/core/channels/telegram/inbound/file.js +108 -0
- package/src/core/channels/telegram/inbound/photo.js +42 -11
- package/src/core/channels/telegram/media.js +31 -3
- package/src/core/channels/telegram/reply.js +2 -0
- package/src/core/engines/gemini.js +27 -9
- package/src/core/stores/messages.js +6 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
90
|
-
//
|
|
91
|
-
const insideLlamaWrap =
|
|
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;
|
|
20
|
-
*
|
|
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 (
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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
|
-
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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: `
|
|
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.
|
|
266
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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.
|