@cosmicstack/mercury-agent 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -235,4 +235,4 @@ MIT © [Cosmic Stack](https://github.com/cosmicstack-labs)
235
235
 
236
236
  ## Suggestions and Contributions
237
237
 
238
- For suggestions, contributions, or any inquiries, please reach out to us at [support@cosmicstack.org](mailto:support@cosmicstack.org).
238
+ For suggestions, contributions, or any inquiries, please reach out to us at [mercury@cosmicstack.org](mailto:mercury@cosmicstack.org).
package/dist/index.js CHANGED
@@ -115,7 +115,7 @@ function getDefaultConfig() {
115
115
  intervalMinutes: getEnvNum("HEARTBEAT_INTERVAL_MINUTES", 60)
116
116
  },
117
117
  tokens: {
118
- dailyBudget: getEnvNum("DAILY_TOKEN_BUDGET", 5e4)
118
+ dailyBudget: getEnvNum("DAILY_TOKEN_BUDGET", 1e6)
119
119
  }
120
120
  };
121
121
  }
@@ -916,9 +916,34 @@ var BaseChannel = class {
916
916
  import { Marked } from "marked";
917
917
  import chalk from "chalk";
918
918
  var lexer = new Marked();
919
+ var HTML_ENTITIES = {
920
+ "&": "&",
921
+ "&lt;": "<",
922
+ "&gt;": ">",
923
+ "&quot;": '"',
924
+ "&#39;": "'",
925
+ "&#x27;": "'",
926
+ "&apos;": "'",
927
+ "&nbsp;": " "
928
+ };
929
+ function decodeHtmlEntities(text) {
930
+ return text.replace(/&(?:#[xX]?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match) => {
931
+ if (HTML_ENTITIES[match]) return HTML_ENTITIES[match];
932
+ if (match.startsWith("&#x") || match.startsWith("&#X")) {
933
+ const code = parseInt(match.slice(3, -1), 16);
934
+ return isNaN(code) ? match : String.fromCodePoint(code);
935
+ }
936
+ if (match.startsWith("&#")) {
937
+ const code = parseInt(match.slice(2, -1), 10);
938
+ return isNaN(code) ? match : String.fromCodePoint(code);
939
+ }
940
+ return match;
941
+ });
942
+ }
919
943
  function renderMarkdown(text) {
920
944
  try {
921
- const tokens = lexer.lexer(text);
945
+ const decoded = decodeHtmlEntities(text);
946
+ const tokens = lexer.lexer(decoded);
922
947
  const result = renderTokens(tokens);
923
948
  return result.replace(/\n{3,}/g, "\n\n").trimEnd();
924
949
  } catch {
@@ -1106,6 +1131,67 @@ function mdToTelegram(text) {
1106
1131
  return out;
1107
1132
  }
1108
1133
 
1134
+ // src/utils/tool-label.ts
1135
+ var TOOL_LABELS = {
1136
+ fetch_url: { icon: "\u2197", label: "Fetching", argKey: "url", argTransform: extractDomain },
1137
+ read_file: { icon: "\u{1F4C4}", label: "Reading", argKey: "path", argTransform: basename },
1138
+ write_file: { icon: "\u270F\uFE0F", label: "Writing", argKey: "path", argTransform: basename },
1139
+ create_file: { icon: "\u2728", label: "Creating", argKey: "path", argTransform: basename },
1140
+ edit_file: { icon: "\u2702\uFE0F", label: "Editing", argKey: "path", argTransform: basename },
1141
+ delete_file: { icon: "\u{1F5D1}", label: "Deleting", argKey: "path", argTransform: basename },
1142
+ list_dir: { icon: "\u{1F4C2}", label: "Listing", argKey: "path", argTransform: basename },
1143
+ approve_scope: { icon: "\u{1F513}", label: "Approving scope" },
1144
+ run_command: { icon: "\u2328", label: "Running command", argKey: "command", argTransform: truncate(40) },
1145
+ cd: { icon: "\u{1F4C2}", label: "Changing dir to", argKey: "path" },
1146
+ approve_command: { icon: "\u2705", label: "Approving command" },
1147
+ send_message: { icon: "\u{1F4AC}", label: "Sending message" },
1148
+ send_file: { icon: "\u{1F4CE}", label: "Sending file", argKey: "path", argTransform: basename },
1149
+ git_status: { icon: "\u{1F4CA}", label: "Git status" },
1150
+ git_diff: { icon: "\u{1F4DD}", label: "Git diff" },
1151
+ git_log: { icon: "\u{1F4CB}", label: "Git log" },
1152
+ git_add: { icon: "\u2795", label: "Staging files" },
1153
+ git_commit: { icon: "\u{1F4BE}", label: "Committing" },
1154
+ git_push: { icon: "\u2B06", label: "Pushing" },
1155
+ create_pr: { icon: "\u{1F500}", label: "Creating PR" },
1156
+ review_pr: { icon: "\u{1F440}", label: "Reviewing PR" },
1157
+ list_issues: { icon: "\u{1F4CB}", label: "Listing issues" },
1158
+ create_issue: { icon: "\u{1F41B}", label: "Creating issue" },
1159
+ github_api: { icon: "\u{1F500}", label: "GitHub API", argKey: "path", argTransform: truncate(30) },
1160
+ schedule_task: { icon: "\u23F0", label: "Scheduling task" },
1161
+ cancel_task: { icon: "\u274C", label: "Cancelling task" },
1162
+ list_tasks: { icon: "\u{1F4CB}", label: "Listing tasks" },
1163
+ use_skill: { icon: "\u{1F9E0}", label: "Using skill", argKey: "name" },
1164
+ list_skills: { icon: "\u{1F4CB}", label: "Listing skills" },
1165
+ install_skill: { icon: "\u{1F4E5}", label: "Installing skill" },
1166
+ budget_status: { icon: "\u{1F4B0}", label: "Budget status" }
1167
+ };
1168
+ function extractDomain(url) {
1169
+ try {
1170
+ return new URL(url).hostname;
1171
+ } catch {
1172
+ return url;
1173
+ }
1174
+ }
1175
+ function basename(p) {
1176
+ const parts = p.replace(/\\/g, "/").split("/");
1177
+ return parts[parts.length - 1] || p;
1178
+ }
1179
+ function truncate(maxLen) {
1180
+ return (v) => v.length > maxLen ? v.slice(0, maxLen) + "\u2026" : v;
1181
+ }
1182
+ function formatToolStep(toolName, args) {
1183
+ const config = TOOL_LABELS[toolName];
1184
+ if (!config) {
1185
+ return toolName;
1186
+ }
1187
+ let detail = "";
1188
+ if (config.argKey && args[config.argKey] !== void 0) {
1189
+ const raw = String(args[config.argKey]);
1190
+ detail = config.argTransform ? config.argTransform(raw) : raw;
1191
+ }
1192
+ return detail ? `${config.icon} ${config.label} ${detail}` : `${config.icon} ${config.label}`;
1193
+ }
1194
+
1109
1195
  // src/utils/arrow-select.ts
1110
1196
  import readline from "readline";
1111
1197
  import chalk2 from "chalk";
@@ -1226,6 +1312,12 @@ async function selectWithArrowKeys(title, options, config = {}) {
1226
1312
  }
1227
1313
 
1228
1314
  // src/channels/cli.ts
1315
+ var USER_PROMPT = " You: ";
1316
+ var USER_PROMPT_VISIBLE_LEN = USER_PROMPT.length;
1317
+ function agentPrefix(name, suffix) {
1318
+ const time = suffix ?? "";
1319
+ return chalk3.cyan(` ${name}:`) + time;
1320
+ }
1229
1321
  var CLIChannel = class extends BaseChannel {
1230
1322
  type = "cli";
1231
1323
  rl = null;
@@ -1233,6 +1325,9 @@ var CLIChannel = class extends BaseChannel {
1233
1325
  menuDepth = 0;
1234
1326
  menuAbortController = null;
1235
1327
  outputInProgress = 0;
1328
+ streamActive = false;
1329
+ streamToolLines = 0;
1330
+ lastUserInput = "";
1236
1331
  constructor(agentName = "Mercury") {
1237
1332
  super();
1238
1333
  this.agentName = agentName;
@@ -1243,16 +1338,17 @@ var CLIChannel = class extends BaseChannel {
1243
1338
  async start() {
1244
1339
  this.createInterface();
1245
1340
  this.ready = true;
1246
- this.showPrompt();
1247
1341
  logger.info("CLI channel started");
1248
1342
  }
1249
1343
  createInterface() {
1250
1344
  this.rl = readline2.createInterface({
1251
1345
  input: process.stdin,
1252
- output: process.stdout,
1253
- prompt: " You: "
1346
+ output: process.stdout
1254
1347
  });
1348
+ this.rl.setPrompt(chalk3.yellow(USER_PROMPT));
1349
+ this.rl._promptLength = USER_PROMPT_VISIBLE_LEN;
1255
1350
  this.rl.on("line", (line) => {
1351
+ this.lastUserInput = line.trim();
1256
1352
  const trimmed = line.trim();
1257
1353
  if (!trimmed) {
1258
1354
  this.showPrompt();
@@ -1279,9 +1375,9 @@ var CLIChannel = class extends BaseChannel {
1279
1375
  this.beginOutput();
1280
1376
  const timeStr = elapsedMs != null ? chalk3.dim(` (${(elapsedMs / 1e3).toFixed(1)}s)`) : "";
1281
1377
  const rendered = renderMarkdown(content);
1378
+ const indented = this.indent(rendered);
1282
1379
  console.log("");
1283
- console.log(chalk3.cyan(` ${this.agentName}:`) + timeStr);
1284
- const indented = rendered.split("\n").map((line) => ` ${line}`).join("\n");
1380
+ console.log(agentPrefix(this.agentName, timeStr));
1285
1381
  console.log(indented);
1286
1382
  console.log("");
1287
1383
  this.endOutput();
@@ -1298,23 +1394,69 @@ var CLIChannel = class extends BaseChannel {
1298
1394
  const stat = fs.statSync(resolved);
1299
1395
  const sizeStr = stat.size > 1024 * 1024 ? `${(stat.size / (1024 * 1024)).toFixed(1)}MB` : stat.size > 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
1300
1396
  console.log("");
1301
- console.log(chalk3.cyan(` ${this.agentName}:`) + chalk3.dim(" (file)"));
1397
+ console.log(agentPrefix(this.agentName, chalk3.dim(" (file)")));
1302
1398
  console.log(chalk3.dim(` path: ${resolved}`));
1303
1399
  console.log(chalk3.dim(` size: ${sizeStr}`));
1304
1400
  console.log("");
1305
1401
  this.endOutput();
1306
1402
  }
1403
+ async sendToolFeedback(toolName, args) {
1404
+ const label = formatToolStep(toolName, args);
1405
+ if (this.streamActive) {
1406
+ this.streamToolLines++;
1407
+ process.stdout.write(chalk3.dim(`
1408
+ ${label}
1409
+ `));
1410
+ } else {
1411
+ console.log(chalk3.dim(` ${label}`));
1412
+ }
1413
+ }
1307
1414
  async stream(content, _targetId) {
1308
1415
  this.closeActiveMenu();
1309
1416
  this.beginOutput();
1310
- console.log("");
1311
- process.stdout.write(chalk3.cyan(` ${this.agentName}: `));
1417
+ this.streamActive = true;
1418
+ this.streamToolLines = 0;
1419
+ if (!process.stdout.isTTY) {
1420
+ process.stdout.write(chalk3.cyan(` ${this.agentName}: `));
1421
+ let full2 = "";
1422
+ for await (const chunk of content) {
1423
+ process.stdout.write(chunk);
1424
+ full2 += chunk;
1425
+ }
1426
+ this.streamActive = false;
1427
+ console.log("\n");
1428
+ this.endOutput();
1429
+ return full2;
1430
+ }
1431
+ console.log(chalk3.cyan(` ${this.agentName}:`));
1312
1432
  let full = "";
1313
1433
  for await (const chunk of content) {
1314
1434
  process.stdout.write(chunk);
1315
1435
  full += chunk;
1316
1436
  }
1317
- console.log("\n");
1437
+ this.streamActive = false;
1438
+ if (!full.trim()) {
1439
+ console.log("");
1440
+ this.endOutput();
1441
+ return full;
1442
+ }
1443
+ const termWidth = process.stdout.columns || 80;
1444
+ let textLineCount = 0;
1445
+ for (const line of full.split("\n")) {
1446
+ const visualLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
1447
+ textLineCount += Math.max(1, Math.ceil((visualLen + 2) / termWidth));
1448
+ }
1449
+ const totalLines = 1 + textLineCount + this.streamToolLines;
1450
+ process.stdout.write(`\x1B[${totalLines}A`);
1451
+ for (let i = 0; i < totalLines; i++) {
1452
+ process.stdout.write("\x1B[2K\x1B[1B");
1453
+ }
1454
+ process.stdout.write(`\x1B[${totalLines}A`);
1455
+ const rendered = renderMarkdown(full);
1456
+ const indented = this.indent(rendered);
1457
+ console.log(agentPrefix(this.agentName));
1458
+ console.log(indented);
1459
+ console.log("");
1318
1460
  this.endOutput();
1319
1461
  return full;
1320
1462
  }
@@ -1323,8 +1465,8 @@ var CLIChannel = class extends BaseChannel {
1323
1465
  }
1324
1466
  showPrompt() {
1325
1467
  if (this.rl) {
1326
- this.rl.setPrompt(" You: ");
1327
- this.rl.prompt();
1468
+ process.stdout.write("\x1B[2K\r");
1469
+ process.stdout.write(chalk3.yellow(USER_PROMPT));
1328
1470
  }
1329
1471
  }
1330
1472
  async withMenu(runner) {
@@ -1377,6 +1519,9 @@ var CLIChannel = class extends BaseChannel {
1377
1519
  if (!this.ready || this.rl) return;
1378
1520
  this.createInterface();
1379
1521
  }
1522
+ indent(text) {
1523
+ return text.split("\n").map((line) => ` ${line}`).join("\n");
1524
+ }
1380
1525
  async prompt(question) {
1381
1526
  return new Promise((resolve13) => {
1382
1527
  this.rl?.question(question, (answer) => resolve13(answer.trim()));
@@ -1391,12 +1536,37 @@ var CLIChannel = class extends BaseChannel {
1391
1536
  });
1392
1537
  });
1393
1538
  }
1539
+ async askToContinue(question, _targetId) {
1540
+ return new Promise((resolve13) => {
1541
+ console.log("");
1542
+ console.log(chalk3.yellow(` \u26A0 ${question}`));
1543
+ this.rl?.question(chalk3.yellow(" Continue? [y/N] "), (answer) => {
1544
+ const val = answer.trim().toLowerCase();
1545
+ resolve13(val === "y" || val === "yes");
1546
+ });
1547
+ });
1548
+ }
1394
1549
  };
1395
1550
 
1396
1551
  // src/core/agent.ts
1397
- var ToolCallLoopDetector = class {
1552
+ var ToolCallLoopDetector = class _ToolCallLoopDetector {
1398
1553
  recentCalls = [];
1399
- maxEntries = 10;
1554
+ maxEntries = 20;
1555
+ hardAborted = false;
1556
+ static HIGH_TOLERANCE_TOOLS = /* @__PURE__ */ new Set([
1557
+ "fetch_url",
1558
+ "read_file",
1559
+ "list_dir",
1560
+ "web_search",
1561
+ "github_api",
1562
+ "run_command"
1563
+ ]);
1564
+ static getIdenticalThreshold() {
1565
+ return 3;
1566
+ }
1567
+ static getSameToolThreshold(toolName) {
1568
+ return _ToolCallLoopDetector.HIGH_TOLERANCE_TOOLS.has(toolName) ? 6 : 4;
1569
+ }
1400
1570
  record(toolName, params) {
1401
1571
  const paramsKey = JSON.stringify(params).slice(0, 100);
1402
1572
  this.recentCalls.push({ tool: toolName, params: paramsKey });
@@ -1404,36 +1574,53 @@ var ToolCallLoopDetector = class {
1404
1574
  this.recentCalls.shift();
1405
1575
  }
1406
1576
  }
1407
- detect() {
1577
+ detectIdentical() {
1408
1578
  if (this.recentCalls.length < 3) return null;
1409
1579
  const last = this.recentCalls[this.recentCalls.length - 1];
1410
- let consecutiveCount = 0;
1580
+ let identicalCount = 0;
1411
1581
  for (let i = this.recentCalls.length - 1; i >= 0; i--) {
1412
1582
  if (this.recentCalls[i].tool === last.tool && this.recentCalls[i].params === last.params) {
1413
- consecutiveCount++;
1583
+ identicalCount++;
1414
1584
  } else {
1415
1585
  break;
1416
1586
  }
1417
1587
  }
1418
- if (consecutiveCount >= 3) {
1419
- return { tool: last.tool, count: consecutiveCount };
1588
+ if (identicalCount >= _ToolCallLoopDetector.getIdenticalThreshold()) {
1589
+ this.hardAborted = true;
1590
+ return {
1591
+ tool: last.tool,
1592
+ count: identicalCount,
1593
+ message: `[SYSTEM] You called "${last.tool}" ${identicalCount} times with identical parameters and got the same result. This is a hard loop \u2014 stop immediately.`
1594
+ };
1420
1595
  }
1421
- const lastTool = last.tool;
1422
- let toolCount = 0;
1596
+ return null;
1597
+ }
1598
+ detectSameTool() {
1599
+ if (this.recentCalls.length < 4) return null;
1600
+ const last = this.recentCalls[this.recentCalls.length - 1];
1601
+ let sameToolCount = 0;
1423
1602
  for (let i = this.recentCalls.length - 1; i >= 0; i--) {
1424
- if (this.recentCalls[i].tool === lastTool) {
1425
- toolCount++;
1603
+ if (this.recentCalls[i].tool === last.tool) {
1604
+ sameToolCount++;
1426
1605
  } else {
1427
1606
  break;
1428
1607
  }
1429
1608
  }
1430
- if (toolCount >= 4) {
1431
- return { tool: lastTool, count: toolCount };
1609
+ const threshold = _ToolCallLoopDetector.getSameToolThreshold(last.tool);
1610
+ if (sameToolCount >= threshold) {
1611
+ return {
1612
+ tool: last.tool,
1613
+ count: sameToolCount
1614
+ };
1432
1615
  }
1433
1616
  return null;
1434
1617
  }
1618
+ isHardAborted() {
1619
+ return this.hardAborted;
1620
+ }
1435
1621
  reset() {
1436
1622
  this.recentCalls = [];
1623
+ this.hardAborted = false;
1437
1624
  }
1438
1625
  };
1439
1626
  var MAX_STEPS = 10;
@@ -1597,30 +1784,6 @@ You can override this:
1597
1784
  const recentMemory = this.shortTerm.getRecent(msg.channelId, 10);
1598
1785
  const relevantFacts = this.longTerm.search(msg.content, 3);
1599
1786
  const messages = [];
1600
- const recentSteps = this.shortTerm.getRecent(msg.channelId, 4);
1601
- let loopWarning = null;
1602
- if (recentSteps.length >= 3) {
1603
- const toolCallPattern = /\[Using: (.+?)\]/g;
1604
- const toolCalls = [];
1605
- for (const m of recentSteps) {
1606
- if (m.role === "assistant") {
1607
- let match;
1608
- while ((match = toolCallPattern.exec(m.content)) !== null) {
1609
- toolCalls.push(match[1]);
1610
- }
1611
- }
1612
- }
1613
- if (toolCalls.length >= 3) {
1614
- const last3 = toolCalls.slice(-3);
1615
- if (last3[0] === last3[1] && last3[1] === last3[2]) {
1616
- loopWarning = `[SYSTEM WARNING] You have called ${last3[0]} 3+ times in a row with the same result. Stop repeating this call. Try a different approach \u2014 if you're failing on permissions, try a different path. If you're failing on git push auth, use github_api with PUT /repos/{owner}/{repo}/contents/{path} to push files directly through the API.`;
1617
- }
1618
- }
1619
- }
1620
- if (loopWarning) {
1621
- messages.push({ role: "user", content: loopWarning });
1622
- messages.push({ role: "assistant", content: "Understood. I will try a different approach." });
1623
- }
1624
1787
  if (relevantFacts.length > 0) {
1625
1788
  messages.push({
1626
1789
  role: "user",
@@ -1651,6 +1814,8 @@ You can override this:
1651
1814
  let lastError = null;
1652
1815
  let streamedText = "";
1653
1816
  const loopDetector = new ToolCallLoopDetector();
1817
+ const loopAbortController = new AbortController();
1818
+ let loopWarningSent = false;
1654
1819
  const canStream = msg.channelType === "cli" || msg.channelType === "telegram" && this.telegramStreaming;
1655
1820
  for (const provider of fallbackIterator) {
1656
1821
  try {
@@ -1662,6 +1827,7 @@ You can override this:
1662
1827
  messages,
1663
1828
  tools: this.capabilities.getTools(),
1664
1829
  maxSteps: MAX_STEPS,
1830
+ abortSignal: loopAbortController.signal,
1665
1831
  onStepFinish: async ({ toolCalls }) => {
1666
1832
  if (toolCalls && toolCalls.length > 0) {
1667
1833
  const names = toolCalls.map((tc) => tc.toolName).join(", ");
@@ -1669,12 +1835,44 @@ You can override this:
1669
1835
  for (const tc of toolCalls) {
1670
1836
  loopDetector.record(tc.toolName, tc.args);
1671
1837
  }
1672
- const loop = loopDetector.detect();
1673
- if (loop) {
1674
- logger.warn({ tool: loop.tool, count: loop.count }, "Tool call loop detected");
1838
+ if (toolCalls.some((tc) => tc.toolName === "use_skill")) {
1839
+ loopDetector.reset();
1840
+ }
1841
+ const hardLoop = loopDetector.detectIdentical();
1842
+ if (hardLoop) {
1843
+ logger.warn({ tool: hardLoop.tool, count: hardLoop.count }, "Hard loop detected \u2014 aborting");
1844
+ if (!loopWarningSent && channel && msg.channelType !== "internal") {
1845
+ loopWarningSent = true;
1846
+ await channel.send(`\u26A0 Repeated call detected \u2014 ${hardLoop.tool} called ${hardLoop.count}x with same params. Stopping.`, msg.channelId).catch(() => {
1847
+ });
1848
+ }
1849
+ loopAbortController.abort();
1850
+ }
1851
+ const softLoop = loopDetector.detectSameTool();
1852
+ if (softLoop && !loopWarningSent && channel && msg.channelType !== "internal") {
1853
+ loopWarningSent = true;
1854
+ const shouldContinue = await channel.askToContinue(
1855
+ `${softLoop.tool} has been called ${softLoop.count}x in a row. This might be a loop.`,
1856
+ msg.channelId
1857
+ ).catch(() => false);
1858
+ if (shouldContinue) {
1859
+ loopDetector.reset();
1860
+ loopWarningSent = false;
1861
+ } else {
1862
+ loopAbortController.abort();
1863
+ }
1864
+ }
1865
+ if (channel && msg.channelType !== "internal") {
1866
+ if (channel instanceof CLIChannel) {
1867
+ for (const tc of toolCalls) {
1868
+ await channel.sendToolFeedback(tc.toolName, tc.args).catch(() => {
1869
+ });
1870
+ }
1871
+ } else {
1872
+ await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {
1873
+ });
1874
+ }
1675
1875
  }
1676
- await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {
1677
- });
1678
1876
  }
1679
1877
  }
1680
1878
  });
@@ -1706,6 +1904,7 @@ You can override this:
1706
1904
  messages,
1707
1905
  tools: this.capabilities.getTools(),
1708
1906
  maxSteps: MAX_STEPS,
1907
+ abortSignal: loopAbortController.signal,
1709
1908
  onStepFinish: async ({ toolCalls, text }) => {
1710
1909
  if (toolCalls && toolCalls.length > 0) {
1711
1910
  const names = toolCalls.map((tc) => tc.toolName).join(", ");
@@ -1713,13 +1912,43 @@ You can override this:
1713
1912
  for (const tc of toolCalls) {
1714
1913
  loopDetector.record(tc.toolName, tc.args);
1715
1914
  }
1716
- const loop = loopDetector.detect();
1717
- if (loop) {
1718
- logger.warn({ tool: loop.tool, count: loop.count }, "Tool call loop detected");
1915
+ if (toolCalls.some((tc) => tc.toolName === "use_skill")) {
1916
+ loopDetector.reset();
1917
+ }
1918
+ const hardLoop = loopDetector.detectIdentical();
1919
+ if (hardLoop) {
1920
+ logger.warn({ tool: hardLoop.tool, count: hardLoop.count }, "Hard loop detected \u2014 aborting");
1921
+ if (!loopWarningSent && channel && msg.channelType !== "internal") {
1922
+ loopWarningSent = true;
1923
+ await channel.send(`\u26A0 Repeated call detected \u2014 ${hardLoop.tool} called ${hardLoop.count}x with same params. Stopping.`, msg.channelId).catch(() => {
1924
+ });
1925
+ }
1926
+ loopAbortController.abort();
1927
+ }
1928
+ const softLoop = loopDetector.detectSameTool();
1929
+ if (softLoop && !loopWarningSent && channel && msg.channelType !== "internal") {
1930
+ loopWarningSent = true;
1931
+ const shouldContinue = await channel.askToContinue(
1932
+ `${softLoop.tool} has been called ${softLoop.count}x in a row. This might be a loop.`,
1933
+ msg.channelId
1934
+ ).catch(() => false);
1935
+ if (shouldContinue) {
1936
+ loopDetector.reset();
1937
+ loopWarningSent = false;
1938
+ } else {
1939
+ loopAbortController.abort();
1940
+ }
1719
1941
  }
1720
1942
  if (channel && msg.channelType !== "internal") {
1721
- await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {
1722
- });
1943
+ if (channel instanceof CLIChannel) {
1944
+ for (const tc of toolCalls) {
1945
+ await channel.sendToolFeedback(tc.toolName, tc.args).catch(() => {
1946
+ });
1947
+ }
1948
+ } else {
1949
+ await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {
1950
+ });
1951
+ }
1723
1952
  }
1724
1953
  }
1725
1954
  }
@@ -1729,6 +1958,19 @@ You can override this:
1729
1958
  this.providers.markSuccess(provider.name);
1730
1959
  break;
1731
1960
  } catch (err) {
1961
+ if (loopDetector.isHardAborted() || loopAbortController.signal.aborted) {
1962
+ logger.info("Generation aborted due to loop detection \u2014 using partial response");
1963
+ if (!result && streamedText) {
1964
+ result = { text: streamedText, usage: void 0 };
1965
+ }
1966
+ if (!result) {
1967
+ result = { text: "I stopped because I was repeating the same tool calls. What would you like me to do differently?", usage: void 0 };
1968
+ }
1969
+ if (usedProvider) {
1970
+ this.providers.markSuccess(usedProvider.name);
1971
+ }
1972
+ break;
1973
+ }
1732
1974
  lastError = err;
1733
1975
  logger.warn({ provider: provider.name, err: err.message }, "Provider failed, trying fallback");
1734
1976
  if (channel && msg.channelType !== "internal") {
@@ -2948,6 +3190,32 @@ var TelegramChannel = class extends BaseChannel {
2948
3190
  }, 12e4);
2949
3191
  });
2950
3192
  }
3193
+ async askToContinue(question, targetId) {
3194
+ const chatIds = this.resolveTargetChatIds(targetId);
3195
+ const chatId = chatIds[0];
3196
+ if (!chatId || !this.bot) return false;
3197
+ const id = `loop_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3198
+ const keyboard = new InlineKeyboard().text("Continue", `${id}:yes`).text("Stop", `${id}:no`);
3199
+ try {
3200
+ await this.bot.api.sendMessage(chatId, mdToTelegram(question), {
3201
+ parse_mode: "HTML",
3202
+ reply_markup: keyboard
3203
+ });
3204
+ } catch {
3205
+ await this.bot.api.sendMessage(chatId, question, {
3206
+ reply_markup: keyboard
3207
+ });
3208
+ }
3209
+ return new Promise((resolve13) => {
3210
+ this.pendingApprovals.set(`${id}:yes`, () => resolve13(true));
3211
+ this.pendingApprovals.set(`${id}:no`, () => resolve13(false));
3212
+ setTimeout(() => {
3213
+ this.pendingApprovals.delete(`${id}:yes`);
3214
+ this.pendingApprovals.delete(`${id}:no`);
3215
+ resolve13(false);
3216
+ }, 12e4);
3217
+ });
3218
+ }
2951
3219
  async handleAccessRequest(userId, chatId, username, firstName) {
2952
3220
  const approvedUser = findTelegramApprovedUser(this.config, userId);
2953
3221
  if (approvedUser) {
@@ -3962,7 +4230,7 @@ function createEditFileTool(permissions, getCwd) {
3962
4230
  import { tool as tool7 } from "ai";
3963
4231
  import { z as z7 } from "zod";
3964
4232
  import { existsSync as existsSync12, statSync as statSync2 } from "fs";
3965
- import { resolve as resolve9, basename, isAbsolute as isAbsolute7 } from "path";
4233
+ import { resolve as resolve9, basename as basename2, isAbsolute as isAbsolute7 } from "path";
3966
4234
  function createSendFileTool(permissions, getCwd, sendFile) {
3967
4235
  return tool7({
3968
4236
  description: "Send a file to the user. On Telegram the file is uploaded as an attachment to the relevant approved recipients. On CLI the file path and size are displayed. The path must be within an allowed read scope.",
@@ -3988,7 +4256,7 @@ function createSendFileTool(permissions, getCwd, sendFile) {
3988
4256
  }
3989
4257
  try {
3990
4258
  await sendFile(resolved);
3991
- const filename = basename(resolved);
4259
+ const filename = basename2(resolved);
3992
4260
  const sizeStr = stat.size > 1024 * 1024 ? `${(stat.size / (1024 * 1024)).toFixed(1)}MB` : `${Math.round(stat.size / 1024)}KB`;
3993
4261
  return `File sent: ${filename} (${sizeStr})`;
3994
4262
  } catch (err) {
@@ -6810,6 +7078,7 @@ async function runAgent(isDaemon = false) {
6810
7078
  console.log(chalk7.green(` ${name} is live. Type a message and press Enter.`));
6811
7079
  console.log(chalk7.dim(" Ctrl+C to exit \xB7 /help for commands"));
6812
7080
  console.log("");
7081
+ cliChannel?.showPrompt();
6813
7082
  } else {
6814
7083
  logger.info({ channels: activeCh, tools: toolNames }, "Mercury is live (daemon mode)");
6815
7084
  }