@agentprojectcontext/apx 1.79.0 → 1.80.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/run-agent.js +16 -1
- package/src/core/agent/super-agent.js +3 -0
- package/src/core/agent/tools/handlers/remember-routine.js +79 -0
- package/src/core/agent/tools/names.js +3 -0
- package/src/core/agent/tools/registry.js +6 -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/profiles/bundled/secretary/routines/day-close.json +1 -1
- package/src/core/profiles/bundled/secretary/routines/watch.json +1 -1
- package/src/core/routines/runner.js +34 -0
- 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,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { appendRoutineMemory } from "#core/stores/routine-memory.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A routine writing to its OWN memory.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS TOOL EXISTS. Routine memory is a file, and until now the only way to
|
|
7
|
+
* write it was `write_file` — which is gated as dangerous, correctly, because it
|
|
8
|
+
* can write anywhere. In a routine there is nobody to confirm anything, so
|
|
9
|
+
* `requirePermission` threw "Action requires user confirmation", the model
|
|
10
|
+
* treated it as a dead end, and the evening anchor ended without sending
|
|
11
|
+
* anything. The run still reported `ok`, so the only symptom was silence.
|
|
12
|
+
*
|
|
13
|
+
* An agent recording what it learned is not a dangerous act. It is the most
|
|
14
|
+
* ordinary thing it does, and it must not need a human standing by.
|
|
15
|
+
*
|
|
16
|
+
* WHY IT IS SAFE UNGATED: there is no path argument. The destination comes from
|
|
17
|
+
* the running routine's own context (channelMeta.routineId + the project's
|
|
18
|
+
* storage), so this tool cannot write anywhere else however it is called. That
|
|
19
|
+
* is the whole difference between this and write_file, and it is the reason the
|
|
20
|
+
* permission is unnecessary rather than merely inconvenient.
|
|
21
|
+
*/
|
|
22
|
+
export default {
|
|
23
|
+
name: "remember_routine",
|
|
24
|
+
schema: {
|
|
25
|
+
type: "function",
|
|
26
|
+
function: {
|
|
27
|
+
name: "remember_routine",
|
|
28
|
+
description:
|
|
29
|
+
"Save a durable note to THIS routine's own memory — what you learned about how the " +
|
|
30
|
+
"owner works, what turned out to be worth mentioning, what turned out to be noise. " +
|
|
31
|
+
"Only available while a routine is running. Use this instead of write_file: it is the " +
|
|
32
|
+
"routine's own notebook and needs no permission. For facts about the owner that matter " +
|
|
33
|
+
"on every channel, use `remember` instead.",
|
|
34
|
+
parameters: {
|
|
35
|
+
type: "object",
|
|
36
|
+
required: ["note"],
|
|
37
|
+
properties: {
|
|
38
|
+
note: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "One line. A judgement that will change what you do next time, not a log of what happened.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
makeHandler: ({ channel, channelMeta, projects }) => async ({ note } = {}) => {
|
|
47
|
+
const text = String(note || "").trim();
|
|
48
|
+
if (!text) return { error: "note required" };
|
|
49
|
+
|
|
50
|
+
const routineId = channelMeta?.routineId;
|
|
51
|
+
const routineName = channelMeta?.routineName || "";
|
|
52
|
+
if (!routineId) {
|
|
53
|
+
// Said plainly so the model reaches for `remember` instead of retrying.
|
|
54
|
+
return {
|
|
55
|
+
error:
|
|
56
|
+
"remember_routine only works inside a running routine. " +
|
|
57
|
+
"Use `remember` for a durable fact about the owner.",
|
|
58
|
+
channel: channel || null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The routine's project storage, never a model-supplied path.
|
|
63
|
+
const projectPath = channelMeta?.projectPath || "";
|
|
64
|
+
let storagePath = "";
|
|
65
|
+
for (const entry of projects?.list?.() || []) {
|
|
66
|
+
if (projectPath && entry.path !== projectPath) continue;
|
|
67
|
+
storagePath = projects.get(entry.id)?.storagePath || "";
|
|
68
|
+
if (storagePath) break;
|
|
69
|
+
}
|
|
70
|
+
if (!storagePath) return { error: "could not resolve this routine's storage" };
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const r = appendRoutineMemory(storagePath, routineId, text, { routineName });
|
|
74
|
+
return { saved: true, routine: routineName || routineId, note: text, path: r?.path };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { error: e.message };
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -42,6 +42,7 @@ export const TOOLS = Object.freeze({
|
|
|
42
42
|
LIST_TASKS: "list_tasks",
|
|
43
43
|
CREATE_TASK: "create_task",
|
|
44
44
|
RECORD_COMMITMENT: "record_commitment",
|
|
45
|
+
REMEMBER_ROUTINE: "remember_routine",
|
|
45
46
|
LIST_COMMITMENTS: "list_commitments",
|
|
46
47
|
|
|
47
48
|
// Interaction
|
|
@@ -139,6 +140,7 @@ export const NATIVE_TOOL_NAMES = new Set([
|
|
|
139
140
|
TOOLS.CREATE_TASK,
|
|
140
141
|
TOOLS.RECORD_COMMITMENT,
|
|
141
142
|
TOOLS.LIST_COMMITMENTS,
|
|
143
|
+
TOOLS.REMEMBER_ROUTINE,
|
|
142
144
|
TOOLS.ASK_QUESTIONS,
|
|
143
145
|
TOOLS.SEARCH_SESSIONS,
|
|
144
146
|
TOOLS.TRANSCRIBE_AUDIO,
|
|
@@ -228,6 +230,7 @@ export const SIDE_EFFECT_TOOLS = new Set([
|
|
|
228
230
|
TOOLS.SEND_TELEGRAM,
|
|
229
231
|
TOOLS.CREATE_TASK,
|
|
230
232
|
TOOLS.RECORD_COMMITMENT,
|
|
233
|
+
TOOLS.REMEMBER_ROUTINE,
|
|
231
234
|
TOOLS.WRITE_FILE,
|
|
232
235
|
TOOLS.EDIT_FILE,
|
|
233
236
|
TOOLS.RUN_SHELL,
|
|
@@ -30,6 +30,7 @@ import askQuestions from "./handlers/ask-questions.js";
|
|
|
30
30
|
import createTask from "./handlers/create-task.js";
|
|
31
31
|
import recordCommitment from "./handlers/record-commitment.js";
|
|
32
32
|
import listCommitments from "./handlers/list-commitments.js";
|
|
33
|
+
import rememberRoutine from "./handlers/remember-routine.js";
|
|
33
34
|
import listTasks from "./handlers/list-tasks.js";
|
|
34
35
|
import discoverTools from "./handlers/discover-tools.js";
|
|
35
36
|
import gitStatus from "./handlers/git-status.js";
|
|
@@ -85,6 +86,7 @@ const NATIVE_TOOLS = [
|
|
|
85
86
|
listTasks,
|
|
86
87
|
recordCommitment,
|
|
87
88
|
listCommitments,
|
|
89
|
+
rememberRoutine,
|
|
88
90
|
discoverTools,
|
|
89
91
|
gitStatus,
|
|
90
92
|
gitDiff,
|
|
@@ -143,6 +145,10 @@ export const BASE_TOOL_NAMES = new Set([
|
|
|
143
145
|
TOOLS.READ_AGENT_MEMORY,
|
|
144
146
|
TOOLS.READ_SELF_MEMORY,
|
|
145
147
|
TOOLS.REMEMBER,
|
|
148
|
+
// NOT here: remember_routine. It only works inside a routine, and the routine
|
|
149
|
+
// channel is in FULL_CHANNELS — it already receives the whole registry. Adding
|
|
150
|
+
// it to the base set would spend tokens on every Telegram and desktop turn for
|
|
151
|
+
// a tool that returns an error on those channels.
|
|
146
152
|
TOOLS.SET_IDENTITY,
|
|
147
153
|
// Sessions + messages (self-recall + channel history).
|
|
148
154
|
TOOLS.SEARCH_SESSIONS,
|
|
@@ -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
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"permission_mode": "permiso",
|
|
6
6
|
"enabled_by_default": true,
|
|
7
7
|
"spec": {
|
|
8
|
-
"prompt": "Close the day. Review what moved today, what is still stuck, and what carries over to tomorrow, across every registered project. Call list_tasks and list_commitments ONCE each, with no project argument — both search every project in a single call. Do not loop over projects. If a commitment came due today, ask whether it was kept — do not mark it yourself.\n\nSend ONE short message covering those, keeping promises to people separate from ordinary tasks. Note anything that has gone quiet longer than the staleness threshold. If the day was quiet, say that plainly in a line or two.\n\nThen record
|
|
8
|
+
"prompt": "Close the day. Review what moved today, what is still stuck, and what carries over to tomorrow, across every registered project. Call list_tasks and list_commitments ONCE each, with no project argument — both search every project in a single call. Do not loop over projects. If a commitment came due today, ask whether it was kept — do not mark it yourself.\n\nSend ONE short message covering those, keeping promises to people separate from ordinary tasks. Note anything that has gone quiet longer than the staleness threshold. If the day was quiet, say that plainly in a line or two.\n\nThen record anything durable you learned about how this person works with remember_routine — one line. Never write_file for this: a scheduled run has nobody to approve it and the whole turn dies there.\n\nDELIVERY: send it with send_telegram. A routine's own output is only written to the log — if you do not call the tool, {{owner_name}} receives nothing at all. Send exactly one message, then stop; do not end the turn by asking whether to keep looking.",
|
|
9
9
|
"anchor": true
|
|
10
10
|
},
|
|
11
11
|
"allowed_tools": [
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"permission_mode": "permiso",
|
|
6
6
|
"enabled_by_default": true,
|
|
7
7
|
"spec": {
|
|
8
|
-
"prompt": "You are watching {{owner_name}}'s projects between the anchors. The signals below were detected deterministically — they are facts, already gathered; you do not need to look them up again.\n\nDecide whether any of it is worth interrupting for RIGHT NOW rather than waiting for the next anchor. Staying quiet is the normal outcome and costs you nothing. A message that could have waited until tomorrow morning costs you the next one, because that is the one they will not open.\n\nIf you do speak: send ONE line naming the single most important thing and what you suggest doing about it, with send_telegram. DELIVERY: send it with send_telegram. A routine's own output is only written to the log — if you do not call the tool, {{owner_name}} receives nothing at all. If you decide to stay quiet, just say so in your reply and call nothing."
|
|
8
|
+
"prompt": "You are watching {{owner_name}}'s projects between the anchors. The signals below were detected deterministically — they are facts, already gathered; you do not need to look them up again.\n\nDecide whether any of it is worth interrupting for RIGHT NOW rather than waiting for the next anchor. Staying quiet is the normal outcome and costs you nothing. A message that could have waited until tomorrow morning costs you the next one, because that is the one they will not open.\n\nIf you do speak: send ONE line naming the single most important thing and what you suggest doing about it, with send_telegram. DELIVERY: send it with send_telegram. A routine's own output is only written to the log — if you do not call the tool, {{owner_name}} receives nothing at all. If you decide to stay quiet, just say so in your reply and call nothing.\n\nWhichever you choose, note what you learned with remember_routine — especially when you stayed quiet, because next time you need to know this kind of signal was not worth it."
|
|
9
9
|
},
|
|
10
10
|
"allowed_tools": [
|
|
11
11
|
"send_telegram"
|
|
@@ -145,6 +145,12 @@ async function handleSuperAgent(ctx, routine, extraChannelMeta = {}) {
|
|
|
145
145
|
suppressTools: suppressTools.length > 0 ? suppressTools : null,
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// A tool that needed a human in a run with no human is a DEAD END, not a
|
|
149
|
+
// hiccup: nobody is ever going to confirm it. Reporting the run as "ok"
|
|
150
|
+
// meant the only symptom was silence — the evening anchor produced nothing
|
|
151
|
+
// and it took twenty-one shell commands to work out why. Name it.
|
|
152
|
+
const blocked = blockedForPermission(result.trace);
|
|
153
|
+
|
|
148
154
|
project.logMessage({
|
|
149
155
|
channel: CHANNELS.ROUTINE,
|
|
150
156
|
direction: "out",
|
|
@@ -161,9 +167,37 @@ async function handleSuperAgent(ctx, routine, extraChannelMeta = {}) {
|
|
|
161
167
|
usage: result.usage,
|
|
162
168
|
},
|
|
163
169
|
});
|
|
170
|
+
if (blocked.length) {
|
|
171
|
+
return {
|
|
172
|
+
status: "error",
|
|
173
|
+
error:
|
|
174
|
+
`blocked waiting for a confirmation nobody can give: ${blocked.join(", ")}. ` +
|
|
175
|
+
`A scheduled run has no one to approve a dangerous tool — either allow it on ` +
|
|
176
|
+
`this routine (allowed_tools) or use a tool that does not need approval.`,
|
|
177
|
+
blocked_tools: blocked,
|
|
178
|
+
reply: result.text,
|
|
179
|
+
trace: result.trace,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
164
182
|
return { status: "ok", reply: result.text, trace: result.trace };
|
|
165
183
|
}
|
|
166
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Tools whose result was "Action requires user confirmation" — the message
|
|
187
|
+
* createPermissionGuard throws when there is no confirmation channel wired
|
|
188
|
+
* (tools/helpers.js). Distinct from a tool that merely failed.
|
|
189
|
+
*/
|
|
190
|
+
export function blockedForPermission(trace) {
|
|
191
|
+
const names = new Set();
|
|
192
|
+
for (const item of Array.isArray(trace) ? trace : []) {
|
|
193
|
+
const err = item?.result?.error;
|
|
194
|
+
if (typeof err === "string" && /requires user confirmation/i.test(err)) {
|
|
195
|
+
names.add(item.tool || "unknown");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...names];
|
|
199
|
+
}
|
|
200
|
+
|
|
167
201
|
async function handleTelegram(ctx, routine) {
|
|
168
202
|
const { plugins } = ctx;
|
|
169
203
|
const tg = plugins?.get("telegram");
|
|
@@ -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.
|