appback-remoteagent 0.23.3 → 0.23.5
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 +2 -1
- package/dist/bot.js +23 -167
- package/dist/services/bridge-service.js +5 -0
- package/docs/RESPONSE_ROUTING.md +20 -0
- package/package.json +1 -1
- package/scripts/selftest-model-fallback.mjs +9 -0
- package/scripts/selftest-telegram-update.mjs +74 -25
package/README.md
CHANGED
|
@@ -106,11 +106,12 @@ Current command surface implemented in `src/bot.ts`:
|
|
|
106
106
|
| `/switch <session>` | Rebinds this chat to an existing RemoteAgent session |
|
|
107
107
|
| `/status` | Shows current session, workspace, provider, and sandbox state |
|
|
108
108
|
| `/model [name]` | Lists selectable provider models or changes the current session model. New Codex sessions default to `gpt-6-astra`. Use `/model gpt-6-astra` for an existing session. |
|
|
109
|
+
| `/model restore` | Clears temporary Codex fallback after a usage reset or credit purchase. Server-wide; next execution uses each session's configured model. Running work is preserved. Also available through the model menu's restore button. |
|
|
109
110
|
| `/option reasoning [low\|medium\|high\|xhigh\|max]` | Shows or changes Astra reasoning for all bots on this server. Defaults to `medium`; persisted as `CODEX_REASONING_EFFORT`. Applies on the next execution without restarting. Running replies retain their actual execution effort in the header. |
|
|
110
111
|
| `/sandbox [codex <mode>]` | Lists Codex sandbox choices or changes the current session sandbox |
|
|
111
112
|
| `/option retry <count>` | Sets the automatic continuation turn limit and persists it to `~/.remoteagent/.env` |
|
|
112
113
|
| `/option timeout <seconds>` | Sets the provider execution timeout and persists it to `~/.remoteagent/.env` |
|
|
113
|
-
| `/option intent <count>` |
|
|
114
|
+
| `/option intent <count>` | Retired compatibility command; explains that untagged replies are delivered without content-based retries. |
|
|
114
115
|
| `/state` | Shows the session ledger that is injected as provider context |
|
|
115
116
|
| `/state clear` | Clears the current session ledger without deleting the session |
|
|
116
117
|
| `/state note <text>` | Adds an operator note to the session ledger |
|
package/dist/bot.js
CHANGED
|
@@ -27,12 +27,12 @@ const HELP_TEXT = [
|
|
|
27
27
|
"/batch start|send|cancel|status",
|
|
28
28
|
"/attach codex <thread_id>",
|
|
29
29
|
"/attach claude <session_id>",
|
|
30
|
-
"/model [name]",
|
|
30
|
+
"/model [name|restore]",
|
|
31
31
|
"/queue [remove <id>|del]",
|
|
32
32
|
"/stop",
|
|
33
33
|
"/sandbox codex <read-only|workspace-write|danger-full-access>",
|
|
34
34
|
"/status",
|
|
35
|
-
"/option [retry <count>|timeout <seconds>|
|
|
35
|
+
"/option [retry <count>|timeout <seconds>|reasoning <low|medium|high|xhigh|max>|command-menu <on|off|refresh>]",
|
|
36
36
|
"/state [clear|note <text>]",
|
|
37
37
|
"/artifacts list|cleanup <days>",
|
|
38
38
|
"/cleanup",
|
|
@@ -547,16 +547,22 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
547
547
|
const { args, rest } = parseCommand(ctx.message?.text, 1);
|
|
548
548
|
const model = args[0]?.trim();
|
|
549
549
|
if (rest?.trim()) {
|
|
550
|
-
await reply(ctx, "Usage: `/model
|
|
550
|
+
await reply(ctx, "Usage: `/model`, `/model <name|number>`, or `/model restore`", {
|
|
551
551
|
parse_mode: "Markdown",
|
|
552
552
|
});
|
|
553
553
|
return;
|
|
554
554
|
}
|
|
555
|
+
if (model?.toLowerCase() === "restore") {
|
|
556
|
+
await ensureOwnerControlAccess(ctx);
|
|
557
|
+
await reply(ctx, await bridge.restoreCodexModel());
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
555
560
|
if (!model) {
|
|
556
561
|
const selection = await bridge.getModelSelection(botId, chatId);
|
|
557
562
|
const rows = selection.presets.map((preset) => [
|
|
558
563
|
actionButton(ctx, `${preset === selection.currentModel ? "✓ " : ""}${preset}`, { kind: "model.set", model: preset }),
|
|
559
564
|
]);
|
|
565
|
+
rows.push([actionButton(ctx, "원래 모델 복귀", { kind: "model.restore" })]);
|
|
560
566
|
await reply(ctx, await bridge.formatModelSelection(botId, chatId), {
|
|
561
567
|
parse_mode: "Markdown",
|
|
562
568
|
...(keyboardOptions(rows) ?? {}),
|
|
@@ -643,7 +649,6 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
643
649
|
actionButton(ctx, "Timeout", { kind: "option.show", option: "timeout" }),
|
|
644
650
|
],
|
|
645
651
|
[
|
|
646
|
-
actionButton(ctx, "Intent", { kind: "option.show", option: "intent" }),
|
|
647
652
|
actionButton(ctx, "Reasoning", { kind: "option.show", option: "reasoning" }),
|
|
648
653
|
actionButton(ctx, "Command menu", { kind: "option.show", option: "command-menu" }),
|
|
649
654
|
],
|
|
@@ -651,9 +656,11 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
651
656
|
return;
|
|
652
657
|
}
|
|
653
658
|
if (option !== "retry" && option !== "timeout" && option !== "intent" && option !== "reasoning" && option !== "command-menu") {
|
|
654
|
-
await reply(ctx,
|
|
655
|
-
|
|
656
|
-
|
|
659
|
+
await reply(ctx, formatRuntimeOptions());
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (option === "intent") {
|
|
663
|
+
await reply(ctx, "The intent option has been retired. Untagged replies are delivered without content-based retries.");
|
|
657
664
|
return;
|
|
658
665
|
}
|
|
659
666
|
if (!value) {
|
|
@@ -702,9 +709,7 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
702
709
|
if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== value.trim()) {
|
|
703
710
|
await reply(ctx, option === "retry"
|
|
704
711
|
? "Invalid retry count. Use `0` or a positive integer, for example `/option retry 6`."
|
|
705
|
-
: option
|
|
706
|
-
? "Invalid intent retry count. Use `0` or a positive integer, for example `/option intent 4`."
|
|
707
|
-
: "Invalid timeout. Use seconds as a positive integer, for example `/option timeout 600`.", {
|
|
712
|
+
: "Invalid timeout. Use seconds as a positive integer, for example `/option timeout 600`.", {
|
|
708
713
|
parse_mode: "Markdown",
|
|
709
714
|
});
|
|
710
715
|
return;
|
|
@@ -716,13 +721,6 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
716
721
|
await reply(ctx, `Set automatic continuation retry limit to ${formatRetryLimit(parsed)}.\n\nSaved: TELEGRAM_AUTO_PROGRESS_MAX_TURNS=${parsed}`);
|
|
717
722
|
return;
|
|
718
723
|
}
|
|
719
|
-
if (option === "intent") {
|
|
720
|
-
config.telegramUntaggedIntentRetries = parsed;
|
|
721
|
-
await upsertInstalledEnvValue("TELEGRAM_UNTAGGED_INTENT_RETRIES", String(parsed));
|
|
722
|
-
await bridge.logSystem(botId, chatId, `Runtime option TELEGRAM_UNTAGGED_INTENT_RETRIES set to ${parsed}.`);
|
|
723
|
-
await reply(ctx, `Set untagged intent retry limit to ${formatRetryLimit(parsed)}.\n\nSaved: TELEGRAM_UNTAGGED_INTENT_RETRIES=${parsed}`);
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
724
|
if (parsed < 10) {
|
|
727
725
|
await reply(ctx, "Invalid timeout. Use at least 10 seconds, for example `/option timeout 600`.", {
|
|
728
726
|
parse_mode: "Markdown",
|
|
@@ -1151,6 +1149,11 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
1151
1149
|
await reply(ctx, await setChatModel(ctx, action.model));
|
|
1152
1150
|
return;
|
|
1153
1151
|
}
|
|
1152
|
+
if (action.kind === "model.restore") {
|
|
1153
|
+
await ensureOwnerControlAccess(ctx);
|
|
1154
|
+
await reply(ctx, await bridge.restoreCodexModel());
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1154
1157
|
if (action.kind === "macro.run") {
|
|
1155
1158
|
const result = await runMacro(ctx, action.alias);
|
|
1156
1159
|
if (result) {
|
|
@@ -1628,11 +1631,8 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1628
1631
|
const emptyResponseRetries = config.telegramEmptyResponseRetries;
|
|
1629
1632
|
const retryableErrorRetries = config.telegramRetryableErrorRetries;
|
|
1630
1633
|
const retryableErrorDelayMs = config.telegramRetryableErrorDelayMs;
|
|
1631
|
-
const untaggedIntentRetries = config.telegramUntaggedIntentRetries;
|
|
1632
1634
|
let emptyResponseRetryCount = 0;
|
|
1633
1635
|
let retryableErrorCount = 0;
|
|
1634
|
-
let untaggedIntentRetryCount = 0;
|
|
1635
|
-
let missingEvidenceRetryCount = 0;
|
|
1636
1636
|
let deliveredProgressCount = 0;
|
|
1637
1637
|
let providerCompleted = false;
|
|
1638
1638
|
const streamedProgressKeys = new Set();
|
|
@@ -1694,23 +1694,12 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1694
1694
|
emptyResponseRetryCount = 0;
|
|
1695
1695
|
retryableErrorCount = 0;
|
|
1696
1696
|
if (parsed.kind === "progress") {
|
|
1697
|
-
untaggedIntentRetryCount = 0;
|
|
1698
|
-
missingEvidenceRetryCount = 0;
|
|
1699
1697
|
const key = progressDeliveryKey(parsed.chunks);
|
|
1700
1698
|
if (!streamedProgressKeys.has(key)) {
|
|
1701
1699
|
streamedProgressKeys.add(key);
|
|
1702
1700
|
deliveredProgressCount += 1;
|
|
1703
1701
|
if (currentSession) {
|
|
1704
|
-
|
|
1705
|
-
if (progress.repeated) {
|
|
1706
|
-
const repeatedMessage = [
|
|
1707
|
-
"Repeated progress detected. The same work pattern has appeared 3 or more times.",
|
|
1708
|
-
"Automatic continuation stopped so the task can be inspected instead of looping.",
|
|
1709
|
-
].join("\n");
|
|
1710
|
-
await bridge.logSystem(botId, chatId, repeatedMessage);
|
|
1711
|
-
autoContinue.clear(botId, chatId, sessionId);
|
|
1712
|
-
return [repeatedMessage];
|
|
1713
|
-
}
|
|
1702
|
+
await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
|
|
1714
1703
|
}
|
|
1715
1704
|
await ensureStillBound(`${turnLabel} progress delivery`);
|
|
1716
1705
|
await helpers.reportProgress(parsed.chunks);
|
|
@@ -1725,26 +1714,6 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1725
1714
|
continue;
|
|
1726
1715
|
}
|
|
1727
1716
|
if (parsed.kind === "result") {
|
|
1728
|
-
untaggedIntentRetryCount = 0;
|
|
1729
|
-
const resultText = parsed.chunks.join("\n");
|
|
1730
|
-
const evidenceIssue = classifyMissingResultEvidence(resultText);
|
|
1731
|
-
if (evidenceIssue && missingEvidenceRetryCount < 1) {
|
|
1732
|
-
missingEvidenceRetryCount += 1;
|
|
1733
|
-
const retryMessage = `${turnLabel} returned a result without required evidence: ${evidenceIssue}`;
|
|
1734
|
-
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1735
|
-
prompt = appendManagedContext(formatMissingEvidenceRetryPrompt(resultText, evidenceIssue), managedContext);
|
|
1736
|
-
continue;
|
|
1737
|
-
}
|
|
1738
|
-
if (evidenceIssue) {
|
|
1739
|
-
const blockedMessage = [
|
|
1740
|
-
"Provider reported a completed result without concrete evidence after a retry.",
|
|
1741
|
-
`Reason: ${evidenceIssue}`,
|
|
1742
|
-
"Automatic continuation stopped so the work is not accepted on an unsupported claim.",
|
|
1743
|
-
].join("\n");
|
|
1744
|
-
await bridge.logSystem(botId, chatId, blockedMessage);
|
|
1745
|
-
autoContinue.clear(botId, chatId, sessionId);
|
|
1746
|
-
return [blockedMessage];
|
|
1747
|
-
}
|
|
1748
1717
|
await ensureStillBound(`${turnLabel} final delivery`);
|
|
1749
1718
|
if (currentSession) {
|
|
1750
1719
|
await memoryService.completeTask(currentSession.session, parsed.chunks.join("\n"));
|
|
@@ -1754,19 +1723,10 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1754
1723
|
return parsed.chunks;
|
|
1755
1724
|
}
|
|
1756
1725
|
if (parsed.kind === "blocked") {
|
|
1757
|
-
untaggedIntentRetryCount = 0;
|
|
1758
|
-
missingEvidenceRetryCount = 0;
|
|
1759
1726
|
await ensureStillBound(`${turnLabel} final delivery`);
|
|
1760
1727
|
autoContinue.clear(botId, chatId, sessionId);
|
|
1761
1728
|
return parsed.chunks;
|
|
1762
1729
|
}
|
|
1763
|
-
if (looksLikeUntaggedIntentOnlyResponse(parsed.chunks.join("\n")) && untaggedIntentRetryCount < untaggedIntentRetries) {
|
|
1764
|
-
untaggedIntentRetryCount += 1;
|
|
1765
|
-
const retryMessage = `${turnLabel} returned an untagged intent-only response; asking provider to do concrete work before replying.`;
|
|
1766
|
-
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1767
|
-
prompt = appendManagedContext(formatUntaggedIntentRetryPrompt(parsed.chunks.join("\n")), managedContext);
|
|
1768
|
-
continue;
|
|
1769
|
-
}
|
|
1770
1730
|
await bridge.logSystem(botId, chatId, `${turnLabel} returned an untagged response; treating it as final output.`);
|
|
1771
1731
|
autoContinue.clear(botId, chatId, sessionId);
|
|
1772
1732
|
return parsed.chunks;
|
|
@@ -1955,11 +1915,8 @@ function parseReportResponses(formattedBlocks, transform) {
|
|
|
1955
1915
|
const header = lines.slice(0, reportLineIndex).join("\n").trim();
|
|
1956
1916
|
const reportLine = lines[reportLineIndex].trim();
|
|
1957
1917
|
const match = /^REPORT:(progress|result|blocked)$/i.exec(reportLine);
|
|
1958
|
-
|
|
1918
|
+
const kind = match?.[1]?.toLowerCase() ?? "unknown";
|
|
1959
1919
|
const body = lines.slice(reportLineIndex + 1).join("\n").trim();
|
|
1960
|
-
if ((kind === "progress" || kind === "result") && looksLikeBlockedBody(body)) {
|
|
1961
|
-
kind = "blocked";
|
|
1962
|
-
}
|
|
1963
1920
|
return {
|
|
1964
1921
|
kind,
|
|
1965
1922
|
text: body ? [header, body].filter(Boolean).join("\n") : "",
|
|
@@ -2055,107 +2012,9 @@ function escapeTelegramHtml(value) {
|
|
|
2055
2012
|
function escapeTelegramHtmlAttribute(value) {
|
|
2056
2013
|
return escapeTelegramHtml(value).replace(/"/g, """);
|
|
2057
2014
|
}
|
|
2058
|
-
function looksLikeUntaggedIntentOnlyResponse(text) {
|
|
2059
|
-
const normalized = text.trim();
|
|
2060
|
-
if (!normalized) {
|
|
2061
|
-
return false;
|
|
2062
|
-
}
|
|
2063
|
-
const hasConcreteEvidence = [
|
|
2064
|
-
/REPORT:/i,
|
|
2065
|
-
/(완료|통과|실패|확인 결과|검증 결과|원인|근거|수정했습니다|배포했습니다|커밋|푸시)/,
|
|
2066
|
-
/\b(git status|git diff|npm run|node --check|docker|journalctl|grep|rg)\b/i,
|
|
2067
|
-
/`[^`]+`/,
|
|
2068
|
-
/:\d{1,5}\b/,
|
|
2069
|
-
].some((pattern) => pattern.test(normalized));
|
|
2070
|
-
if (hasConcreteEvidence) {
|
|
2071
|
-
return false;
|
|
2072
|
-
}
|
|
2073
|
-
return [
|
|
2074
|
-
/(하겠습니다|진행하겠습니다|확인하겠습니다|수정하겠습니다|검증하겠습니다|대조하겠습니다|보겠습니다)/,
|
|
2075
|
-
/(진행해서|확인해서|수정해서|검증해서).*(하겠습니다|진행하겠습니다)/,
|
|
2076
|
-
/\b(I will|I'll|I am going to|going to|will continue|will check|will verify)\b/i,
|
|
2077
|
-
].some((pattern) => pattern.test(normalized));
|
|
2078
|
-
}
|
|
2079
|
-
function formatUntaggedIntentRetryPrompt(lastResponse) {
|
|
2080
|
-
return [
|
|
2081
|
-
"The previous response did not follow the REPORT protocol and only stated intent without concrete evidence.",
|
|
2082
|
-
"Do not repeat the plan or say what you will do.",
|
|
2083
|
-
"Do concrete work now before replying again.",
|
|
2084
|
-
"Reply with exactly one first line: REPORT:progress, REPORT:result, or REPORT:blocked.",
|
|
2085
|
-
"If you cannot continue, use REPORT:blocked and state the exact blocker.",
|
|
2086
|
-
"",
|
|
2087
|
-
"Previous invalid response:",
|
|
2088
|
-
lastResponse.trim(),
|
|
2089
|
-
].join("\n");
|
|
2090
|
-
}
|
|
2091
|
-
function classifyMissingResultEvidence(text) {
|
|
2092
|
-
const normalized = text.trim();
|
|
2093
|
-
if (!normalized) {
|
|
2094
|
-
return undefined;
|
|
2095
|
-
}
|
|
2096
|
-
if (!looksLikeCompletedWorkClaim(normalized)) {
|
|
2097
|
-
return undefined;
|
|
2098
|
-
}
|
|
2099
|
-
if (hasConcreteResultEvidence(normalized)) {
|
|
2100
|
-
return undefined;
|
|
2101
|
-
}
|
|
2102
|
-
return "REPORT:result claims completed work but does not include concrete evidence.";
|
|
2103
|
-
}
|
|
2104
|
-
function looksLikeCompletedWorkClaim(text) {
|
|
2105
|
-
return [
|
|
2106
|
-
/(수정|반영|배포|커밋|푸시|전송|생성|삭제|추가|적용|구현|저장|업데이트|등록|제거|정리|마이그레이션|검증|테스트|빌드).{0,24}(완료|했습니다|됐습니다|성공|통과)/,
|
|
2107
|
-
/(완료했습니다|완료됐습니다|끝났습니다|처리했습니다)/,
|
|
2108
|
-
/\b(fixed|implemented|deployed|committed|pushed|sent|created|deleted|updated|added|removed|migrated|verified|passed|completed|built)\b/i,
|
|
2109
|
-
].some((pattern) => pattern.test(text));
|
|
2110
|
-
}
|
|
2111
|
-
function hasConcreteResultEvidence(text) {
|
|
2112
|
-
return [
|
|
2113
|
-
/```/,
|
|
2114
|
-
/`[^`]+`/,
|
|
2115
|
-
/\b[0-9a-f]{7,40}\b/i,
|
|
2116
|
-
/sha256:[0-9a-f]{20,}/i,
|
|
2117
|
-
/\b(HTTP\s+\d{3}|exit\s+\d+|active|passed|failed)\b/i,
|
|
2118
|
-
/\b(npm run|git status|git diff|node --check|docker|journalctl|curl|psql|grep|rg|bash)\b/i,
|
|
2119
|
-
/\/[A-Za-z0-9._/-]{3,}/,
|
|
2120
|
-
/\b[A-Za-z0-9._/-]+\.(?:js|ts|tsx|jsx|sql|md|json|yml|yaml|sh|py|css|html|txt|log)\b/,
|
|
2121
|
-
/:\d{1,5}\b/,
|
|
2122
|
-
/(근거|검증|변경 파일|커밋|푸시|배포|로그|명령|출력|파일|라인|경로|상태)\s*:/,
|
|
2123
|
-
].some((pattern) => pattern.test(text));
|
|
2124
|
-
}
|
|
2125
|
-
function formatMissingEvidenceRetryPrompt(lastResponse, issue) {
|
|
2126
|
-
return [
|
|
2127
|
-
"The previous REPORT:result was not accepted by RemoteAgent.",
|
|
2128
|
-
issue,
|
|
2129
|
-
"RemoteAgent does not inspect code or decide whether the work is correct.",
|
|
2130
|
-
"You, the provider, must either provide concrete evidence for the completed work or change the reply to REPORT:progress or REPORT:blocked.",
|
|
2131
|
-
"Do not repeat a bare completion claim.",
|
|
2132
|
-
"Reply with exactly one first line: REPORT:progress, REPORT:result, or REPORT:blocked.",
|
|
2133
|
-
"",
|
|
2134
|
-
"Accepted evidence examples: file paths, line references, commands and outputs, log paths, commit IDs, image digests, deployment status, or explicit verification output.",
|
|
2135
|
-
"",
|
|
2136
|
-
"Previous unsupported result:",
|
|
2137
|
-
lastResponse.trim(),
|
|
2138
|
-
].join("\n");
|
|
2139
|
-
}
|
|
2140
2015
|
function isEmptyResponseError(message) {
|
|
2141
2016
|
return /empty response|failed without any output|without stdout\/stderr/i.test(message);
|
|
2142
2017
|
}
|
|
2143
|
-
function looksLikeBlockedBody(text) {
|
|
2144
|
-
if (!text.trim()) {
|
|
2145
|
-
return false;
|
|
2146
|
-
}
|
|
2147
|
-
const blockedPatterns = [
|
|
2148
|
-
/\b(sudo|usermod|setfacl|chmod|chown|relogin|re-login|new login session)\b/i,
|
|
2149
|
-
/\b(waiting on|need you to|you need to|please run|please do|manual step|admin step|external fix)\b/i,
|
|
2150
|
-
/\b(permission denied|permission change|ssh access|api key|login required|authentication required)\b/i,
|
|
2151
|
-
/적용되면.*(다시|이어서|계속)/i,
|
|
2152
|
-
/해주시면.*(다시|이어서|계속)/i,
|
|
2153
|
-
/권한.*(필요|없)/i,
|
|
2154
|
-
/로그인 세션.*필요/i,
|
|
2155
|
-
/관리자.*조치/i,
|
|
2156
|
-
];
|
|
2157
|
-
return blockedPatterns.some((pattern) => pattern.test(text));
|
|
2158
|
-
}
|
|
2159
2018
|
function classifyRetryableProviderIssue(message, retryAfterMs) {
|
|
2160
2019
|
if (/selected model is at capacity/i.test(message)) {
|
|
2161
2020
|
return { kind: "capacity", retryAfterMs };
|
|
@@ -2520,19 +2379,16 @@ function formatRuntimeOptions() {
|
|
|
2520
2379
|
"Runtime options",
|
|
2521
2380
|
`- retry: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)} (TELEGRAM_AUTO_PROGRESS_MAX_TURNS)`,
|
|
2522
2381
|
`- timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)} (COMMAND_TIMEOUT_MS)`,
|
|
2523
|
-
`- intent: ${formatRetryLimit(config.telegramUntaggedIntentRetries)} (TELEGRAM_UNTAGGED_INTENT_RETRIES)`,
|
|
2524
2382
|
`- reasoning: ${getAstraReasoning()} (Astra, server-wide, CODEX_REASONING_EFFORT)`,
|
|
2525
2383
|
`- command-menu: ${config.telegramCommandMenuEnabled ? "on" : "off"} (TELEGRAM_COMMAND_MENU_ENABLED)`,
|
|
2526
2384
|
"",
|
|
2527
2385
|
"Usage:",
|
|
2528
2386
|
"/option retry <count>",
|
|
2529
2387
|
"/option timeout <seconds>",
|
|
2530
|
-
"/option intent <count>",
|
|
2531
2388
|
"/option reasoning <low|medium|high|xhigh|max>",
|
|
2532
2389
|
"/option command-menu <on|off|refresh>",
|
|
2533
2390
|
"",
|
|
2534
2391
|
"`retry 0` disables the automatic continuation limit.",
|
|
2535
|
-
"`intent 0` disables untagged intent-only response retries.",
|
|
2536
2392
|
"`command-menu refresh` reapplies Telegram slash-command autocomplete without changing the saved option.",
|
|
2537
2393
|
].join("\n");
|
|
2538
2394
|
}
|
|
@@ -2544,7 +2400,7 @@ function formatRuntimeOptionDetail(option) {
|
|
|
2544
2400
|
return `Current automatic continuation retry limit: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)}\n\nUsage: \`/option retry <count>\``;
|
|
2545
2401
|
}
|
|
2546
2402
|
if (option === "intent") {
|
|
2547
|
-
return
|
|
2403
|
+
return "The intent option has been retired. Untagged replies are delivered without content-based retries.";
|
|
2548
2404
|
}
|
|
2549
2405
|
if (option === "command-menu") {
|
|
2550
2406
|
return `Current Telegram command menu: ${config.telegramCommandMenuEnabled ? "on" : "off"}\n\nUsage: \`/option command-menu on\`, \`/option command-menu off\`, or \`/option command-menu refresh\``;
|
|
@@ -195,12 +195,17 @@ export class BridgeService {
|
|
|
195
195
|
...presets.map((item, index) => ` ${index + 1}. ${item}`),
|
|
196
196
|
"",
|
|
197
197
|
"Use `/model <name>` or `/model <number>` to change it.",
|
|
198
|
+
"Use `/model restore` to clear temporary Codex fallback for all bots on this server. The next execution uses each session's configured model.",
|
|
198
199
|
];
|
|
199
200
|
if (presets.length === 0) {
|
|
200
201
|
lines.splice(3, 1, "availablePresets: none");
|
|
201
202
|
}
|
|
202
203
|
return lines.join("\n");
|
|
203
204
|
}
|
|
205
|
+
async restoreCodexModel() {
|
|
206
|
+
await this.codexUsageFallback.clear();
|
|
207
|
+
return "이 서버의 Codex 임시 전환 상태를 해제했습니다. 다음 실행부터 각 세션에 설정된 원래 모델을 사용합니다.\n진행 중인 작업과 세션의 모델 설정은 유지됩니다. 실제 사용 한도가 남아 있으면 다시 임시 전환될 수 있습니다.";
|
|
208
|
+
}
|
|
204
209
|
async getModelSelection(botId, chatId) {
|
|
205
210
|
const chatSession = await this.requireChat(botId, chatId);
|
|
206
211
|
const provider = chatSession.session.mode;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Provider response routing
|
|
2
|
+
|
|
3
|
+
RemoteAgent uses the explicit first-line REPORT marker to route provider replies.
|
|
4
|
+
|
|
5
|
+
- `REPORT:progress`: forward progress and continue within the configured automatic continuation limit.
|
|
6
|
+
- `REPORT:result`: forward the final reply and end the turn.
|
|
7
|
+
- `REPORT:blocked`: forward the reply and end the turn.
|
|
8
|
+
- Missing marker: forward the reply as final output.
|
|
9
|
+
|
|
10
|
+
The response body is not used to decide whether work happened, whether evidence is adequate, or whether the provider needs permission. Evidence remains a provider reporting instruction, not a delivery gate. Recording a final reply does not certify project completion.
|
|
11
|
+
|
|
12
|
+
Repeated progress text is deduplicated for delivery. It does not override the explicit status. The configured continuation limit, user stop requests, and session binding checks control further executions.
|
|
13
|
+
|
|
14
|
+
The retired `/option intent` command returns a compatibility notice. Existing `TELEGRAM_UNTAGGED_INTENT_RETRIES` values no longer trigger executions.
|
|
15
|
+
|
|
16
|
+
## Verification
|
|
17
|
+
|
|
18
|
+
Run `npm run selftest:telegram` to exercise S091 requirement corrections and negations, final replies without evidence, untagged replies, explicit progress and blocked states, queued instruction removal, timeout handling, and stopping an active progress turn.
|
|
19
|
+
|
|
20
|
+
The S091 incident was caused by body regexes matching both "정리했습니다" and "완료 보고가 아닙니다" as completion claims. Those classifiers and their corrective retry prompts have been removed rather than expanded with word exceptions.
|
package/package.json
CHANGED
|
@@ -121,6 +121,14 @@ try {
|
|
|
121
121
|
);
|
|
122
122
|
assert.deepEqual(calls.map((call) => call.model), [primaryModel, CODEX_USAGE_FALLBACK_MODEL]);
|
|
123
123
|
|
|
124
|
+
await bridge.restoreCodexModel();
|
|
125
|
+
await assert.rejects(fs.stat(fallbackStatePath), { code: "ENOENT" });
|
|
126
|
+
failPrimaryWithUsageLimit = false;
|
|
127
|
+
calls.length = 0;
|
|
128
|
+
await bridge.routeMessage("test-bot", "test-chat", "after coupon reset");
|
|
129
|
+
assert.deepEqual(calls.map(call => call.model), [primaryModel]);
|
|
130
|
+
assert.equal((await store.getChatSession("test-bot", "test-chat"))?.session.sessionId, originalSessionId);
|
|
131
|
+
|
|
124
132
|
console.log(JSON.stringify({
|
|
125
133
|
ok: true,
|
|
126
134
|
detectedExactUsageError: true,
|
|
@@ -130,6 +138,7 @@ try {
|
|
|
130
138
|
fallbackSharedAcrossSessions: true,
|
|
131
139
|
primaryRestoredAfterSuccessfulProbe: true,
|
|
132
140
|
fallbackAttemptLimit: 1,
|
|
141
|
+
manualRestoreUsesPrimary: true,
|
|
133
142
|
}, null, 2));
|
|
134
143
|
} finally {
|
|
135
144
|
await fs.rm(root, { recursive: true, force: true });
|
|
@@ -109,6 +109,8 @@ if (persistedBotIdentity.username !== "appbackadmin_bot") {
|
|
|
109
109
|
|
|
110
110
|
const providerCalls = [];
|
|
111
111
|
let providerMode = "success";
|
|
112
|
+
let literalResponse = "";
|
|
113
|
+
let explicitResponses = [];
|
|
112
114
|
let untaggedIntentCalls = 0;
|
|
113
115
|
let missingEvidenceCalls = 0;
|
|
114
116
|
let streamingFinalProgressCalls = 0;
|
|
@@ -136,6 +138,9 @@ const provider = {
|
|
|
136
138
|
output: "REPORT:result\nusage fallback completed with evidence: `fallback-test.log`",
|
|
137
139
|
};
|
|
138
140
|
}
|
|
141
|
+
if (providerMode === "literal-result") {
|
|
142
|
+
return {provider: "codex", sessionId: request.sessionId || "mock-thread", cwd: request.cwd, output: literalResponse};
|
|
143
|
+
}
|
|
139
144
|
if (providerMode === "untagged-intent") {
|
|
140
145
|
untaggedIntentCalls += 1;
|
|
141
146
|
return {
|
|
@@ -160,7 +165,10 @@ const provider = {
|
|
|
160
165
|
: "REPORT:result\n수정 완료했습니다.\n\n근거:\n- 변경 파일: `src/example.ts`\n- 검증: `npm run check` 통과",
|
|
161
166
|
};
|
|
162
167
|
}
|
|
163
|
-
if (providerMode === "
|
|
168
|
+
if (providerMode === "explicit-status") {
|
|
169
|
+
return {provider: "codex", sessionId: request.sessionId || "mock-thread", cwd: request.cwd, output: explicitResponses.shift() ?? "REPORT:result\nunexpected extra call"};
|
|
170
|
+
}
|
|
171
|
+
if (providerMode === "queue-hold" || providerMode === "stop-hold") {
|
|
164
172
|
queueHoldStartedResolve?.();
|
|
165
173
|
await queueHoldReleasePromise;
|
|
166
174
|
return {
|
|
@@ -168,7 +176,7 @@ const provider = {
|
|
|
168
176
|
sessionId: request.sessionId || "mock-thread",
|
|
169
177
|
publicSessionId: request.publicSessionId,
|
|
170
178
|
cwd: request.cwd,
|
|
171
|
-
output: "REPORT:result\nactive queue test completed",
|
|
179
|
+
output: providerMode === "stop-hold" ? "REPORT:progress\nstop regression progress" : "REPORT:result\nactive queue test completed",
|
|
172
180
|
};
|
|
173
181
|
}
|
|
174
182
|
if (providerMode === "streaming-progress") {
|
|
@@ -359,8 +367,8 @@ if (!/^TELEGRAM_AUTO_PROGRESS_MAX_TURNS=6$/m.test(envText)) {
|
|
|
359
367
|
if (!/^COMMAND_TIMEOUT_MS=600000$/m.test(envText)) {
|
|
360
368
|
throw new Error(`Option command did not persist command timeout to .env: ${envText}`);
|
|
361
369
|
}
|
|
362
|
-
if (
|
|
363
|
-
throw new Error(
|
|
370
|
+
if (/^TELEGRAM_UNTAGGED_INTENT_RETRIES=/m.test(envText)) {
|
|
371
|
+
throw new Error("Retired intent option should not persist configuration");
|
|
364
372
|
}
|
|
365
373
|
|
|
366
374
|
const importedSecretDataDir = path.join(tmp, "imported-secret-data");
|
|
@@ -509,7 +517,7 @@ if (!calls.some((call) => call.method === "sendMessage" && /Set automatic contin
|
|
|
509
517
|
if (!calls.some((call) => call.method === "sendMessage" && /Set provider execution timeout to 600s/.test(call.text))) {
|
|
510
518
|
throw new Error(`Did not see option timeout acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
511
519
|
}
|
|
512
|
-
if (!calls.some((call) => call.method === "sendMessage" && /
|
|
520
|
+
if (!calls.some((call) => call.method === "sendMessage" && /intent option has been retired/.test(call.text))) {
|
|
513
521
|
throw new Error(`Did not see option intent acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
514
522
|
}
|
|
515
523
|
if (!calls.some((call) => call.method === "sendMessage" && /Workspace cleanup finished for S001/.test(call.text))) {
|
|
@@ -531,6 +539,20 @@ await waitForTelegramCall((call) => call.text.includes("Switched this chat to se
|
|
|
531
539
|
|
|
532
540
|
await send("/model");
|
|
533
541
|
const modelListCall = await waitForTelegramCall((call) => call.text.includes("availablePresets:"));
|
|
542
|
+
const restoreButton = findInlineButton(modelListCall, "원래 모델 복귀");
|
|
543
|
+
if (!restoreButton?.callback_data) throw new Error("Model restore button missing");
|
|
544
|
+
const fallbackPath = path.join(dataDir, "codex-usage-fallback.json");
|
|
545
|
+
const fallbackFixture = JSON.stringify({fallbackModel: "gpt-5.3-codex-spark", activatedAt: new Date().toISOString(), resetAt: "2099-01-01T00:00:00Z"});
|
|
546
|
+
const beforeRestore = await fs.readFile(path.join(dataDir, "state.json"), "utf8");
|
|
547
|
+
await fs.writeFile(fallbackPath, fallbackFixture);
|
|
548
|
+
await click(restoreButton.callback_data);
|
|
549
|
+
if (await pathExists(fallbackPath)) throw new Error("Restore button did not clear fallback");
|
|
550
|
+
await fs.writeFile(fallbackPath, fallbackFixture);
|
|
551
|
+
await send("/model restore");
|
|
552
|
+
await send("/model restore");
|
|
553
|
+
if (await pathExists(fallbackPath)) throw new Error("Restore command did not clear fallback");
|
|
554
|
+
const afterRestore = await fs.readFile(path.join(dataDir, "state.json"), "utf8");
|
|
555
|
+
if (JSON.stringify(JSON.parse(beforeRestore).sessions) !== JSON.stringify(JSON.parse(afterRestore).sessions)) throw new Error("Restore changed sessions");
|
|
534
556
|
const modelButton = findInlineButton(modelListCall, "gpt-5.6-terra");
|
|
535
557
|
if (!modelButton?.callback_data) {
|
|
536
558
|
throw new Error(`Model selection button is missing: ${modelListCall.reply_markup}`);
|
|
@@ -711,14 +733,8 @@ const untaggedCalls = (await fs.readFile(telegramCalls, "utf8"))
|
|
|
711
733
|
text: Buffer.from(textB64, "base64").toString("utf8"),
|
|
712
734
|
};
|
|
713
735
|
});
|
|
714
|
-
if (untaggedIntentCalls !==
|
|
715
|
-
throw new Error(
|
|
716
|
-
}
|
|
717
|
-
if (!untaggedCalls.some((call) => /untagged intent recovered/.test(call.text))) {
|
|
718
|
-
throw new Error(`Did not see recovered result after untagged intent retry. Calls: ${JSON.stringify(untaggedCalls, null, 2)}`);
|
|
719
|
-
}
|
|
720
|
-
if (untaggedCalls.some((call) => call.method === "sendMessage" && /^계속 진행해서 확인하겠습니다\.$/.test(call.text.trim()))) {
|
|
721
|
-
throw new Error(`Untagged intent-only response leaked as final Telegram message. Calls: ${JSON.stringify(untaggedCalls, null, 2)}`);
|
|
736
|
+
if (untaggedIntentCalls !== 1 || !untaggedCalls.some(call => call.text.includes("계속 진행해서 확인하겠습니다."))) {
|
|
737
|
+
throw new Error("Untagged response was not delivered in one execution");
|
|
722
738
|
}
|
|
723
739
|
|
|
724
740
|
providerMode = "missing-evidence";
|
|
@@ -738,17 +754,23 @@ const evidenceCalls = (await fs.readFile(telegramCalls, "utf8"))
|
|
|
738
754
|
text: Buffer.from(textB64, "base64").toString("utf8"),
|
|
739
755
|
};
|
|
740
756
|
});
|
|
741
|
-
if (missingEvidenceCalls !==
|
|
742
|
-
throw new Error(
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
757
|
+
if (missingEvidenceCalls !== 1 || !evidenceCalls.some(call => call.text.includes("수정 완료했습니다."))) {
|
|
758
|
+
throw new Error("Final response was not delivered in one execution");
|
|
759
|
+
}
|
|
760
|
+
for (const body of [
|
|
761
|
+
"플레이스미션을 누락했고, 표시 방식도 잘못 정리했습니다.\n‘더보기 목록’이 아니라 기존 넘기기 UI를 재사용하는 요구로 정정합니다.",
|
|
762
|
+
"앞선 답변은 요구사항 정정이며, 코드 수정·검증·병합 완료 보고가 아닙니다. 이번 정정으로 변경한 파일이나 커밋은 없습니다.",
|
|
763
|
+
"sudo 권한이 필요하지 않습니다. API key 변경도 없습니다.",
|
|
764
|
+
]) {
|
|
765
|
+
providerMode = "literal-result";
|
|
766
|
+
literalResponse = "REPORT:result\n" + body;
|
|
767
|
+
const countBefore = providerCalls.length;
|
|
768
|
+
await send("/batch start");
|
|
769
|
+
await send("S091 regression");
|
|
770
|
+
await send("/batch send");
|
|
771
|
+
if (providerCalls.length !== countBefore + 1) throw new Error("Result triggered extra provider execution");
|
|
772
|
+
const delivered = await readTelegramCalls();
|
|
773
|
+
if (!delivered.some(call => call.text.includes(body))) throw new Error("Result body not delivered");
|
|
752
774
|
}
|
|
753
775
|
|
|
754
776
|
providerMode = "streaming-progress";
|
|
@@ -845,6 +867,33 @@ if (providerCalls.length !== queueProviderCallsBefore + 1) {
|
|
|
845
867
|
throw new Error(`Removed queued instructions reached the provider: ${providerCalls.length - queueProviderCallsBefore} calls`);
|
|
846
868
|
}
|
|
847
869
|
|
|
870
|
+
providerMode = "explicit-status";
|
|
871
|
+
explicitResponses = ["REPORT:progress\nsudo 권한 변경은 필요 없습니다. 다음 단계를 진행합니다.", "REPORT:result\nexplicit continuation finished"];
|
|
872
|
+
const explicitBefore = providerCalls.length;
|
|
873
|
+
await send("/batch start");
|
|
874
|
+
await send("explicit status regression");
|
|
875
|
+
await send("/batch send");
|
|
876
|
+
if (providerCalls.length !== explicitBefore + 2) throw new Error("Explicit progress was overridden by body words");
|
|
877
|
+
explicitResponses = ["REPORT:blocked\n명시적으로 중단합니다."];
|
|
878
|
+
const blockedBefore = providerCalls.length;
|
|
879
|
+
await send("/batch start");
|
|
880
|
+
await send("explicit blocked regression");
|
|
881
|
+
await send("/batch send");
|
|
882
|
+
if (providerCalls.length !== blockedBefore + 1) throw new Error("Explicit blocked response retried");
|
|
883
|
+
|
|
884
|
+
providerMode = "stop-hold";
|
|
885
|
+
queueHoldStartedPromise = new Promise(resolve => { queueHoldStartedResolve = resolve; });
|
|
886
|
+
queueHoldReleasePromise = new Promise(resolve => { queueHoldReleaseResolve = resolve; });
|
|
887
|
+
const stopBefore = providerCalls.length;
|
|
888
|
+
await send("/batch start");
|
|
889
|
+
await send("stop regression");
|
|
890
|
+
const stoppedRun = send("/batch send");
|
|
891
|
+
await queueHoldStartedPromise;
|
|
892
|
+
await send("/stop");
|
|
893
|
+
queueHoldReleaseResolve();
|
|
894
|
+
await stoppedRun;
|
|
895
|
+
if (providerCalls.length !== stopBefore + 1) throw new Error("Stop allowed automatic continuation");
|
|
896
|
+
|
|
848
897
|
console.log(JSON.stringify({
|
|
849
898
|
ok: true,
|
|
850
899
|
dataDir,
|
|
@@ -853,7 +902,7 @@ console.log(JSON.stringify({
|
|
|
853
902
|
recoveredTodoItems: recoveredActive.length,
|
|
854
903
|
retryOption: 6,
|
|
855
904
|
timeoutOptionMs: 600000,
|
|
856
|
-
|
|
905
|
+
intentOptionRetired: true,
|
|
857
906
|
providerCalls: providerCalls.length,
|
|
858
907
|
untaggedIntentCalls,
|
|
859
908
|
missingEvidenceCalls,
|