@agentprojectcontext/apx 1.31.1 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/package.json +1 -1
- package/skills/apc-context/SKILL.md +0 -1
- package/src/core/agent/constants.js +5 -0
- package/src/core/agent/run-agent.js +29 -1
- package/src/core/confirmation/adapters/code.js +41 -0
- package/src/core/confirmation/adapters/telegram.js +134 -0
- package/src/core/confirmation/adapters/terminal.js +35 -0
- package/src/core/confirmation/adapters/web.js +53 -0
- package/src/core/confirmation/index.js +44 -0
- package/src/core/confirmation/pending-store.js +68 -0
- package/src/host/daemon/api/artifacts.js +117 -0
- package/src/host/daemon/api/code.js +14 -0
- package/src/host/daemon/api/confirm.js +30 -0
- package/src/host/daemon/api/super-agent.js +12 -4
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/plugins/desktop.js +34 -0
- package/src/host/daemon/plugins/telegram-ask.js +309 -0
- package/src/host/daemon/plugins/telegram.js +358 -2
- package/src/host/daemon/super-agent-tools/helpers.js +27 -6
- package/src/host/daemon/super-agent-tools/index.js +1 -0
- package/src/host/daemon/super-agent-tools/tools/add-project.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
- package/src/host/daemon/super-agent-tools/tools/call-mcp.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/call-runtime.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/edit-file.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/run-shell.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/search-files.js +1 -4
- package/src/host/daemon/super-agent-tools/tools/send-telegram.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/set-identity.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/set-permission-mode.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/write-file.js +2 -2
- package/src/host/daemon/super-agent.js +5 -1
- package/src/interfaces/cli/commands/artifact.js +99 -0
- package/src/interfaces/cli/index.js +4 -0
- package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
- package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
- package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
- package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
- package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
- package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
- package/src/interfaces/web/src/i18n/en.ts +20 -0
- package/src/interfaces/web/src/i18n/es.ts +20 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
- package/src/interfaces/web/src/lib/api.ts +1 -0
- package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
- package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
- package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
- package/src/interfaces/web/dist/assets/index-BV615I9p.js.map +0 -1
|
@@ -41,6 +41,9 @@ import { transcribe as transcribeAudioFile } from "../transcription.js";
|
|
|
41
41
|
import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "../../../core/identity.js";
|
|
42
42
|
import { registerSender, resolveAllowedTools } from "../../../core/telegram-identity.js";
|
|
43
43
|
import { buildRelationshipBlock } from "../../../core/agent/index.js";
|
|
44
|
+
import { getConfirmationStore as getConfirmStore } from "../../../core/confirmation/pending-store.js";
|
|
45
|
+
import { createTelegramConfirmAdapter } from "../../../core/confirmation/adapters/telegram.js";
|
|
46
|
+
import * as askFlow from "./telegram-ask.js";
|
|
44
47
|
|
|
45
48
|
const API_BASE = "https://api.telegram.org";
|
|
46
49
|
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -422,6 +425,13 @@ class ChannelPoller {
|
|
|
422
425
|
|
|
423
426
|
async _handleUpdate(u) {
|
|
424
427
|
this.lastUpdateAt = nowIso();
|
|
428
|
+
|
|
429
|
+
// Inline keyboard button press: route to the confirmation adapter.
|
|
430
|
+
if (u.callback_query) {
|
|
431
|
+
await this._handleCallbackQuery(u.callback_query);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
425
435
|
const msg = u.message || u.edited_message;
|
|
426
436
|
if (!msg) return;
|
|
427
437
|
const target = this.resolveProject();
|
|
@@ -568,6 +578,30 @@ class ChannelPoller {
|
|
|
568
578
|
text = text ? `${audioBody}\n${text}` : audioBody;
|
|
569
579
|
}
|
|
570
580
|
|
|
581
|
+
// If there's a pending ask_questions flow for this chat AND the current
|
|
582
|
+
// question is free-text, treat this message as the answer rather than a
|
|
583
|
+
// brand-new turn. Returns true when the message was consumed.
|
|
584
|
+
if (chat_id && text && await this._maybeConsumeAskTextAnswer({ chat_id, text })) {
|
|
585
|
+
// Still log the inbound so the chat history records what the user said.
|
|
586
|
+
appendGlobalMessage({
|
|
587
|
+
channel: "telegram",
|
|
588
|
+
direction: "in",
|
|
589
|
+
type: "user",
|
|
590
|
+
actor_id: msg.from?.id ? String(msg.from.id) : author,
|
|
591
|
+
external_id: String(u.update_id),
|
|
592
|
+
author,
|
|
593
|
+
body: text,
|
|
594
|
+
meta: {
|
|
595
|
+
chat_id,
|
|
596
|
+
user_id: msg.from?.id || null,
|
|
597
|
+
message_id: msg.message_id,
|
|
598
|
+
tg_channel: this.channel.name,
|
|
599
|
+
ask_answer: true,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
571
605
|
// /reset or /new wipes the rolling context for this chat. We just
|
|
572
606
|
// remember a marker timestamp; subsequent inbounds will only consider
|
|
573
607
|
// history newer than this. Implemented by writing a synthetic message
|
|
@@ -806,6 +840,12 @@ class ChannelPoller {
|
|
|
806
840
|
}
|
|
807
841
|
};
|
|
808
842
|
|
|
843
|
+
const confirmAdapter = createTelegramConfirmAdapter({
|
|
844
|
+
token: resolveBotToken(this.channel),
|
|
845
|
+
chatId: chat_id,
|
|
846
|
+
pendingStore: getConfirmStore(),
|
|
847
|
+
});
|
|
848
|
+
|
|
809
849
|
try {
|
|
810
850
|
const sa = await runSuperAgent({
|
|
811
851
|
globalConfig: this.globalConfig,
|
|
@@ -826,12 +866,42 @@ class ChannelPoller {
|
|
|
826
866
|
}),
|
|
827
867
|
signal: abortCtrl.signal,
|
|
828
868
|
onEvent,
|
|
869
|
+
requestConfirmation: confirmAdapter.requestConfirmation,
|
|
829
870
|
});
|
|
830
871
|
replyText = sa.text;
|
|
831
872
|
replyAuthor = sa.name || agentDisplay;
|
|
832
873
|
replyActorId = SUPERAGENT_ACTOR_ID;
|
|
833
874
|
replyKind = "superagent";
|
|
834
875
|
saUsage = sa.usage;
|
|
876
|
+
|
|
877
|
+
// ── ask_questions integration ────────────────────────────────────
|
|
878
|
+
// If the super-agent ended this turn by calling ask_questions, hand
|
|
879
|
+
// off to the inline-keyboard flow instead of sending the bare
|
|
880
|
+
// assistant text. The flow keeps state per chat_id and re-runs the
|
|
881
|
+
// super-agent once every answer is collected.
|
|
882
|
+
const askQuestions = askFlow.extractAskQuestionsFromTrace(sa.trace);
|
|
883
|
+
if (askQuestions && chat_id) {
|
|
884
|
+
if (chat_id) this.activeRequests.delete(chat_id);
|
|
885
|
+
stopTyping();
|
|
886
|
+
try {
|
|
887
|
+
await this._startAskFlow({
|
|
888
|
+
chat_id,
|
|
889
|
+
projectId: target?.id,
|
|
890
|
+
authorId: msg.from?.id,
|
|
891
|
+
questions: askQuestions,
|
|
892
|
+
author,
|
|
893
|
+
agentDisplay,
|
|
894
|
+
relationshipBlock,
|
|
895
|
+
allowedTools,
|
|
896
|
+
target,
|
|
897
|
+
sender,
|
|
898
|
+
update_id: u.update_id,
|
|
899
|
+
});
|
|
900
|
+
} catch (e) {
|
|
901
|
+
this.log(`telegram[${this.channel.name}] ask flow start failed: ${e.message}`);
|
|
902
|
+
}
|
|
903
|
+
return; // The reply for this turn IS the ask flow.
|
|
904
|
+
}
|
|
835
905
|
} catch (e) {
|
|
836
906
|
if (abortCtrl.signal.aborted) {
|
|
837
907
|
// A newer message superseded this one. Whatever streamed so far is
|
|
@@ -911,6 +981,250 @@ class ChannelPoller {
|
|
|
911
981
|
}
|
|
912
982
|
}
|
|
913
983
|
|
|
984
|
+
async _handleCallbackQuery(callbackQuery) {
|
|
985
|
+
// Route ask_questions button presses before the confirmation adapter —
|
|
986
|
+
// both use `apx:<verb>:...` namespacing but ask owns its own state.
|
|
987
|
+
const data = callbackQuery.data || "";
|
|
988
|
+
if (data.startsWith("apx:ask:")) {
|
|
989
|
+
await this._handleAskCallback(callbackQuery);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const adapter = createTelegramConfirmAdapter({
|
|
994
|
+
token: resolveBotToken(this.channel),
|
|
995
|
+
chatId: callbackQuery.message?.chat?.id,
|
|
996
|
+
pendingStore: getConfirmStore(),
|
|
997
|
+
});
|
|
998
|
+
const handled = await adapter.handleCallbackQuery(callbackQuery);
|
|
999
|
+
if (!handled) {
|
|
1000
|
+
this.log(`telegram[${this.channel.name}] unhandled callback_query: ${callbackQuery.data}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ── ask_questions: state-machine helpers ───────────────────────────────
|
|
1005
|
+
// The flow lives in telegram-ask.js; this class owns the I/O (sending
|
|
1006
|
+
// messages, editing keyboards, re-entering the super-agent loop with the
|
|
1007
|
+
// compiled answer once the flow finishes).
|
|
1008
|
+
|
|
1009
|
+
async _renderQuestion(state) {
|
|
1010
|
+
const text = askFlow.formatQuestionText(state);
|
|
1011
|
+
const reply_markup = askFlow.buildKeyboard(state);
|
|
1012
|
+
// If we already have a message for the previous question, leave its
|
|
1013
|
+
// keyboard wiped — we draw a fresh message per question for clearer
|
|
1014
|
+
// history in the chat (the question text stays as a record).
|
|
1015
|
+
if (state.messageId) {
|
|
1016
|
+
try {
|
|
1017
|
+
await this._editKeyboard({
|
|
1018
|
+
chat_id: state.chatId,
|
|
1019
|
+
message_id: state.messageId,
|
|
1020
|
+
reply_markup: { inline_keyboard: [] },
|
|
1021
|
+
});
|
|
1022
|
+
} catch { /* best-effort */ }
|
|
1023
|
+
}
|
|
1024
|
+
const sent = await this._send({
|
|
1025
|
+
chat_id: state.chatId,
|
|
1026
|
+
text,
|
|
1027
|
+
reply_markup,
|
|
1028
|
+
parse_mode: "Markdown",
|
|
1029
|
+
});
|
|
1030
|
+
state.messageId = sent?.message_id || null;
|
|
1031
|
+
askFlow.saveState(state.chatId, state);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Kick off a brand-new ask flow after the super-agent called ask_questions.
|
|
1035
|
+
// The flow's `resume` callback captures the per-turn context (sender,
|
|
1036
|
+
// relationship, project) so when the compiled answer arrives we can run
|
|
1037
|
+
// another super-agent turn without retyping all the inputs.
|
|
1038
|
+
async _startAskFlow(ctx) {
|
|
1039
|
+
const state = askFlow.startFlow({
|
|
1040
|
+
chatId: ctx.chat_id,
|
|
1041
|
+
projectId: ctx.projectId,
|
|
1042
|
+
authorId: ctx.authorId,
|
|
1043
|
+
questions: ctx.questions,
|
|
1044
|
+
resume: async (compiled) => {
|
|
1045
|
+
await this._runResumedTurn({ ...ctx, compiled });
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
await this._renderQuestion(state);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Apply an inline-keyboard press, then react: redraw, advance, or finish.
|
|
1052
|
+
async _handleAskCallback(callbackQuery) {
|
|
1053
|
+
const chatId = callbackQuery.message?.chat?.id;
|
|
1054
|
+
if (!chatId) return;
|
|
1055
|
+
const result = askFlow.applyCallback(chatId, callbackQuery.data || "");
|
|
1056
|
+
// Ack the press regardless — keeps the spinner from hanging client-side.
|
|
1057
|
+
await this._answerCallback({ callback_query_id: callbackQuery.id });
|
|
1058
|
+
if (!result) return; // stale or unknown — adapter already ack'd.
|
|
1059
|
+
|
|
1060
|
+
if (result.action === "redraw") {
|
|
1061
|
+
// Multi-select toggle: just refresh the keyboard on the SAME message.
|
|
1062
|
+
try {
|
|
1063
|
+
await this._editKeyboard({
|
|
1064
|
+
chat_id: chatId,
|
|
1065
|
+
message_id: callbackQuery.message?.message_id,
|
|
1066
|
+
reply_markup: askFlow.buildKeyboard(result.state),
|
|
1067
|
+
});
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
this.log(`telegram[${this.channel.name}] redraw failed: ${e.message}`);
|
|
1070
|
+
}
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (result.action === "advance") {
|
|
1074
|
+
await this._renderQuestion(result.state);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (result.action === "cancel") {
|
|
1078
|
+
try {
|
|
1079
|
+
await this._editKeyboard({
|
|
1080
|
+
chat_id: chatId,
|
|
1081
|
+
message_id: callbackQuery.message?.message_id,
|
|
1082
|
+
reply_markup: { inline_keyboard: [] },
|
|
1083
|
+
});
|
|
1084
|
+
await this._send({ chat_id: chatId, text: "Pregunta cancelada." });
|
|
1085
|
+
} catch { /* best-effort */ }
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (result.action === "done") {
|
|
1089
|
+
try {
|
|
1090
|
+
await this._editKeyboard({
|
|
1091
|
+
chat_id: chatId,
|
|
1092
|
+
message_id: callbackQuery.message?.message_id,
|
|
1093
|
+
reply_markup: { inline_keyboard: [] },
|
|
1094
|
+
});
|
|
1095
|
+
} catch { /* best-effort */ }
|
|
1096
|
+
// Feed the compiled answer back as a synthetic user turn.
|
|
1097
|
+
if (typeof result.state.resume === "function") {
|
|
1098
|
+
await result.state.resume(result.compiled);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Apply a free-text user reply when there's a pending free-text question.
|
|
1104
|
+
// Returns true iff the message was consumed by the ask flow (so the normal
|
|
1105
|
+
// super-agent path should be skipped for this update).
|
|
1106
|
+
async _maybeConsumeAskTextAnswer({ chat_id, text }) {
|
|
1107
|
+
if (!chat_id || !text) return false;
|
|
1108
|
+
if (!askFlow.hasPendingFreeText(chat_id)) return false;
|
|
1109
|
+
const state = askFlow.applyTextAnswer(chat_id, text);
|
|
1110
|
+
if (!state) return false;
|
|
1111
|
+
// Advance: emit a synthetic "next" to move past this question.
|
|
1112
|
+
const next = askFlow.applyCallback(
|
|
1113
|
+
chat_id,
|
|
1114
|
+
`apx:ask:${state.correlationId}:next`,
|
|
1115
|
+
);
|
|
1116
|
+
if (!next) return true;
|
|
1117
|
+
if (next.action === "advance") {
|
|
1118
|
+
await this._renderQuestion(next.state);
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
if (next.action === "done") {
|
|
1122
|
+
if (typeof next.state.resume === "function") {
|
|
1123
|
+
await next.state.resume(next.compiled);
|
|
1124
|
+
}
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Run a follow-up super-agent turn with the compiled answers as the user
|
|
1131
|
+
// prompt. Mirrors the post-runSuperAgent reply path in _handleUpdate but
|
|
1132
|
+
// skipped of the photo/audio/reset preamble. Re-enters the ask flow if the
|
|
1133
|
+
// model decides to ask again.
|
|
1134
|
+
async _runResumedTurn(ctx) {
|
|
1135
|
+
const { chat_id, compiled, target, relationshipBlock, allowedTools, author, agentDisplay, update_id, sender, authorId } = ctx;
|
|
1136
|
+
if (!chat_id) return;
|
|
1137
|
+
// Log the synthetic user message so getRecentTelegramTurnsFromFs picks
|
|
1138
|
+
// it up on the NEXT inbound. Mirrors how a normal text reply would be
|
|
1139
|
+
// recorded.
|
|
1140
|
+
appendGlobalMessage({
|
|
1141
|
+
channel: "telegram",
|
|
1142
|
+
direction: "in",
|
|
1143
|
+
type: "user",
|
|
1144
|
+
actor_id: authorId ? String(authorId) : (author || "ask_flow"),
|
|
1145
|
+
external_id: `ask-${Date.now()}`,
|
|
1146
|
+
author: author || "user",
|
|
1147
|
+
body: compiled,
|
|
1148
|
+
meta: {
|
|
1149
|
+
chat_id,
|
|
1150
|
+
user_id: authorId || null,
|
|
1151
|
+
tg_channel: this.channel.name,
|
|
1152
|
+
ask_flow: true,
|
|
1153
|
+
},
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
const previousMessages = getRecentTelegramTurnsFromFs({
|
|
1157
|
+
chat_id,
|
|
1158
|
+
keepRecent: 40,
|
|
1159
|
+
max_age_hours: 24,
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
const stopTyping = this._startTyping(chat_id);
|
|
1163
|
+
try {
|
|
1164
|
+
const sa = await runSuperAgent({
|
|
1165
|
+
globalConfig: this.globalConfig,
|
|
1166
|
+
projects: this.projects,
|
|
1167
|
+
plugins: this.plugins,
|
|
1168
|
+
registries: this.registries,
|
|
1169
|
+
prompt: compiled,
|
|
1170
|
+
previousMessages,
|
|
1171
|
+
channel: "telegram",
|
|
1172
|
+
relationshipBlock,
|
|
1173
|
+
allowedTools,
|
|
1174
|
+
channelMeta: { channel: "telegram", chat_id, author, route_to_agent: this.channel.route_to_agent },
|
|
1175
|
+
});
|
|
1176
|
+
stopTyping();
|
|
1177
|
+
|
|
1178
|
+
// Did the model ask again? Restart the flow instead of replying.
|
|
1179
|
+
const followupAsk = askFlow.extractAskQuestionsFromTrace(sa.trace);
|
|
1180
|
+
if (followupAsk) {
|
|
1181
|
+
await this._startAskFlow({
|
|
1182
|
+
chat_id,
|
|
1183
|
+
projectId: target?.id,
|
|
1184
|
+
authorId,
|
|
1185
|
+
questions: followupAsk,
|
|
1186
|
+
author,
|
|
1187
|
+
agentDisplay,
|
|
1188
|
+
relationshipBlock,
|
|
1189
|
+
allowedTools,
|
|
1190
|
+
target,
|
|
1191
|
+
sender,
|
|
1192
|
+
update_id,
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const replyText = sa.text ? stripThinking(sa.text).trim() : "";
|
|
1198
|
+
if (replyText) {
|
|
1199
|
+
await this._send({ chat_id, text: replyText });
|
|
1200
|
+
appendGlobalMessage({
|
|
1201
|
+
channel: "telegram",
|
|
1202
|
+
direction: "out",
|
|
1203
|
+
type: "agent",
|
|
1204
|
+
actor_id: SUPERAGENT_ACTOR_ID,
|
|
1205
|
+
actor_kind: "superagent",
|
|
1206
|
+
agent_slug: SUPERAGENT_ACTOR_ID,
|
|
1207
|
+
author: sa.name || agentDisplay,
|
|
1208
|
+
body: replyText,
|
|
1209
|
+
meta: {
|
|
1210
|
+
chat_id,
|
|
1211
|
+
tg_channel: this.channel.name,
|
|
1212
|
+
in_reply_to: update_id,
|
|
1213
|
+
final: true,
|
|
1214
|
+
ask_resume: true,
|
|
1215
|
+
...(sa.usage ? { usage: sa.usage } : {}),
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
stopTyping();
|
|
1221
|
+
this.log(`telegram[${this.channel.name}] ask resume failed: ${e.message}`);
|
|
1222
|
+
try {
|
|
1223
|
+
await this._send({ chat_id, text: `⚠️ Error procesando tus respuestas (${e.message}).` });
|
|
1224
|
+
} catch { /* best-effort */ }
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
914
1228
|
// Show "typing..." indicator in the chat. Telegram clears it automatically
|
|
915
1229
|
// after 5 seconds, so call this every ~4s while a long operation is going.
|
|
916
1230
|
async _typing(chat_id) {
|
|
@@ -943,22 +1257,64 @@ class ChannelPoller {
|
|
|
943
1257
|
return () => { stopped = true; };
|
|
944
1258
|
}
|
|
945
1259
|
|
|
946
|
-
async _send({ chat_id, text }) {
|
|
1260
|
+
async _send({ chat_id, text, reply_markup, parse_mode }) {
|
|
947
1261
|
const token = resolveBotToken(this.channel);
|
|
948
1262
|
if (!token) throw new Error(`channel ${this.channel.name}: no bot_token`);
|
|
949
1263
|
const target = chat_id || resolveChatId(this.channel);
|
|
950
1264
|
if (!target) throw new Error(`channel ${this.channel.name}: no chat_id`);
|
|
951
1265
|
const url = `${API_BASE}/bot${token}/sendMessage`;
|
|
1266
|
+
const body = { chat_id: target, text };
|
|
1267
|
+
if (reply_markup) body.reply_markup = reply_markup;
|
|
1268
|
+
if (parse_mode) body.parse_mode = parse_mode;
|
|
952
1269
|
const res = await fetch(url, {
|
|
953
1270
|
method: "POST",
|
|
954
1271
|
headers: { "content-type": "application/json" },
|
|
955
|
-
body: JSON.stringify(
|
|
1272
|
+
body: JSON.stringify(body),
|
|
956
1273
|
});
|
|
957
1274
|
const json = await res.json();
|
|
958
1275
|
if (!json.ok) throw new Error(json.description || `send failed (${res.status})`);
|
|
959
1276
|
return json.result;
|
|
960
1277
|
}
|
|
961
1278
|
|
|
1279
|
+
// Replace just the inline keyboard on a previously-sent message (used to
|
|
1280
|
+
// refresh after a multi-select toggle, or to wipe buttons once the flow
|
|
1281
|
+
// has moved on). Best-effort: failures are logged but don't break the flow.
|
|
1282
|
+
async _editKeyboard({ chat_id, message_id, reply_markup }) {
|
|
1283
|
+
const token = resolveBotToken(this.channel);
|
|
1284
|
+
if (!token) return;
|
|
1285
|
+
try {
|
|
1286
|
+
const url = `${API_BASE}/bot${token}/editMessageReplyMarkup`;
|
|
1287
|
+
const body = { chat_id, message_id };
|
|
1288
|
+
if (reply_markup) body.reply_markup = reply_markup;
|
|
1289
|
+
await fetch(url, {
|
|
1290
|
+
method: "POST",
|
|
1291
|
+
headers: { "content-type": "application/json" },
|
|
1292
|
+
body: JSON.stringify(body),
|
|
1293
|
+
});
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
this.log(`telegram[${this.channel.name}] editMessageReplyMarkup failed: ${e.message}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Acknowledge a callback button press so the user's Telegram client clears
|
|
1300
|
+
// the spinner on the tapped button. Optional `text` shows a small toast.
|
|
1301
|
+
async _answerCallback({ callback_query_id, text }) {
|
|
1302
|
+
const token = resolveBotToken(this.channel);
|
|
1303
|
+
if (!token) return;
|
|
1304
|
+
try {
|
|
1305
|
+
const url = `${API_BASE}/bot${token}/answerCallbackQuery`;
|
|
1306
|
+
const body = { callback_query_id };
|
|
1307
|
+
if (text) body.text = text;
|
|
1308
|
+
await fetch(url, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: { "content-type": "application/json" },
|
|
1311
|
+
body: JSON.stringify(body),
|
|
1312
|
+
});
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
this.log(`telegram[${this.channel.name}] answerCallbackQuery failed: ${e.message}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
962
1318
|
/** Send a photo via this channel */
|
|
963
1319
|
async _sendPhoto({ chat_id, photo, caption, parse_mode }) {
|
|
964
1320
|
const token = resolveBotToken(this.channel);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { agentSkills, buildAgentSystem as buildCoreAgentSystem } from "../../../core/agent-system.js";
|
|
3
|
+
import { buildConfirmDescription } from "../../../core/confirmation/index.js";
|
|
3
4
|
|
|
4
5
|
export function projectMeta(projects, entry) {
|
|
5
6
|
const meta = projects.list().find((p) => p.id === entry.id);
|
|
@@ -79,19 +80,39 @@ export function buildAgentSystem(project, agent, opts = {}) {
|
|
|
79
80
|
return buildCoreAgentSystem(project, agent, opts);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export function createPermissionGuard(globalConfig = {}, {
|
|
83
|
+
export function createPermissionGuard(globalConfig = {}, {
|
|
84
|
+
implicitConfirmation = false,
|
|
85
|
+
requestConfirmation = null,
|
|
86
|
+
} = {}) {
|
|
83
87
|
const permissionMode = globalConfig.super_agent?.permission_mode || "automatico";
|
|
84
88
|
const allowedTools = new Set(globalConfig.super_agent?.allowed_tools || []);
|
|
85
89
|
|
|
86
|
-
|
|
90
|
+
// async so tools can `await requirePermission(...)` and the confirmation
|
|
91
|
+
// dialog resolves transparently before execution continues.
|
|
92
|
+
return async function requirePermission(tool, { dangerous = false, confirmed = false, args } = {}) {
|
|
87
93
|
const ok = confirmed || implicitConfirmation;
|
|
88
94
|
if (permissionMode === "total") return;
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
|
|
96
|
+
const blocked =
|
|
97
|
+
(permissionMode === "permiso" && !allowedTools.has(tool) && !ok) ||
|
|
98
|
+
(permissionMode === "automatico" && dangerous && !ok);
|
|
99
|
+
|
|
100
|
+
if (!blocked) return;
|
|
101
|
+
|
|
102
|
+
const description = buildConfirmDescription(tool, args || {});
|
|
103
|
+
|
|
104
|
+
if (!requestConfirmation) {
|
|
105
|
+
// No confirmation channel wired for this invocation context (e.g. routine,
|
|
106
|
+
// autonomous agent). Surface a clear message so the model can explain it.
|
|
107
|
+
throw new Error(`Action requires user confirmation: ${description}`);
|
|
91
108
|
}
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
|
|
110
|
+
const userConfirmed = await requestConfirmation(tool, args || {}, description);
|
|
111
|
+
|
|
112
|
+
if (!userConfirmed) {
|
|
113
|
+
throw new Error(`User did not confirm: ${description}`);
|
|
94
114
|
}
|
|
115
|
+
// Confirmed — fall through, tool executes normally.
|
|
95
116
|
};
|
|
96
117
|
}
|
|
97
118
|
|
|
@@ -335,6 +335,7 @@ export function makeToolHandlers(ctx) {
|
|
|
335
335
|
...ctx,
|
|
336
336
|
requirePermission: createPermissionGuard(ctx.globalConfig || {}, {
|
|
337
337
|
implicitConfirmation: !!ctx.implicitConfirmation,
|
|
338
|
+
requestConfirmation: ctx.requestConfirmation || null,
|
|
338
339
|
}),
|
|
339
340
|
};
|
|
340
341
|
return Object.fromEntries(TOOLS.map((tool) => [tool.name, tool.makeHandler(toolCtx)]));
|
|
@@ -31,8 +31,8 @@ export default {
|
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
|
-
makeHandler: ({ projects, requirePermission }) => ({ path: projectPath, name, init = true, confirmed = false }) => {
|
|
35
|
-
requirePermission("add_project", { dangerous: true, confirmed });
|
|
34
|
+
makeHandler: ({ projects, requirePermission }) => async ({ path: projectPath, name, init = true, confirmed = false }) => {
|
|
35
|
+
await requirePermission("add_project", { dangerous: true, confirmed, args: { path: projectPath } });
|
|
36
36
|
if (!projectPath) throw new Error("add_project: path required");
|
|
37
37
|
|
|
38
38
|
const abs = path.resolve(projectPath);
|
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
// Normalize a raw question entry into the canonical shape rendered by every
|
|
2
|
+
// surface (web InlineAskPanel, future desktop/telegram/CLI). The model can
|
|
3
|
+
// pass either a plain string (legacy) or a rich object with options.
|
|
4
|
+
function normalizeQuestion(q) {
|
|
5
|
+
if (typeof q === "string") {
|
|
6
|
+
return { question: q, options: [], multiSelect: false, allowText: true };
|
|
7
|
+
}
|
|
8
|
+
if (!q || typeof q !== "object") return null;
|
|
9
|
+
const text = typeof q.question === "string" ? q.question : "";
|
|
10
|
+
if (!text) return null;
|
|
11
|
+
const options = Array.isArray(q.options)
|
|
12
|
+
? q.options
|
|
13
|
+
.map((o) => {
|
|
14
|
+
if (typeof o === "string") return { label: o };
|
|
15
|
+
if (o && typeof o === "object" && typeof o.label === "string") {
|
|
16
|
+
return {
|
|
17
|
+
label: o.label,
|
|
18
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
})
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
: [];
|
|
25
|
+
return {
|
|
26
|
+
question: text,
|
|
27
|
+
header: typeof q.header === "string" ? q.header : undefined,
|
|
28
|
+
options,
|
|
29
|
+
multiSelect: q.multiSelect === true,
|
|
30
|
+
// Free-text fallback: on by default. Set false explicitly to force a
|
|
31
|
+
// pick from `options`. Has no effect when options is empty.
|
|
32
|
+
allowText: q.allowText === false ? false : true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
1
36
|
export default {
|
|
2
37
|
name: "ask_questions",
|
|
3
38
|
schema: {
|
|
@@ -7,26 +42,74 @@ export default {
|
|
|
7
42
|
type: "function",
|
|
8
43
|
function: {
|
|
9
44
|
name: "ask_questions",
|
|
10
|
-
description:
|
|
45
|
+
description:
|
|
46
|
+
"Ask the user one or more questions when you genuinely need input to proceed. " +
|
|
47
|
+
"Each question can be free-text OR a selectable list of options (single- or multi-select). " +
|
|
48
|
+
"Call this ONCE per turn — the loop hands control back to the user immediately.",
|
|
11
49
|
parameters: {
|
|
12
50
|
type: "object",
|
|
13
51
|
properties: {
|
|
14
52
|
questions: {
|
|
15
53
|
type: "array",
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
54
|
+
description:
|
|
55
|
+
"Questions for the user. Each item is an object with the question text " +
|
|
56
|
+
"and optional `options` for selectable answers (single- or multi-select). " +
|
|
57
|
+
"Leave `options` empty for free-text questions.",
|
|
58
|
+
items: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
question: { type: "string", description: "The question text." },
|
|
62
|
+
header: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Optional short chip (≤12 chars) shown next to the question.",
|
|
65
|
+
},
|
|
66
|
+
options: {
|
|
67
|
+
type: "array",
|
|
68
|
+
description:
|
|
69
|
+
"Selectable answers. Omit or leave empty for a free-text question. " +
|
|
70
|
+
"Prefer 2–4 distinct, mutually-exclusive choices.",
|
|
71
|
+
items: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
label: { type: "string", description: "Visible label." },
|
|
75
|
+
description: {
|
|
76
|
+
type: "string",
|
|
77
|
+
description: "Optional explanation shown under the label.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ["label"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
multiSelect: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
description:
|
|
86
|
+
"true → user can pick several options (checkboxes). Default false (single-select).",
|
|
87
|
+
},
|
|
88
|
+
allowText: {
|
|
89
|
+
type: "boolean",
|
|
90
|
+
description:
|
|
91
|
+
"When options is non-empty, also show an 'Otro' free-text field. Default true.",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
required: ["question"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
19
97
|
},
|
|
20
|
-
required: ["questions"]
|
|
21
|
-
}
|
|
22
|
-
}
|
|
98
|
+
required: ["questions"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
23
101
|
},
|
|
24
102
|
makeHandler: () => async ({ questions }) => {
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
103
|
+
// Normalize so downstream code (UI panels, persistence) always sees the
|
|
104
|
+
// canonical shape. The agent loop treats this tool as turn-ending
|
|
105
|
+
// (see TURN_ENDING_TOOLS in src/core/agent/constants.js).
|
|
106
|
+
const normalized = Array.isArray(questions)
|
|
107
|
+
? questions.map(normalizeQuestion).filter(Boolean)
|
|
108
|
+
: [];
|
|
109
|
+
return {
|
|
110
|
+
status: "Questions presented to user. Waiting for input.",
|
|
111
|
+
count: normalized.length,
|
|
112
|
+
questions: normalized,
|
|
30
113
|
};
|
|
31
|
-
}
|
|
114
|
+
},
|
|
32
115
|
};
|
|
@@ -21,7 +21,7 @@ export default {
|
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
23
|
makeHandler: ({ projects, registries, requirePermission }) => async ({ project, mcp, tool, args = {}, confirmed = false }) => {
|
|
24
|
-
requirePermission("call_mcp", { dangerous: true, confirmed });
|
|
24
|
+
await requirePermission("call_mcp", { dangerous: true, confirmed, args: { mcp, tool } });
|
|
25
25
|
const p = resolveProject(projects, project);
|
|
26
26
|
if (!registries) throw new Error("MCP registry unavailable");
|
|
27
27
|
const registry = registries.for ? registries.for(p) : registries.ensure(p);
|
|
@@ -174,7 +174,7 @@ export default {
|
|
|
174
174
|
},
|
|
175
175
|
},
|
|
176
176
|
makeHandler: ({ projects, requirePermission }) => async ({ project, agent: slug, runtime, prompt, resume_session_id = null, timeout_s = 300, confirmed = false }) => {
|
|
177
|
-
requirePermission("call_runtime", { dangerous: true, confirmed });
|
|
177
|
+
await requirePermission("call_runtime", { dangerous: true, confirmed, args: { runtime } });
|
|
178
178
|
|
|
179
179
|
const p = slug ? resolveProjectForAgent(projects, project, slug) : resolveProject(projects, project);
|
|
180
180
|
const agent = slug ? readAgents(p.path).find((a) => a.slug === slug) : null;
|
|
@@ -22,8 +22,8 @@ export default {
|
|
|
22
22
|
},
|
|
23
23
|
},
|
|
24
24
|
},
|
|
25
|
-
makeHandler: ({ projects, requirePermission }) => ({ project, path, search, replace, all = false, confirmed = false }) => {
|
|
26
|
-
requirePermission("edit_file", { dangerous: true, confirmed });
|
|
25
|
+
makeHandler: ({ projects, requirePermission }) => async ({ project, path, search, replace, all = false, confirmed = false }) => {
|
|
26
|
+
await requirePermission("edit_file", { dangerous: true, confirmed, args: { path } });
|
|
27
27
|
if (!path) throw new Error("edit_file: path required");
|
|
28
28
|
if (!search) throw new Error("edit_file: search required");
|
|
29
29
|
|
|
@@ -23,8 +23,8 @@ export default {
|
|
|
23
23
|
},
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
|
-
makeHandler: ({ projects, requirePermission }) => ({ project, agent: slug, confirmed = false }) => {
|
|
27
|
-
requirePermission("import_agent", { dangerous: true, confirmed });
|
|
26
|
+
makeHandler: ({ projects, requirePermission }) => async ({ project, agent: slug, confirmed = false }) => {
|
|
27
|
+
await requirePermission("import_agent", { dangerous: true, confirmed, args: { agent: slug } });
|
|
28
28
|
if (!slug) throw new Error("import_agent: agent required");
|
|
29
29
|
|
|
30
30
|
const vaultPath = path.join(VAULT_DIR, `${slug}.md`);
|