@agentprojectcontext/apx 1.41.0 → 1.42.1
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/constants.js +10 -0
- package/src/core/agent/run-agent.js +36 -18
- package/src/core/channels/telegram/dispatch.js +60 -310
- package/src/core/channels/telegram/helpers.js +28 -1
- package/src/core/channels/telegram/inbound/audio.js +82 -0
- package/src/core/channels/telegram/inbound/photo.js +63 -0
- package/src/core/channels/telegram/reply.js +204 -0
- package/src/core/config/index.js +5 -0
- package/src/core/i18n/en.js +4 -0
- package/src/core/i18n/es.js +4 -0
- package/src/core/i18n/pt.js +4 -0
- package/src/host/daemon/plugins/telegram/index.js +45 -53
- package/src/interfaces/desktop/renderer.js +43 -41
- package/src/interfaces/desktop/style.css +15 -6
- package/src/interfaces/web/dist/assets/{index-DW7j3cXB.js → index-BReF4_xV.js} +21 -21
- package/src/interfaces/web/dist/assets/{index-DW7j3cXB.js.map → index-BReF4_xV.js.map} +1 -1
- package/src/interfaces/web/dist/index.html +1 -1
- package/src/interfaces/web/package-lock.json +3 -3
package/package.json
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
// Coding surfaces (web Code / terminal Build) raise this via maxIters and use
|
|
6
6
|
// the finish-tool completionContract instead.
|
|
7
7
|
export const MAX_TOOL_ITERS = 10;
|
|
8
|
+
// Telegram is the "do real work for me" conversational surface (the super-agent
|
|
9
|
+
// Roby): it needs to chain explore→edit→verify→close autonomously, not stop
|
|
10
|
+
// after ~9 actions and ask "want me to continue?". A budget of 10 left only one
|
|
11
|
+
// usable action step before the reserved wrap-up, so multi-step tasks routinely
|
|
12
|
+
// cut off mid-job. We give it a real autonomy budget (mirroring the TUI Code
|
|
13
|
+
// surface's maxIters:40) while keeping it below the coding surfaces. The
|
|
14
|
+
// reserved final-step wrap-up still applies, but now only fires when a task
|
|
15
|
+
// genuinely exhausts this budget — a rare safety floor, not the default close.
|
|
16
|
+
// Overridable per-deployment via config.super_agent.telegram_max_iters.
|
|
17
|
+
export const TELEGRAM_TOOL_ITERS = 24;
|
|
8
18
|
export const ACK_ONLY_TOOLS = new Set(["send_telegram"]);
|
|
9
19
|
export const MAX_CONSECUTIVE_ACKS = 2;
|
|
10
20
|
// Tools whose semantics REQUIRE handing control back to the user. After the
|
|
@@ -84,20 +84,32 @@ export const FINISH_TOOL_SCHEMA = {
|
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
// step at the end of a turn (see the loop's
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
// In-band signal injected as a CONVERSATION turn (not a system suffix) for the
|
|
88
|
+
// ONE tool-free wrap-up step at the end of a turn (see the loop's
|
|
89
|
+
// `isFinalWrapUp`). Delivering it through the message channel — the way a tool
|
|
90
|
+
// result arrives — makes weak models reliably author a reply instead of
|
|
91
|
+
// returning empty, because they always answer the latest turn. It shapes
|
|
92
|
+
// BEHAVIOR only: it never dictates wording or supplies a canned sentence. The
|
|
93
|
+
// reply the user sees is 100% model-authored and varies with what the model
|
|
94
|
+
// actually did this turn. Critically it must not claim work it didn't do (weak
|
|
95
|
+
// models otherwise fabricate "all done").
|
|
96
|
+
//
|
|
97
|
+
// Unlike a hard "iteration limit" message, it asks the model to surface the
|
|
98
|
+
// situation NATURALLY ("this is taking more steps than I expected") plus a
|
|
99
|
+
// concrete recap of what it found and did NOT find — so the closing reads like
|
|
100
|
+
// a human status update, never robotic system jargon.
|
|
101
|
+
const WRAPUP_SIGNAL =
|
|
102
|
+
"[Internal turn note — this is NOT from the user. You've taken several tool " +
|
|
103
|
+
"steps this turn and the task isn't finished; no more tools will run now. " +
|
|
104
|
+
"Write the user ONE short, natural closing message, in their language, " +
|
|
105
|
+
"entirely in your own words:\n" +
|
|
106
|
+
"- Concretely recap what you actually did and what you found so far — and be " +
|
|
107
|
+
"honest about what you did NOT find or couldn't resolve yet. Read the tool " +
|
|
108
|
+
"results above; do not claim anything you didn't do.\n" +
|
|
109
|
+
"- Mention plainly that this is taking more steps than expected and isn't done.\n" +
|
|
110
|
+
"- Ask whether they want you to keep going.\n" +
|
|
111
|
+
"Talk like a person giving a quick status update. Do NOT emit a tool call, " +
|
|
112
|
+
"JSON, or system jargon like \"iteration\" or \"limit\".]";
|
|
101
113
|
|
|
102
114
|
/**
|
|
103
115
|
* Shared tool-calling agent loop used by super-agent and future surfaces.
|
|
@@ -301,8 +313,8 @@ export async function runAgent({
|
|
|
301
313
|
// Rather than cut off silently mid-tool-call, we run ONE tool-free step so
|
|
302
314
|
// the model writes a natural closing in its OWN words — what it did, what's
|
|
303
315
|
// left, and (if anything remains) whether to continue. We change only the
|
|
304
|
-
// STRUCTURE (no tools this step) +
|
|
305
|
-
// entirely the model's. Coding surfaces keep their finish-tool flow, so
|
|
316
|
+
// STRUCTURE (no tools this step) + an in-band directive turn (WRAPUP_SIGNAL);
|
|
317
|
+
// the wording is entirely the model's. Coding surfaces keep their finish-tool flow, so
|
|
306
318
|
// this never applies under completionContract.
|
|
307
319
|
const isFinalWrapUp =
|
|
308
320
|
!useContract && effectiveSchemas.length > 0 && iter === maxIters - 1;
|
|
@@ -322,8 +334,14 @@ export async function runAgent({
|
|
|
322
334
|
let result;
|
|
323
335
|
try {
|
|
324
336
|
result = await tryCallEngine({
|
|
325
|
-
system:
|
|
326
|
-
|
|
337
|
+
system: baseSystem,
|
|
338
|
+
// Wrap-up: deliver the "you're out of steps, summarize + ask" directive
|
|
339
|
+
// as the latest CONVERSATION turn so the model treats it like any other
|
|
340
|
+
// turn it must answer — far more reliable than a system suffix on weak
|
|
341
|
+
// models. Ephemeral: built fresh here, never persisted to history.
|
|
342
|
+
messages: isFinalWrapUp
|
|
343
|
+
? [...conversation, { role: "user", content: WRAPUP_SIGNAL }]
|
|
344
|
+
: conversation,
|
|
327
345
|
config: globalConfig,
|
|
328
346
|
// On the wrap-up step we withhold tools entirely so the model must
|
|
329
347
|
// answer in prose — same as a real engine called with tools omitted.
|
|
@@ -11,25 +11,22 @@
|
|
|
11
11
|
// `appendGlobalMessage`, `CHANNELS`, `nowIso`, etc. Top-level imports here
|
|
12
12
|
// keep that scope intact — earlier splits forgot them and the bug only
|
|
13
13
|
// surfaced when a real telegram update arrived (ReferenceError at runtime).
|
|
14
|
-
import path from "node:path";
|
|
15
14
|
import { callEngine } from "#core/engines/index.js";
|
|
16
|
-
import {
|
|
17
|
-
import { stripThinking } from "#core/util/thinking.js";
|
|
15
|
+
import { isSuperAgentEnabled } from "#core/agent/super-agent.js";
|
|
18
16
|
import { getRecentTelegramTurnsFromFs, appendGlobalMessage } from "#core/stores/messages.js";
|
|
19
17
|
import { compactChannelIfNeeded } from "#core/memory/index.js";
|
|
20
18
|
import { readAgents } from "#core/apc/parser.js";
|
|
21
19
|
import { buildAgentSystem } from "#core/agent/build-agent-system.js";
|
|
22
|
-
import { transcribe as transcribeAudioFile } from "#core/voice/transcription.js";
|
|
23
20
|
import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
|
|
24
21
|
import { registerSender, resolveAllowedTools } from "#core/identity/telegram.js";
|
|
25
22
|
import { buildRelationshipBlock } from "#core/agent/index.js";
|
|
26
|
-
import { getConfirmationStore as getConfirmStore } from "#core/confirmation/pending-store.js";
|
|
27
23
|
import { CHANNELS } from "#core/constants/channels.js";
|
|
28
24
|
import { tryResolveSkillCommand } from "#core/agent/skills/trigger.js";
|
|
29
|
-
import { createTelegramConfirmAdapter } from "#core/confirmation/adapters/telegram.js";
|
|
30
25
|
import * as askFlow from "./ask.js";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
26
|
+
import { telegramAuthorLabel } from "./helpers.js";
|
|
27
|
+
import { handleIncomingPhoto } from "./inbound/photo.js";
|
|
28
|
+
import { handleIncomingAudio } from "./inbound/audio.js";
|
|
29
|
+
import { buildStreamHandler, runTelegramSuperAgent, telegramErrorText, sendFinalReply } from "./reply.js";
|
|
33
30
|
import { t, resolveLang } from "#core/i18n/index.js";
|
|
34
31
|
|
|
35
32
|
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -50,10 +47,7 @@ export async function handleUpdate(self, u) {
|
|
|
50
47
|
self.log(`telegram[${self.channel.name}] update ${u.update_id} ignored — no target project`);
|
|
51
48
|
return;
|
|
52
49
|
}
|
|
53
|
-
const author =
|
|
54
|
-
msg.from?.username
|
|
55
|
-
? "@" + msg.from.username
|
|
56
|
-
: `${msg.from?.first_name || ""} ${msg.from?.last_name || ""}`.trim() || "unknown";
|
|
50
|
+
const author = telegramAuthorLabel(msg.from);
|
|
57
51
|
const chat_id = msg.chat?.id;
|
|
58
52
|
|
|
59
53
|
// Resolve WHO is writing (owner / known contact / guest), keyed by the
|
|
@@ -85,108 +79,18 @@ export async function handleUpdate(self, u) {
|
|
|
85
79
|
|
|
86
80
|
let text = msg.text || msg.caption || "";
|
|
87
81
|
|
|
88
|
-
// ── Incoming
|
|
82
|
+
// ── Incoming media ────────────────────────────────────────────────────
|
|
83
|
+
// Photo and voice/audio each download + archive the file and rewrite `text`
|
|
84
|
+
// so the rest of the pipeline treats them like a typed message. The handlers
|
|
85
|
+
// live in ./inbound/ to keep this dispatcher focused on routing. Photos have
|
|
86
|
+
// no vision yet, so the handler injects an `[image]` marker (never silent);
|
|
87
|
+
// audio injects its `[audio]` transcript.
|
|
89
88
|
if (msg.photo && msg.photo.length > 0) {
|
|
90
|
-
|
|
91
|
-
const bestPhoto = msg.photo.reduce((a, b) => (b.file_size > a.file_size ? b : a));
|
|
92
|
-
const token = resolveBotToken(self.channel);
|
|
93
|
-
const mediaDir = path.join(APX_HOME, "media");
|
|
94
|
-
fs.mkdirSync(mediaDir, { recursive: true });
|
|
95
|
-
try {
|
|
96
|
-
const localPath = await downloadTelegramFile(token, bestPhoto.file_id, mediaDir);
|
|
97
|
-
self.log(`telegram[${self.channel.name}] photo saved: ${localPath}`);
|
|
98
|
-
appendGlobalMessage({
|
|
99
|
-
channel: CHANNELS.TELEGRAM,
|
|
100
|
-
direction: "in",
|
|
101
|
-
type: "photo",
|
|
102
|
-
actor_id: msg.from?.id ? String(msg.from.id) : author,
|
|
103
|
-
external_id: String(u.update_id),
|
|
104
|
-
author,
|
|
105
|
-
body: text || "[photo]",
|
|
106
|
-
meta: {
|
|
107
|
-
chat_id,
|
|
108
|
-
user_id: msg.from?.id || null,
|
|
109
|
-
message_id: msg.message_id,
|
|
110
|
-
tg_channel: self.channel.name,
|
|
111
|
-
local_path: localPath,
|
|
112
|
-
file_id: bestPhoto.file_id,
|
|
113
|
-
width: bestPhoto.width,
|
|
114
|
-
height: bestPhoto.height,
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
} catch (e) {
|
|
118
|
-
self.log(`telegram[${self.channel.name}] photo download failed: ${e.message}`);
|
|
119
|
-
}
|
|
120
|
-
// If there's a caption, continue to handle it as text; otherwise return
|
|
121
|
-
if (!text) return;
|
|
89
|
+
({ text } = await handleIncomingPhoto(self, { msg, u, author, chat_id, text }));
|
|
122
90
|
}
|
|
123
|
-
|
|
124
|
-
// ── Incoming voice / audio handling ──────────────────────────────────
|
|
125
|
-
// Telegram sends `voice` for the press-and-hold mic recording (.oga/opus)
|
|
126
|
-
// and `audio` for uploaded audio files (mp3/m4a/etc.). Either way we
|
|
127
|
-
// download, run it through Whisper, prefix the result with `[audio] `
|
|
128
|
-
// and let the rest of the message flow handle it as plain text.
|
|
129
91
|
const incomingAudio = msg.voice || msg.audio;
|
|
130
92
|
if (incomingAudio && incomingAudio.file_id) {
|
|
131
|
-
|
|
132
|
-
const mediaDir = path.join(APX_HOME, "media");
|
|
133
|
-
fs.mkdirSync(mediaDir, { recursive: true });
|
|
134
|
-
// Show "typing…" right away — download + transcription is the slow part of
|
|
135
|
-
// a voice message, and the reply-path typing (below) only starts after it,
|
|
136
|
-
// so without this the chat sits silent for seconds with no feedback.
|
|
137
|
-
const stopVoiceTyping = self._startTyping(chat_id);
|
|
138
|
-
let localPath = null;
|
|
139
|
-
let transcript = "";
|
|
140
|
-
let transcribeError = null;
|
|
141
|
-
let transcribeBackend = null;
|
|
142
|
-
try {
|
|
143
|
-
localPath = await downloadTelegramFile(token, incomingAudio.file_id, mediaDir);
|
|
144
|
-
self.log(`telegram[${self.channel.name}] audio saved: ${localPath}`);
|
|
145
|
-
} catch (e) {
|
|
146
|
-
self.log(`telegram[${self.channel.name}] audio download failed: ${e.message}`);
|
|
147
|
-
}
|
|
148
|
-
if (localPath) {
|
|
149
|
-
try {
|
|
150
|
-
const result = await transcribeAudioFile(localPath);
|
|
151
|
-
transcript = result.text || "";
|
|
152
|
-
transcribeBackend = result.backend;
|
|
153
|
-
self.log(`telegram[${self.channel.name}] audio transcribed via ${transcribeBackend} (${transcript.length} chars, lang=${result.language || "?"})`);
|
|
154
|
-
} catch (e) {
|
|
155
|
-
transcribeError = e.message;
|
|
156
|
-
self.log(`telegram[${self.channel.name}] audio transcription failed: ${e.message}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
stopVoiceTyping(); // reply-path typing takes over from here
|
|
160
|
-
const audioBody = transcript
|
|
161
|
-
? `[audio] ${transcript}`
|
|
162
|
-
: `[audio] (transcription unavailable${transcribeError ? ": " + transcribeError : ""})`;
|
|
163
|
-
|
|
164
|
-
appendGlobalMessage({
|
|
165
|
-
channel: CHANNELS.TELEGRAM,
|
|
166
|
-
direction: "in",
|
|
167
|
-
type: "audio",
|
|
168
|
-
actor_id: msg.from?.id ? String(msg.from.id) : author,
|
|
169
|
-
external_id: String(u.update_id),
|
|
170
|
-
author,
|
|
171
|
-
body: audioBody,
|
|
172
|
-
meta: {
|
|
173
|
-
chat_id,
|
|
174
|
-
user_id: msg.from?.id || null,
|
|
175
|
-
message_id: msg.message_id,
|
|
176
|
-
tg_channel: self.channel.name,
|
|
177
|
-
local_path: localPath,
|
|
178
|
-
file_id: incomingAudio.file_id,
|
|
179
|
-
duration: incomingAudio.duration,
|
|
180
|
-
mime_type: incomingAudio.mime_type,
|
|
181
|
-
transcription_backend: transcribeBackend,
|
|
182
|
-
transcription_error: transcribeError,
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
// Inject the transcribed text into `text` so the rest of the agent
|
|
187
|
-
// pipeline treats it identically to a typed message. If there was a
|
|
188
|
-
// caption alongside the audio, prepend the audio marker to it.
|
|
189
|
-
text = text ? `${audioBody}\n${text}` : audioBody;
|
|
93
|
+
({ text } = await handleIncomingAudio(self, { msg, u, author, chat_id, text, incomingAudio }));
|
|
190
94
|
}
|
|
191
95
|
|
|
192
96
|
// If there's a pending ask_questions flow for this chat AND the current
|
|
@@ -309,10 +213,14 @@ export async function handleUpdate(self, u) {
|
|
|
309
213
|
// Start "typing..." indicator. Stops when we send the reply (or fail).
|
|
310
214
|
const stopTyping = self._startTyping(chat_id);
|
|
311
215
|
|
|
216
|
+
// Preset to the super-agent defaults so every exit path (including one where
|
|
217
|
+
// neither the routed-agent nor the super-agent branch runs) has a valid
|
|
218
|
+
// actor — the routed-agent / super-agent branches override these on success,
|
|
219
|
+
// and their catch blocks reset all four together (no partial-overwrite gap).
|
|
312
220
|
let replyText;
|
|
313
221
|
let replyAuthor;
|
|
314
|
-
let replyActorId; // stable id: super_agent | agent slug
|
|
315
|
-
let replyKind;
|
|
222
|
+
let replyActorId = SUPERAGENT_ACTOR_ID; // stable id: super_agent | agent slug
|
|
223
|
+
let replyKind = "superagent"; // actor_kind: superagent | agent
|
|
316
224
|
const projectCfg = target.config || self.globalConfig;
|
|
317
225
|
// Display name for the super-agent persona on this channel (from identity.json).
|
|
318
226
|
const agentDisplay = resolveAgentName(self.globalConfig);
|
|
@@ -342,7 +250,10 @@ export async function handleUpdate(self, u) {
|
|
|
342
250
|
replyKind = "agent";
|
|
343
251
|
} catch (e) {
|
|
344
252
|
self.log(`telegram[${self.channel.name}] agent reply failed: ${e.message}`);
|
|
345
|
-
replyText =
|
|
253
|
+
replyText = t("telegram.error_agent", {
|
|
254
|
+
lang: resolveLang(self.globalConfig),
|
|
255
|
+
vars: { error: e.message.slice(0, 200) },
|
|
256
|
+
});
|
|
346
257
|
replyAuthor = agentDisplay;
|
|
347
258
|
replyActorId = SUPERAGENT_ACTOR_ID;
|
|
348
259
|
replyKind = "superagent";
|
|
@@ -354,140 +265,33 @@ export async function handleUpdate(self, u) {
|
|
|
354
265
|
}
|
|
355
266
|
}
|
|
356
267
|
|
|
357
|
-
// Fallback: super-agent — STREAMED.
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// to channels that render tools — but NEVER sent to Telegram; tools are
|
|
363
|
-
// internal. The conversation saved on disk is the full, real exchange;
|
|
364
|
-
// Telegram is just the prose-only view of it.
|
|
268
|
+
// Fallback: super-agent — STREAMED. Each iteration's assistant text is sent
|
|
269
|
+
// to Telegram as its own message the moment the model produces it; tool
|
|
270
|
+
// calls are logged but never sent (internal). The streamed turn + its final
|
|
271
|
+
// send live in ./reply.js so this dispatcher and the ask-flow resume
|
|
272
|
+
// (_runResumedTurn in the host poller) share ONE reply path — no drift.
|
|
365
273
|
let saUsage = null;
|
|
366
274
|
let streamedCount = 0;
|
|
367
275
|
let lastStreamedText = "";
|
|
368
|
-
// Telegram shows the user ONLY prose — never the tool calls. On an action
|
|
369
|
-
// request the model often jumps straight to a tool with no preamble text,
|
|
370
|
-
// so the user would stare at a silent chat until the final reply. Send one
|
|
371
|
-
// short localized heads-up the moment real work starts (first tool_start),
|
|
372
|
-
// but only if the agent didn't already write its own "on it" line.
|
|
373
|
-
let sentHeadsUp = false;
|
|
374
|
-
const headsUpPhrase = () => t("telegram.heads_up", { lang: resolveLang(self.globalConfig) });
|
|
375
276
|
if (!replyText && isSuperAgentEnabled(self.globalConfig)) {
|
|
376
|
-
const onEvent =
|
|
377
|
-
try {
|
|
378
|
-
if (ev.type === "tool_start" && !sentHeadsUp && streamedCount === 0) {
|
|
379
|
-
sentHeadsUp = true;
|
|
380
|
-
const heads = headsUpPhrase();
|
|
381
|
-
await self._send({ chat_id, text: heads });
|
|
382
|
-
appendGlobalMessage({
|
|
383
|
-
channel: CHANNELS.TELEGRAM,
|
|
384
|
-
direction: "out",
|
|
385
|
-
type: "agent",
|
|
386
|
-
actor_id: SUPERAGENT_ACTOR_ID,
|
|
387
|
-
actor_kind: "superagent",
|
|
388
|
-
agent_slug: SUPERAGENT_ACTOR_ID,
|
|
389
|
-
author: agentDisplay,
|
|
390
|
-
body: heads,
|
|
391
|
-
meta: { chat_id, tg_channel: self.channel.name, in_reply_to: u.update_id, heads_up: true },
|
|
392
|
-
});
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
if (ev.type === "assistant_text" && ev.text) {
|
|
396
|
-
const piece = stripThinking(ev.text).trim();
|
|
397
|
-
if (!piece) return;
|
|
398
|
-
await self._send({ chat_id, text: piece });
|
|
399
|
-
lastStreamedText = piece;
|
|
400
|
-
streamedCount += 1;
|
|
401
|
-
appendGlobalMessage({
|
|
402
|
-
channel: CHANNELS.TELEGRAM,
|
|
403
|
-
direction: "out",
|
|
404
|
-
type: "agent",
|
|
405
|
-
actor_id: SUPERAGENT_ACTOR_ID,
|
|
406
|
-
actor_kind: "superagent",
|
|
407
|
-
agent_slug: SUPERAGENT_ACTOR_ID,
|
|
408
|
-
author: agentDisplay,
|
|
409
|
-
body: piece,
|
|
410
|
-
meta: {
|
|
411
|
-
chat_id,
|
|
412
|
-
tg_channel: self.channel.name,
|
|
413
|
-
in_reply_to: u.update_id,
|
|
414
|
-
streamed: true,
|
|
415
|
-
iteration: ev.iteration,
|
|
416
|
-
},
|
|
417
|
-
});
|
|
418
|
-
} else if (ev.type === "tool_result" && ev.trace) {
|
|
419
|
-
// Logged for the audit trail / other channels — NOT sent to Telegram.
|
|
420
|
-
const t = ev.trace;
|
|
421
|
-
appendGlobalMessage({
|
|
422
|
-
channel: CHANNELS.TELEGRAM,
|
|
423
|
-
direction: "out",
|
|
424
|
-
type: "tool",
|
|
425
|
-
actor_id: t.tool,
|
|
426
|
-
actor_kind: "tool",
|
|
427
|
-
author: agentDisplay,
|
|
428
|
-
body: `${t.tool}(${JSON.stringify(t.args || {}).slice(0, 200)})`,
|
|
429
|
-
meta: {
|
|
430
|
-
chat_id,
|
|
431
|
-
tg_channel: self.channel.name,
|
|
432
|
-
in_reply_to: u.update_id,
|
|
433
|
-
tool: t.tool,
|
|
434
|
-
args: t.args,
|
|
435
|
-
result: t.result,
|
|
436
|
-
iteration: ev.iteration,
|
|
437
|
-
},
|
|
438
|
-
});
|
|
439
|
-
} else if (ev.type === "engine_failed") {
|
|
440
|
-
// A model in the fallback chain errored; the loop is rotating to
|
|
441
|
-
// the next one. Log it so a mid-turn provider failure (rate limit,
|
|
442
|
-
// tool-grammar 400, …) is diagnosable instead of invisible.
|
|
443
|
-
self.log(
|
|
444
|
-
`telegram[${self.channel.name}] engine_failed: ${ev.model || "?"} (${ev.reason || "?"}) → ${ev.retry_with || "end of chain"}`,
|
|
445
|
-
);
|
|
446
|
-
} else if (ev.type === "model_routed" || ev.type === "model_retry") {
|
|
447
|
-
self.log(
|
|
448
|
-
`telegram[${self.channel.name}] ${ev.type}: model=${ev.model || "?"}${ev.reason ? ` reason=${ev.reason}` : ""}${ev.from_fallback ? " (fallback)" : ""}`,
|
|
449
|
-
);
|
|
450
|
-
}
|
|
451
|
-
} catch (e) {
|
|
452
|
-
// A failed intermediate send must not abort the whole run.
|
|
453
|
-
self.log(`telegram[${self.channel.name}] stream event failed: ${e.message}`);
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
const confirmAdapter = createTelegramConfirmAdapter({
|
|
458
|
-
token: resolveBotToken(self.channel),
|
|
459
|
-
chatId: chat_id,
|
|
460
|
-
pendingStore: getConfirmStore(),
|
|
461
|
-
});
|
|
277
|
+
const { onEvent, state } = buildStreamHandler(self, { chat_id, update_id: u.update_id, agentDisplay });
|
|
462
278
|
|
|
463
|
-
// `/slug ...` shortcut: load the matching skill body into contextNote
|
|
464
|
-
//
|
|
279
|
+
// `/slug ...` shortcut: load the matching skill body into contextNote and
|
|
280
|
+
// strip the prefix from the user prompt before sending to the loop.
|
|
465
281
|
const slashed = tryResolveSkillCommand(text, { projectPath: target?.path });
|
|
466
|
-
const slashedPrompt = slashed.handled ? slashed.prompt : text;
|
|
467
|
-
const slashedContextNote = slashed.handled ? slashed.contextNote : "";
|
|
468
282
|
|
|
469
283
|
try {
|
|
470
|
-
const sa = await
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
plugins: self.plugins,
|
|
474
|
-
registries: self.registries,
|
|
475
|
-
prompt: slashedPrompt,
|
|
284
|
+
const sa = await runTelegramSuperAgent(self, {
|
|
285
|
+
chat_id,
|
|
286
|
+
prompt: slashed.handled ? slashed.prompt : text,
|
|
476
287
|
previousMessages,
|
|
477
|
-
|
|
288
|
+
target,
|
|
289
|
+
author,
|
|
478
290
|
relationshipBlock,
|
|
479
291
|
allowedTools,
|
|
480
|
-
contextNote:
|
|
481
|
-
channelMeta: buildTelegramMeta({
|
|
482
|
-
channelName: self.channel.name,
|
|
483
|
-
author,
|
|
484
|
-
chatId: chat_id,
|
|
485
|
-
target,
|
|
486
|
-
routeToAgent: self.channel.route_to_agent,
|
|
487
|
-
}),
|
|
292
|
+
contextNote: slashed.handled ? slashed.contextNote : "",
|
|
488
293
|
signal: abortCtrl.signal,
|
|
489
294
|
onEvent,
|
|
490
|
-
requestConfirmation: confirmAdapter.requestConfirmation,
|
|
491
295
|
});
|
|
492
296
|
replyText = sa.text;
|
|
493
297
|
replyAuthor = sa.name || agentDisplay;
|
|
@@ -496,13 +300,13 @@ export async function handleUpdate(self, u) {
|
|
|
496
300
|
saUsage = sa.usage;
|
|
497
301
|
|
|
498
302
|
// ── ask_questions integration ────────────────────────────────────
|
|
499
|
-
// If the super-agent ended this turn by calling ask_questions, hand
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
303
|
+
// If the super-agent ended this turn by calling ask_questions, hand off
|
|
304
|
+
// to the inline-keyboard flow instead of sending the bare assistant
|
|
305
|
+
// text. The flow keeps state per chat_id and re-runs the super-agent
|
|
306
|
+
// (via _runResumedTurn) once every answer is collected.
|
|
503
307
|
const askQuestions = askFlow.extractAskQuestionsFromTrace(sa.trace);
|
|
504
308
|
if (askQuestions && chat_id) {
|
|
505
|
-
|
|
309
|
+
self.activeRequests.delete(chat_id);
|
|
506
310
|
stopTyping();
|
|
507
311
|
try {
|
|
508
312
|
await self._startAskFlow({
|
|
@@ -523,21 +327,20 @@ export async function handleUpdate(self, u) {
|
|
|
523
327
|
}
|
|
524
328
|
return; // The reply for this turn IS the ask flow.
|
|
525
329
|
}
|
|
330
|
+
streamedCount = state.streamedCount;
|
|
331
|
+
lastStreamedText = state.lastStreamedText;
|
|
526
332
|
} catch (e) {
|
|
527
333
|
if (abortCtrl.signal.aborted) {
|
|
528
334
|
// A newer message superseded this one. Whatever streamed so far is
|
|
529
|
-
// already sent + logged; the newer message's run continues the
|
|
530
|
-
// thread from that history.
|
|
335
|
+
// already sent + logged; the newer message's run continues the thread.
|
|
531
336
|
self.log(`telegram[${self.channel.name}] request aborted for chat ${chat_id}`);
|
|
532
337
|
if (chat_id) self.activeRequests.delete(chat_id);
|
|
533
338
|
stopTyping();
|
|
534
339
|
return;
|
|
535
340
|
}
|
|
536
341
|
self.log(`telegram[${self.channel.name}] super-agent failed: ${e.message}`);
|
|
537
|
-
// Surface the failure to the user instead of silently dropping the
|
|
538
|
-
|
|
539
|
-
// the message. Keep the message short and non-leaking.
|
|
540
|
-
replyText = `⚠️ Could not generate a reply right now (${e.message || "internal error"}).`;
|
|
342
|
+
// Surface the failure to the user instead of silently dropping the turn.
|
|
343
|
+
replyText = telegramErrorText(self, e);
|
|
541
344
|
replyAuthor = agentDisplay;
|
|
542
345
|
replyActorId = SUPERAGENT_ACTOR_ID;
|
|
543
346
|
replyKind = "superagent";
|
|
@@ -545,71 +348,18 @@ export async function handleUpdate(self, u) {
|
|
|
545
348
|
}
|
|
546
349
|
|
|
547
350
|
if (chat_id) self.activeRequests.delete(chat_id);
|
|
548
|
-
|
|
549
|
-
// Final answer. The intermediate prose was already streamed; only send the
|
|
550
|
-
// final text if it's non-empty AND not a duplicate of the last streamed
|
|
551
|
-
// piece (the loop can end on an iteration whose text was already sent).
|
|
552
|
-
// If nothing streamed and there's no final text, send a minimal ack so the
|
|
553
|
-
// turn isn't silently empty.
|
|
554
|
-
const finalClean = replyText ? stripThinking(replyText).trim() : "";
|
|
555
|
-
let toSend = "";
|
|
556
|
-
if (finalClean && finalClean !== lastStreamedText) {
|
|
557
|
-
toSend = finalClean;
|
|
558
|
-
} else if (!finalClean) {
|
|
559
|
-
// Never end a turn on silence. The loop's tool-free wrap-up normally
|
|
560
|
-
// fills finalClean with a model-authored closing (handled above); this is
|
|
561
|
-
// the last-resort floor for the rare case it still came back empty. A
|
|
562
|
-
// pure chit-chat turn that did nothing gets the short ack; a turn that
|
|
563
|
-
// streamed/acted but produced no closing gets a neutral "continue?" that
|
|
564
|
-
// does NOT claim completion.
|
|
565
|
-
toSend = streamedCount === 0
|
|
566
|
-
? t("telegram.fallback_listo", { lang: resolveLang(self.globalConfig) })
|
|
567
|
-
: t("telegram.fallback_continue", { lang: resolveLang(self.globalConfig) });
|
|
568
|
-
}
|
|
569
|
-
|
|
570
351
|
stopTyping();
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
appendGlobalMessage({
|
|
584
|
-
channel: CHANNELS.TELEGRAM,
|
|
585
|
-
direction: "out",
|
|
586
|
-
type: "agent",
|
|
587
|
-
actor_id: replyActorId || SUPERAGENT_ACTOR_ID,
|
|
588
|
-
actor_kind: replyKind || "superagent",
|
|
589
|
-
agent_slug: replyActorId || SUPERAGENT_ACTOR_ID,
|
|
590
|
-
author: replyAuthor || agentDisplay,
|
|
591
|
-
body: toSend,
|
|
592
|
-
meta,
|
|
593
|
-
});
|
|
594
|
-
} catch (e) {
|
|
595
|
-
self.log(`telegram[${self.channel.name}] send-back error: ${e.message}`);
|
|
596
|
-
appendGlobalMessage({
|
|
597
|
-
channel: CHANNELS.TELEGRAM,
|
|
598
|
-
direction: "out",
|
|
599
|
-
type: "agent",
|
|
600
|
-
actor_id: replyActorId || SUPERAGENT_ACTOR_ID,
|
|
601
|
-
actor_kind: replyKind || "superagent",
|
|
602
|
-
agent_slug: replyActorId || SUPERAGENT_ACTOR_ID,
|
|
603
|
-
author: replyAuthor || agentDisplay,
|
|
604
|
-
body: `[send_failed] ${toSend}`,
|
|
605
|
-
meta: {
|
|
606
|
-
chat_id,
|
|
607
|
-
tg_channel: self.channel.name,
|
|
608
|
-
in_reply_to: u.update_id,
|
|
609
|
-
send_error: e.message,
|
|
610
|
-
...(saUsage ? { usage: saUsage } : {}),
|
|
611
|
-
},
|
|
612
|
-
});
|
|
613
|
-
}
|
|
352
|
+
await sendFinalReply(self, {
|
|
353
|
+
chat_id,
|
|
354
|
+
update_id: u.update_id,
|
|
355
|
+
replyText,
|
|
356
|
+
replyAuthor,
|
|
357
|
+
replyActorId,
|
|
358
|
+
replyKind,
|
|
359
|
+
saUsage,
|
|
360
|
+
streamedCount,
|
|
361
|
+
lastStreamedText,
|
|
362
|
+
agentDisplay,
|
|
363
|
+
});
|
|
614
364
|
}
|
|
615
365
|
|
|
@@ -2,10 +2,37 @@
|
|
|
2
2
|
// big poller class stays focused on lifecycle + message dispatch. Each
|
|
3
3
|
// function is pure (no `this`) — instances import them and call as needed.
|
|
4
4
|
import fs from "node:fs";
|
|
5
|
-
import
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { TELEGRAM_STATE_PATH, APX_HOME } from "#core/config/index.js";
|
|
6
7
|
|
|
7
8
|
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Display label for a Telegram sender used as the `author` / actor fallback:
|
|
12
|
+
* @username → "First Last" → "unknown".
|
|
13
|
+
* Single source of truth so every inbound branch (text/photo/audio) and the
|
|
14
|
+
* message store agree. NOTE: this is the raw handle; the *resolved contact
|
|
15
|
+
* name* (which prefers a saved roster name) is `resolveSender().name` in
|
|
16
|
+
* core/identity/telegram.js — different purpose, don't conflate them.
|
|
17
|
+
*/
|
|
18
|
+
export function telegramAuthorLabel(from) {
|
|
19
|
+
if (from?.username) return "@" + from.username;
|
|
20
|
+
const full = `${from?.first_name || ""} ${from?.last_name || ""}`.trim();
|
|
21
|
+
return full || "unknown";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure and return the shared media-download directory (~/.apx/media).
|
|
26
|
+
* Owns BOTH the path and the mkdir so callers never touch `fs`/`APX_HOME`
|
|
27
|
+
* directly — the inbound dispatcher used to inline this and a module split
|
|
28
|
+
* dropped its `fs`/`APX_HOME` imports, silently breaking every photo/voice.
|
|
29
|
+
*/
|
|
30
|
+
export function telegramMediaDir() {
|
|
31
|
+
const dir = path.join(APX_HOME, "media");
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
/**
|
|
10
37
|
* Build the channelMeta block the super-agent loop receives for a Telegram
|
|
11
38
|
* turn. The prompt template at src/core/agent/prompts/channels/telegram.md
|