@agentprojectcontext/apx 1.42.0 → 1.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ // Shared Telegram super-agent reply path. Both the inbound dispatcher
2
+ // (handleUpdate) and the ask-flow resume (_runResumedTurn in the host poller)
3
+ // drive the SAME streamed turn through these helpers, so behavior — autonomy
4
+ // budget, streaming, never-silent floor, localized errors, rich channelMeta —
5
+ // can't drift between the two entry points. It did drift: the resume path was a
6
+ // stale hand-rolled copy that missed maxIters, streaming and i18n. One source
7
+ // of truth fixes that for good.
8
+ import { runSuperAgent } from "#core/agent/super-agent.js";
9
+ import { TELEGRAM_TOOL_ITERS } from "#core/agent/constants.js";
10
+ import { stripThinking } from "#core/util/thinking.js";
11
+ import { appendGlobalMessage } from "#core/stores/messages.js";
12
+ import { CHANNELS } from "#core/constants/channels.js";
13
+ import { SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
14
+ import { createTelegramConfirmAdapter } from "#core/confirmation/adapters/telegram.js";
15
+ import { getConfirmationStore as getConfirmStore } from "#core/confirmation/pending-store.js";
16
+ import { t, resolveLang } from "#core/i18n/index.js";
17
+ import { buildTelegramMeta, resolveBotToken } from "./helpers.js";
18
+
19
+ /**
20
+ * Build the streaming event handler for a Telegram super-agent turn. Sends a
21
+ * one-shot localized heads-up the moment real work starts (first tool), streams
22
+ * each assistant-text iteration as its own chat message, and logs tool calls
23
+ * (audit trail / other channels — never sent to Telegram). Returns the handler
24
+ * plus a live `state` the caller reads AFTER the run to drive the final send.
25
+ *
26
+ * @returns {{ onEvent: Function, state: { streamedCount: number, lastStreamedText: string } }}
27
+ */
28
+ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
29
+ const state = { streamedCount: 0, lastStreamedText: "", sentHeadsUp: false };
30
+ const onEvent = async (ev) => {
31
+ try {
32
+ if (ev.type === "tool_start" && !state.sentHeadsUp && state.streamedCount === 0) {
33
+ state.sentHeadsUp = true;
34
+ const heads = t("telegram.heads_up", { lang: resolveLang(self.globalConfig) });
35
+ await self._send({ chat_id, text: heads });
36
+ appendGlobalMessage({
37
+ channel: CHANNELS.TELEGRAM,
38
+ direction: "out",
39
+ type: "agent",
40
+ actor_id: SUPERAGENT_ACTOR_ID,
41
+ actor_kind: "superagent",
42
+ agent_slug: SUPERAGENT_ACTOR_ID,
43
+ author: agentDisplay,
44
+ body: heads,
45
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, heads_up: true },
46
+ });
47
+ return;
48
+ }
49
+ if (ev.type === "assistant_text" && ev.text) {
50
+ const piece = stripThinking(ev.text).trim();
51
+ if (!piece) return;
52
+ await self._send({ chat_id, text: piece });
53
+ state.lastStreamedText = piece;
54
+ state.streamedCount += 1;
55
+ appendGlobalMessage({
56
+ channel: CHANNELS.TELEGRAM,
57
+ direction: "out",
58
+ type: "agent",
59
+ actor_id: SUPERAGENT_ACTOR_ID,
60
+ actor_kind: "superagent",
61
+ agent_slug: SUPERAGENT_ACTOR_ID,
62
+ author: agentDisplay,
63
+ body: piece,
64
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, streamed: true, iteration: ev.iteration },
65
+ });
66
+ } else if (ev.type === "tool_result" && ev.trace) {
67
+ // Logged for the audit trail / other channels — NOT sent to Telegram.
68
+ const tr = ev.trace;
69
+ appendGlobalMessage({
70
+ channel: CHANNELS.TELEGRAM,
71
+ direction: "out",
72
+ type: "tool",
73
+ actor_id: tr.tool,
74
+ actor_kind: "tool",
75
+ author: agentDisplay,
76
+ body: `${tr.tool}(${JSON.stringify(tr.args || {}).slice(0, 200)})`,
77
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, tool: tr.tool, args: tr.args, result: tr.result, iteration: ev.iteration },
78
+ });
79
+ } else if (ev.type === "engine_failed") {
80
+ // A model in the fallback chain errored; the loop is rotating to the
81
+ // next one. Log so a mid-turn provider failure is diagnosable.
82
+ self.log(`telegram[${self.channel.name}] engine_failed: ${ev.model || "?"} (${ev.reason || "?"}) → ${ev.retry_with || "end of chain"}`);
83
+ } else if (ev.type === "model_routed" || ev.type === "model_retry") {
84
+ self.log(`telegram[${self.channel.name}] ${ev.type}: model=${ev.model || "?"}${ev.reason ? ` reason=${ev.reason}` : ""}${ev.from_fallback ? " (fallback)" : ""}`);
85
+ }
86
+ } catch (e) {
87
+ // A failed intermediate send must not abort the whole run.
88
+ self.log(`telegram[${self.channel.name}] stream event failed: ${e.message}`);
89
+ }
90
+ };
91
+ return { onEvent, state };
92
+ }
93
+
94
+ /**
95
+ * Run the super-agent for a Telegram turn with the canonical channel config:
96
+ * the autonomy budget (telegram_max_iters → TELEGRAM_TOOL_ITERS), rich
97
+ * channelMeta (project pin + route), the confirmation adapter, and streaming.
98
+ * The single place this call is configured — change it once, both entry points
99
+ * inherit it. Throws on failure (caller decides abort-vs-error handling).
100
+ */
101
+ export function runTelegramSuperAgent(self, {
102
+ chat_id, prompt, previousMessages, target, author, relationshipBlock,
103
+ allowedTools, contextNote, signal, onEvent,
104
+ }) {
105
+ const confirmAdapter = createTelegramConfirmAdapter({
106
+ token: resolveBotToken(self.channel),
107
+ chatId: chat_id,
108
+ pendingStore: getConfirmStore(),
109
+ });
110
+ return runSuperAgent({
111
+ globalConfig: self.globalConfig,
112
+ projects: self.projects,
113
+ plugins: self.plugins,
114
+ registries: self.registries,
115
+ prompt,
116
+ previousMessages,
117
+ channel: CHANNELS.TELEGRAM,
118
+ relationshipBlock,
119
+ allowedTools,
120
+ contextNote: contextNote || undefined,
121
+ channelMeta: buildTelegramMeta({
122
+ channelName: self.channel.name,
123
+ author,
124
+ chatId: chat_id,
125
+ target,
126
+ routeToAgent: self.channel.route_to_agent,
127
+ }),
128
+ signal,
129
+ onEvent,
130
+ requestConfirmation: confirmAdapter.requestConfirmation,
131
+ // Autonomy budget: Telegram is the "do the whole task for me" surface, so it
132
+ // gets a real multi-step budget instead of the conversational default (which
133
+ // cut tasks off after ~9 actions to ask "continue?"). Tunable via
134
+ // config.super_agent.telegram_max_iters.
135
+ maxIters: Number(self.globalConfig?.super_agent?.telegram_max_iters) || TELEGRAM_TOOL_ITERS,
136
+ });
137
+ }
138
+
139
+ /** Localized "couldn't reply" text for a failed super-agent turn (model itself
140
+ * failed, so it can't author this — templated, but follows the user's language). */
141
+ export function telegramErrorText(self, e) {
142
+ return t("telegram.error_generic", {
143
+ lang: resolveLang(self.globalConfig),
144
+ vars: { error: e?.message || "internal error" },
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Send the final reply for a turn and log it. The intermediate prose was already
150
+ * streamed, so we only send `replyText` if it's non-empty AND not a duplicate of
151
+ * the last streamed piece. Never ends on silence: a turn that streamed/acted but
152
+ * produced no closing gets a neutral "continue?"; a pure chit-chat turn that did
153
+ * nothing gets a short ack. Caller stops the typing indicator before calling.
154
+ */
155
+ export async function sendFinalReply(self, {
156
+ chat_id, update_id, replyText, replyAuthor, replyActorId, replyKind,
157
+ saUsage = null, streamedCount = 0, lastStreamedText = "", agentDisplay,
158
+ extraMeta = {},
159
+ }) {
160
+ const finalClean = replyText ? stripThinking(replyText).trim() : "";
161
+ let toSend = "";
162
+ if (finalClean && finalClean !== lastStreamedText) {
163
+ toSend = finalClean;
164
+ } else if (!finalClean) {
165
+ const lang = resolveLang(self.globalConfig);
166
+ toSend = streamedCount === 0
167
+ ? t("telegram.fallback_listo", { lang })
168
+ : t("telegram.fallback_continue", { lang });
169
+ }
170
+ if (!toSend) return; // everything was already streamed — nothing left to send
171
+
172
+ const actorId = replyActorId || SUPERAGENT_ACTOR_ID;
173
+ const kind = replyKind || "superagent";
174
+ try {
175
+ await self._send({ chat_id, text: toSend });
176
+ const meta = { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, final: true, ...extraMeta };
177
+ if (replyText && stripThinking(replyText) !== replyText) meta.thinking_stripped = true;
178
+ if (saUsage) meta.usage = saUsage;
179
+ appendGlobalMessage({
180
+ channel: CHANNELS.TELEGRAM,
181
+ direction: "out",
182
+ type: "agent",
183
+ actor_id: actorId,
184
+ actor_kind: kind,
185
+ agent_slug: actorId,
186
+ author: replyAuthor || agentDisplay,
187
+ body: toSend,
188
+ meta,
189
+ });
190
+ } catch (e) {
191
+ self.log(`telegram[${self.channel.name}] send-back error: ${e.message}`);
192
+ appendGlobalMessage({
193
+ channel: CHANNELS.TELEGRAM,
194
+ direction: "out",
195
+ type: "agent",
196
+ actor_id: actorId,
197
+ actor_kind: kind,
198
+ agent_slug: actorId,
199
+ author: replyAuthor || agentDisplay,
200
+ body: `[send_failed] ${toSend}`,
201
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, send_error: e.message, ...(saUsage ? { usage: saUsage } : {}) },
202
+ });
203
+ }
204
+ }
@@ -57,6 +57,11 @@ const DEFAULT_CONFIG = {
57
57
  system: "", // optional override; defaults in src/core/agent/prompts/
58
58
  permission_mode: PERMISSION_MODES.AUTOMATICO, // total | automatico | permiso
59
59
  allowed_tools: [], // used by permission_mode="permiso"
60
+ // Per-turn tool-loop budget for the Telegram super-agent. Higher = more
61
+ // autonomous (chains explore→edit→verify→close before replying); lower =
62
+ // snappier but more "want me to continue?" hand-backs. 0/unset → built-in
63
+ // default (TELEGRAM_TOOL_ITERS in src/core/agent/constants.js).
64
+ telegram_max_iters: 0,
60
65
  // Model fallback: ordered list. Each item carries its own provider
61
66
  // prefix; the array order IS the attempt order. The router tries the
62
67
  // primary (super_agent.model) first, then walks this list, skipping
@@ -24,7 +24,10 @@
24
24
  // keyboard but before the user tapped, pendingStore.wasKnown() detects the
25
25
  // SQLite row with no memory entry and we show "Expirado" instead of an error.
26
26
 
27
- const API_BASE = "https://api.telegram.org";
27
+ // Raw Bot API calls go through the shared client so endpoint boilerplate lives
28
+ // in one place (these used to be hand-rolled fetch calls duplicated here).
29
+ import { sendMessage, answerCallbackQuery as apiAnswerCallbackQuery, editMessageReplyMarkup } from "#core/channels/telegram/api.js";
30
+
28
31
  const TIMEOUT_MS = 60_000; // 60 s — long enough for a human, short enough to not block forever
29
32
 
30
33
  /**
@@ -81,51 +84,31 @@ export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
81
84
 
82
85
  async function sendConfirmKeyboard(token, chatId, description, correlationId, timeoutMs) {
83
86
  const timeoutSec = Math.round(timeoutMs / 1000);
84
- await fetch(`${API_BASE}/bot${token}/sendMessage`, {
85
- method: "POST",
86
- headers: { "content-type": "application/json" },
87
- body: JSON.stringify({
88
- chat_id: chatId,
89
- text:
90
- `⚠️ *Confirm action*\n\n${escapeMarkdown(description)}\n\n` +
91
- `_Expires in ${timeoutSec}s. No response cancelled._`,
92
- parse_mode: "Markdown",
93
- reply_markup: {
94
- inline_keyboard: [[
95
- { text: "✅ Yes", callback_data: `apx:confirm:${correlationId}:yes` },
96
- { text: "❌ No", callback_data: `apx:confirm:${correlationId}:no` },
97
- ]],
98
- },
99
- }),
87
+ await sendMessage(token, chatId, {
88
+ text:
89
+ `⚠️ *Confirm action*\n\n${escapeMarkdown(description)}\n\n` +
90
+ `_Expires in ${timeoutSec}s. No response → cancelled._`,
91
+ parse_mode: "Markdown",
92
+ reply_markup: {
93
+ inline_keyboard: [[
94
+ { text: "✅ Yes", callback_data: `apx:confirm:${correlationId}:yes` },
95
+ { text: "❌ No", callback_data: `apx:confirm:${correlationId}:no` },
96
+ ]],
97
+ },
100
98
  });
101
99
  }
102
100
 
101
+ // best-effort — Telegram gives only ~30s to answer; after that it's already cleared
103
102
  async function answerCallbackQuery(token, callbackQueryId, text) {
104
103
  try {
105
- await fetch(`${API_BASE}/bot${token}/answerCallbackQuery`, {
106
- method: "POST",
107
- headers: { "content-type": "application/json" },
108
- body: JSON.stringify({ callback_query_id: callbackQueryId, text }),
109
- });
110
- } catch {
111
- // best-effort — Telegram gives only 30s to answer; after that it's already cleared
112
- }
104
+ await apiAnswerCallbackQuery(token, callbackQueryId, text);
105
+ } catch { /* best-effort */ }
113
106
  }
114
107
 
115
108
  async function editMessageButtons(token, chatId, messageId, inlineKeyboard) {
116
109
  try {
117
- await fetch(`${API_BASE}/bot${token}/editMessageReplyMarkup`, {
118
- method: "POST",
119
- headers: { "content-type": "application/json" },
120
- body: JSON.stringify({
121
- chat_id: chatId,
122
- message_id: messageId,
123
- reply_markup: { inline_keyboard: inlineKeyboard },
124
- }),
125
- });
126
- } catch {
127
- // best-effort
128
- }
110
+ await editMessageReplyMarkup(token, chatId, messageId, { inline_keyboard: inlineKeyboard });
111
+ } catch { /* best-effort */ }
129
112
  }
130
113
 
131
114
  // Escape Markdown special chars so description text doesn't break Telegram markup.
@@ -4,4 +4,8 @@ export default {
4
4
  "telegram.reset_ack": "Done, context cleared. Starting fresh. What do you need?",
5
5
  "telegram.fallback_listo": "Done.",
6
6
  "telegram.fallback_continue": "Made some headway. Want me to keep going?",
7
+ // Host-emitted error floors (the model itself failed, so it can't author
8
+ // these — they stay templated, but at least follow the user's language).
9
+ "telegram.error_agent": "⚠️ The agent hit an error ({error}).",
10
+ "telegram.error_generic": "⚠️ Couldn't reply right now ({error}).",
7
11
  };
@@ -6,4 +6,8 @@ export default {
6
6
  "telegram.reset_ack": "Listo, contexto borrado. Arranco un hilo nuevo, ¿qué necesitás?",
7
7
  "telegram.fallback_listo": "Listo.",
8
8
  "telegram.fallback_continue": "Avancé con eso. ¿Querés que siga?",
9
+ // Pisos de error emitidos por el host (el modelo falló, no puede redactarlos
10
+ // él mismo — quedan fijos, pero al menos respetan el idioma del usuario).
11
+ "telegram.error_agent": "⚠️ El agente tuvo un error ({error}).",
12
+ "telegram.error_generic": "⚠️ No pude responder ahora mismo ({error}).",
9
13
  };
@@ -4,4 +4,8 @@ export default {
4
4
  "telegram.reset_ack": "Pronto, contexto limpo. Começando do zero — do que você precisa?",
5
5
  "telegram.fallback_listo": "Pronto.",
6
6
  "telegram.fallback_continue": "Avancei com isso. Quer que eu continue?",
7
+ // Pisos de erro emitidos pelo host (o modelo falhou, não pode redigi-los —
8
+ // ficam fixos, mas ao menos seguem o idioma do usuário).
9
+ "telegram.error_agent": "⚠️ O agente encontrou um erro ({error}).",
10
+ "telegram.error_generic": "⚠️ Não consegui responder agora ({error}).",
7
11
  };
@@ -140,7 +140,12 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
140
140
  channel: CHANNELS.DESKTOP,
141
141
  ...(slashed.handled ? { contextNote: slashed.contextNote } : {}),
142
142
  channelMeta: { voice: true }, // desktop module is voice-first → spoken mode
143
- previousMessages: history.slice(0, -1),
143
+ // WS path: history was just appended with the current user turn (line 87),
144
+ // so drop it. HTTP path: `previousMessages` came in already excluding the
145
+ // current user turn (the renderer slices it off before POSTing), so
146
+ // dropping again would silently strip the last assistant reply — making
147
+ // every turn look like a fresh conversation to the model.
148
+ previousMessages: ws ? history.slice(0, -1) : history,
144
149
  overrideModel: cfg.model || null,
145
150
  signal: controller.signal,
146
151
  onToken: (chunk) => { liveBuf += chunk; },