@gaberrb/polypus 0.1.0 โ 0.2.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 +20 -0
- package/dist/index.js +202 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Polypus ๐
|
|
2
2
|
|
|
3
|
+
[](https://github.com/GaberRB/polypus/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@gaberrb/polypus)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**๐ Site:** https://gaberrb.github.io/polypus/ ยท **๐ฆ npm:** [`@gaberrb/polypus`](https://www.npmjs.com/package/@gaberrb/polypus)
|
|
8
|
+
|
|
3
9
|
An agentic coding harness that makes **any** AI API generate and apply code โ
|
|
4
10
|
even models **without** native tool-calling. Bring your own keys: OpenRouter,
|
|
5
11
|
Ollama, Anthropic, or any OpenAI-compatible endpoint. Run one agent, or split a
|
|
@@ -221,6 +227,20 @@ npm run typecheck
|
|
|
221
227
|
npm test # vitest
|
|
222
228
|
```
|
|
223
229
|
|
|
230
|
+
## Contributing
|
|
231
|
+
|
|
232
|
+
Contributions are welcome โ but **open an issue first**. Issues are triaged and labeled
|
|
233
|
+
`accepted` before any PR; PRs without a linked accepted issue don't pass CI. See
|
|
234
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the full flow (issue โ `accepted` โ branch โ PR โ review).
|
|
235
|
+
|
|
236
|
+
## Apoie o projeto
|
|
237
|
+
|
|
238
|
+
Se o Polypus te ajudou, vocรช pode apoiar o desenvolvimento via **PIX** ๐
|
|
239
|
+
|
|
240
|
+
> **Chave PIX (e-mail):** `gabrielriosbelmiro@gmail.com`
|
|
241
|
+
|
|
242
|
+
Qualquer valor รฉ muito bem-vindo e ajuda a manter o projeto vivo. Obrigado!
|
|
243
|
+
|
|
224
244
|
## Author
|
|
225
245
|
|
|
226
246
|
**Gabriel Rios**
|
package/dist/index.js
CHANGED
|
@@ -121,6 +121,7 @@ var en = {
|
|
|
121
121
|
"run.stopped": "\u26A0 Stopped after {steps} steps without a finish signal. You can continue with another instruction.",
|
|
122
122
|
"run.confirm": "Allow {summary}?",
|
|
123
123
|
"run.reprompt": "\u21BB no tool call \u2014 reinforcing instructions (attempt {attempt})",
|
|
124
|
+
"run.autocorrect": "\u21BB tool failed \u2014 auto-correcting with extra context",
|
|
124
125
|
"run.cancelled": "\u25A0 cancelled",
|
|
125
126
|
// repl
|
|
126
127
|
"repl.welcome": "Polypus interactive session.",
|
|
@@ -308,6 +309,7 @@ var ptBR = {
|
|
|
308
309
|
"run.stopped": "\u26A0 Parou ap\xF3s {steps} passos sem sinal de conclus\xE3o. Voc\xEA pode continuar com outra instru\xE7\xE3o.",
|
|
309
310
|
"run.confirm": "Permitir {summary}?",
|
|
310
311
|
"run.reprompt": "\u21BB nenhuma chamada de tool \u2014 refor\xE7ando instru\xE7\xF5es (tentativa {attempt})",
|
|
312
|
+
"run.autocorrect": "\u21BB tool falhou \u2014 autocorrigindo com contexto extra",
|
|
311
313
|
"run.cancelled": "\u25A0 cancelado",
|
|
312
314
|
"repl.welcome": "Sess\xE3o interativa do Polypus.",
|
|
313
315
|
"repl.welcomeHint": " Digite /help para comandos, /exit para sair.",
|
|
@@ -1442,6 +1444,182 @@ function getTool(name) {
|
|
|
1442
1444
|
return TOOLS[name];
|
|
1443
1445
|
}
|
|
1444
1446
|
|
|
1447
|
+
// src/core/agent/correction.ts
|
|
1448
|
+
import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
|
|
1449
|
+
import { dirname as dirname2, resolve as resolve6 } from "path";
|
|
1450
|
+
async function buildCorrection(call, output, deps) {
|
|
1451
|
+
const deterministic = await deterministicCorrection(call, output, deps);
|
|
1452
|
+
if (deterministic) return deterministic;
|
|
1453
|
+
if (deps.escalate) return deps.escalate({ call, output, toolSpec: deps.toolSpec });
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
async function deterministicCorrection(call, output, deps) {
|
|
1457
|
+
const path = typeof call.arguments.path === "string" ? call.arguments.path : void 0;
|
|
1458
|
+
if (call.name === "edit_file" && /was not found/i.test(output) && path) {
|
|
1459
|
+
const search = typeof call.arguments.search === "string" ? call.arguments.search : "";
|
|
1460
|
+
const snippet = await snippetNearSearch(deps.workspace, path, search);
|
|
1461
|
+
if (snippet) {
|
|
1462
|
+
return [
|
|
1463
|
+
"AUTO-CORRECTION \u2014 the 'search' text does not exist verbatim in the file.",
|
|
1464
|
+
"Here is the file's actual content (line-numbered). Copy the 'search' value EXACTLY",
|
|
1465
|
+
"from these lines (including indentation and whitespace), then resend edit_file:",
|
|
1466
|
+
"",
|
|
1467
|
+
snippet
|
|
1468
|
+
].join("\n");
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (call.name === "edit_file" && /matched \d+ times/i.test(output) && path) {
|
|
1472
|
+
const search = typeof call.arguments.search === "string" ? call.arguments.search : "";
|
|
1473
|
+
const lines = await occurrenceLines(deps.workspace, path, search);
|
|
1474
|
+
if (lines.length > 0) {
|
|
1475
|
+
return [
|
|
1476
|
+
`AUTO-CORRECTION \u2014 the 'search' text is not unique; it appears at lines ${lines.join(", ")}.`,
|
|
1477
|
+
"Add more surrounding lines to 'search' so it matches exactly one location, then resend."
|
|
1478
|
+
].join("\n");
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (/ENOENT|no such file/i.test(output) && path) {
|
|
1482
|
+
const listing = await listNearest(deps.workspace, path);
|
|
1483
|
+
if (listing) {
|
|
1484
|
+
return [
|
|
1485
|
+
"AUTO-CORRECTION \u2014 that path does not exist. Nearby existing entries:",
|
|
1486
|
+
"",
|
|
1487
|
+
listing,
|
|
1488
|
+
"",
|
|
1489
|
+
"Use list_dir to confirm the correct path, or create the parent directory first."
|
|
1490
|
+
].join("\n");
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (/denied|not allowed/i.test(output)) {
|
|
1494
|
+
return [
|
|
1495
|
+
"AUTO-CORRECTION \u2014 this action was blocked by the permission policy.",
|
|
1496
|
+
`Editable paths (glob allow-list): ${deps.allow.join(", ") || "(none)"}.`,
|
|
1497
|
+
"Target a path inside the allow-list, or stop and report that the change is out of scope."
|
|
1498
|
+
].join("\n");
|
|
1499
|
+
}
|
|
1500
|
+
if (deps.toolSpec && /needs .* arguments|Invalid|required|Resend the tool call/i.test(output)) {
|
|
1501
|
+
return [
|
|
1502
|
+
"AUTO-CORRECTION \u2014 the call had missing or invalid arguments. Expected schema:",
|
|
1503
|
+
"",
|
|
1504
|
+
formatSchema(deps.toolSpec),
|
|
1505
|
+
"",
|
|
1506
|
+
"Resend the tool call with every required argument filled in."
|
|
1507
|
+
].join("\n");
|
|
1508
|
+
}
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
function makeLLMEscalator(provider) {
|
|
1512
|
+
return async ({ call, output, toolSpec }) => {
|
|
1513
|
+
const prompt = [
|
|
1514
|
+
"A tool call just failed. Diagnose WHY in one or two sentences, then give a concrete,",
|
|
1515
|
+
"actionable instruction for how to correct the call. Do not apologize and do not call any tool.",
|
|
1516
|
+
"",
|
|
1517
|
+
`Tool: ${call.name}`,
|
|
1518
|
+
`Arguments: ${JSON.stringify(call.arguments)}`,
|
|
1519
|
+
`Error: ${output}`,
|
|
1520
|
+
toolSpec ? `Schema: ${JSON.stringify(toolSpec.parameters)}` : ""
|
|
1521
|
+
].join("\n");
|
|
1522
|
+
try {
|
|
1523
|
+
const res = await provider.chat({
|
|
1524
|
+
messages: [
|
|
1525
|
+
{ role: "system", content: "You are a debugging assistant for an autonomous coding agent." },
|
|
1526
|
+
{ role: "user", content: prompt }
|
|
1527
|
+
],
|
|
1528
|
+
params: { maxTokens: 400 }
|
|
1529
|
+
});
|
|
1530
|
+
const text2 = res.content.trim();
|
|
1531
|
+
return text2 ? `AUTO-CORRECTION (diagnosis):
|
|
1532
|
+
${text2}` : null;
|
|
1533
|
+
} catch {
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
async function readWorkspaceFile(workspace, path) {
|
|
1539
|
+
try {
|
|
1540
|
+
return await readFile4(resolve6(workspace, path), "utf8");
|
|
1541
|
+
} catch {
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
function numberedWindow(content, center2, radius) {
|
|
1546
|
+
const lines = content.split("\n");
|
|
1547
|
+
const start = Math.max(0, center2 - radius);
|
|
1548
|
+
const end = Math.min(lines.length, center2 + radius + 1);
|
|
1549
|
+
const width = String(end).length;
|
|
1550
|
+
return lines.slice(start, end).map((line, i) => `${String(start + i + 1).padStart(width, " ")} | ${line}`).join("\n");
|
|
1551
|
+
}
|
|
1552
|
+
async function snippetNearSearch(workspace, path, search) {
|
|
1553
|
+
const content = await readWorkspaceFile(workspace, path);
|
|
1554
|
+
if (content === null) return null;
|
|
1555
|
+
const lines = content.split("\n");
|
|
1556
|
+
const anchor = bestAnchorLine(lines, search);
|
|
1557
|
+
return numberedWindow(content, anchor >= 0 ? anchor : 0, 6);
|
|
1558
|
+
}
|
|
1559
|
+
function bestAnchorLine(lines, search) {
|
|
1560
|
+
const target = search.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
1561
|
+
if (!target) return -1;
|
|
1562
|
+
const targetWords = new Set(tokens(target));
|
|
1563
|
+
let best = -1;
|
|
1564
|
+
let bestScore = 0;
|
|
1565
|
+
lines.forEach((line, i) => {
|
|
1566
|
+
if (line.includes(target)) {
|
|
1567
|
+
if (bestScore < Number.MAX_SAFE_INTEGER) {
|
|
1568
|
+
best = i;
|
|
1569
|
+
bestScore = Number.MAX_SAFE_INTEGER;
|
|
1570
|
+
}
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const score = tokens(line).filter((w) => targetWords.has(w)).length;
|
|
1574
|
+
if (score > bestScore) {
|
|
1575
|
+
best = i;
|
|
1576
|
+
bestScore = score;
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
return best;
|
|
1580
|
+
}
|
|
1581
|
+
function tokens(s) {
|
|
1582
|
+
return s.split(/\W+/).filter((w) => w.length > 0);
|
|
1583
|
+
}
|
|
1584
|
+
async function occurrenceLines(workspace, path, search) {
|
|
1585
|
+
const content = await readWorkspaceFile(workspace, path);
|
|
1586
|
+
if (content === null || search.length === 0) return [];
|
|
1587
|
+
const out = [];
|
|
1588
|
+
let from = 0;
|
|
1589
|
+
for (; ; ) {
|
|
1590
|
+
const idx = content.indexOf(search, from);
|
|
1591
|
+
if (idx === -1) break;
|
|
1592
|
+
out.push(content.slice(0, idx).split("\n").length);
|
|
1593
|
+
from = idx + search.length;
|
|
1594
|
+
}
|
|
1595
|
+
return out;
|
|
1596
|
+
}
|
|
1597
|
+
async function listNearest(workspace, path) {
|
|
1598
|
+
let dir = dirname2(resolve6(workspace, path));
|
|
1599
|
+
for (let i = 0; i < 8; i++) {
|
|
1600
|
+
try {
|
|
1601
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
1602
|
+
const rel = dir === resolve6(workspace) ? "." : dir;
|
|
1603
|
+
const names = entries.slice(0, 40).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
|
|
1604
|
+
return `${rel}:
|
|
1605
|
+
${names.join(" ") || "(empty)"}`;
|
|
1606
|
+
} catch {
|
|
1607
|
+
const parent = dirname2(dir);
|
|
1608
|
+
if (parent === dir) return null;
|
|
1609
|
+
dir = parent;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
function formatSchema(spec) {
|
|
1615
|
+
const props = spec.parameters.properties ?? {};
|
|
1616
|
+
const required = new Set(spec.parameters.required ?? []);
|
|
1617
|
+
const lines = Object.entries(props).map(
|
|
1618
|
+
([name, v]) => ` - ${name} (${v.type ?? "any"}, ${required.has(name) ? "required" : "optional"})` + (v.description ? `: ${v.description}` : "")
|
|
1619
|
+
);
|
|
1620
|
+
return lines.join("\n") || " (no parameters)";
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1445
1623
|
// src/core/agent/loop.ts
|
|
1446
1624
|
function looksLikeStall(text2) {
|
|
1447
1625
|
const lc = text2.toLowerCase();
|
|
@@ -1492,6 +1670,7 @@ async function runAgent(opts) {
|
|
|
1492
1670
|
let lastFailSig = "";
|
|
1493
1671
|
let failStreak = 0;
|
|
1494
1672
|
const maxToolRetries = opts.maxToolRetries ?? 3;
|
|
1673
|
+
const autoCorrect = opts.autoCorrect ?? true;
|
|
1495
1674
|
const usage = { promptTokens: 0, completionTokens: 0 };
|
|
1496
1675
|
for (let step = 1; step <= maxSteps; step++) {
|
|
1497
1676
|
if (opts.signal?.aborted) return { finished: false, reason: "cancelled", steps: step - 1, messages, usage };
|
|
@@ -1538,12 +1717,29 @@ async function runAgent(opts) {
|
|
|
1538
1717
|
const tool = getTool(call.name);
|
|
1539
1718
|
const result = tool ? await tool.run(call.arguments, ctx) : { ok: false, output: `Unknown tool "${call.name}". Available: ${toolSpecs().map((t2) => t2.name).join(", ")}` };
|
|
1540
1719
|
events?.onToolResult?.(call, result);
|
|
1541
|
-
|
|
1720
|
+
const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
|
|
1721
|
+
let resultText = result.output;
|
|
1722
|
+
if (!result.ok && autoCorrect) {
|
|
1723
|
+
const guidance = await buildCorrection(call, result.output, {
|
|
1724
|
+
workspace: opts.workspace,
|
|
1725
|
+
allow: opts.promptContext.allow,
|
|
1726
|
+
toolSpec: tool?.spec,
|
|
1727
|
+
// Only spend a fixer-LLM call once the model has already repeated a
|
|
1728
|
+
// failing call โ the first failure gets deterministic help for free.
|
|
1729
|
+
escalate: sig === lastFailSig ? makeLLMEscalator(agent.provider) : void 0
|
|
1730
|
+
});
|
|
1731
|
+
if (guidance) {
|
|
1732
|
+
resultText = `${result.output}
|
|
1733
|
+
|
|
1734
|
+
${guidance}`;
|
|
1735
|
+
events?.onCorrection?.(call, guidance);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
messages.push(driver.toolResultMessage(call, resultText));
|
|
1542
1739
|
if (result.ok) {
|
|
1543
1740
|
failStreak = 0;
|
|
1544
1741
|
lastFailSig = "";
|
|
1545
1742
|
} else {
|
|
1546
|
-
const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
|
|
1547
1743
|
failStreak = sig === lastFailSig ? failStreak + 1 : 1;
|
|
1548
1744
|
lastFailSig = sig;
|
|
1549
1745
|
if (failStreak >= maxToolRetries) {
|
|
@@ -2403,6 +2599,10 @@ function renderEvents(spinner3) {
|
|
|
2403
2599
|
onReprompt(attempt) {
|
|
2404
2600
|
spinner3.stop();
|
|
2405
2601
|
console.log(pc7.yellow(" " + t("run.reprompt", { attempt })));
|
|
2602
|
+
},
|
|
2603
|
+
onCorrection() {
|
|
2604
|
+
spinner3.stop();
|
|
2605
|
+
console.log(pc7.yellow(" \u21BB " + t("run.autocorrect")));
|
|
2406
2606
|
}
|
|
2407
2607
|
};
|
|
2408
2608
|
}
|