ucode-agent 1.38.0 → 1.40.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/package.json +1 -1
- package/src/core/loop.js +78 -7
- package/src/core/provider.js +75 -0
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -103,6 +103,9 @@ const SILENT = new Set(['update_plan']);
|
|
|
103
103
|
const MAX_FIX_ROUNDS = 3;
|
|
104
104
|
|
|
105
105
|
/** Files worth checking after they change. */
|
|
106
|
+
/** A file with a page in it — something a browser can be pointed at. */
|
|
107
|
+
const PAGE = /\.(?:html?|tsx|jsx)$/i;
|
|
108
|
+
|
|
106
109
|
const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?)$/i;
|
|
107
110
|
|
|
108
111
|
/** Where TypeScript keeps what it learned, so the next check is a quick one. */
|
|
@@ -1082,6 +1085,8 @@ export class Agent {
|
|
|
1082
1085
|
// /undo can put the whole turn back.
|
|
1083
1086
|
beginTurn();
|
|
1084
1087
|
this.lookedThisTurn = false;
|
|
1088
|
+
this.reads = new Map();
|
|
1089
|
+
this.declines = 0;
|
|
1085
1090
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1086
1091
|
const images = await this.attachImages(input);
|
|
1087
1092
|
this.push(images.length
|
|
@@ -1641,7 +1646,14 @@ export class Agent {
|
|
|
1641
1646
|
// changed so far; the automatic check at the end would only repeat it.
|
|
1642
1647
|
if (call.name === 'run_command' && out.exitCode === 0 &&
|
|
1643
1648
|
/(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
|
|
1649
|
+
// The pages stay. A build proves the project compiles, which is not the
|
|
1650
|
+
// same claim as the page working — and clearing everything here meant a
|
|
1651
|
+
// Next.js app, where a passing `npm run build` is part of every turn,
|
|
1652
|
+
// never reached the look at all. That is the one check that opens the
|
|
1653
|
+
// thing and presses its buttons, skipped on the whole framework.
|
|
1654
|
+
const pages = [...(this.sinceCheck ?? [])].filter((f) => PAGE.test(f));
|
|
1644
1655
|
this.sinceCheck?.clear();
|
|
1656
|
+
for (const p of pages) this.sinceCheck?.add(p);
|
|
1645
1657
|
}
|
|
1646
1658
|
if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
|
|
1647
1659
|
// The change as its two numbers, not as a copy of the file. The diff rows
|
|
@@ -1670,11 +1682,32 @@ export class Agent {
|
|
|
1670
1682
|
// user made, and anything that actually failed, still shows.
|
|
1671
1683
|
if (err instanceof Declined) this.ui.toolFailed('declined');
|
|
1672
1684
|
else if (err.kind !== 'bad_args') this.ui.toolFailed(`${err.kind}: ${err.failed}`);
|
|
1685
|
+
|
|
1686
|
+
// Declines that keep coming are not decisions, they are a wall.
|
|
1687
|
+
//
|
|
1688
|
+
// A traced build ran 250 steps and 32 minutes before dying on the step
|
|
1689
|
+
// limit because every command it tried was declined — a piped session
|
|
1690
|
+
// cannot answer "go ahead? [y/N]", so the answer is always no. The model
|
|
1691
|
+
// read each refusal as being about that command, picked a different one,
|
|
1692
|
+
// and was refused again. Nothing ever told it the refusals were the room
|
|
1693
|
+
// rather than the request.
|
|
1694
|
+
//
|
|
1695
|
+
// Two are a person saying no to two things. The third is the wall, and it
|
|
1696
|
+
// is worth naming, because the way out is to finish without commands
|
|
1697
|
+
// rather than to keep hunting for one that is allowed.
|
|
1698
|
+
this.declines = err instanceof Declined ? (this.declines ?? 0) + 1 : 0;
|
|
1673
1699
|
this.push({
|
|
1674
1700
|
role: 'tool',
|
|
1675
1701
|
toolCallId: call.id,
|
|
1676
1702
|
name: call.name,
|
|
1677
|
-
content: err.forModel()
|
|
1703
|
+
content: err.forModel()
|
|
1704
|
+
+ (err instanceof Declined ? '' : this.stuckNote(call, { err }))
|
|
1705
|
+
+ (this.declines >= 3
|
|
1706
|
+
? '\n\nThat is the third command declined in a row. They are not being refused one by one — '
|
|
1707
|
+
+ 'nothing can be run in this session at all, and the next one will be declined too. '
|
|
1708
|
+
+ 'Stop trying to run things: finish the work with the files you have, and say plainly '
|
|
1709
|
+
+ 'in your reply which steps someone will need to run themselves.'
|
|
1710
|
+
: ''),
|
|
1678
1711
|
});
|
|
1679
1712
|
return err.kind === 'bad_args';
|
|
1680
1713
|
}
|
|
@@ -1705,6 +1738,34 @@ export class Agent {
|
|
|
1705
1738
|
});
|
|
1706
1739
|
}
|
|
1707
1740
|
|
|
1741
|
+
// Reading the same unchanged file over and over.
|
|
1742
|
+
//
|
|
1743
|
+
// A traced build of a one-page app spent 53 of its 80 tool calls on
|
|
1744
|
+
// read_file, most of them the same handful of files again and again — six
|
|
1745
|
+
// seconds and a few thousand tokens each, for text that was already in the
|
|
1746
|
+
// conversation. The prompt has asked it not to since 1.27; asking is not
|
|
1747
|
+
// working, so the third read of a file nothing has written to since says
|
|
1748
|
+
// so instead of sending the file a third time.
|
|
1749
|
+
//
|
|
1750
|
+
// Two reads are left alone: the first is the work, and the second is
|
|
1751
|
+
// usually a fair re-check after an edit. Only the third is a loop. And it
|
|
1752
|
+
// is keyed on the file being untouched since — the moment anything writes
|
|
1753
|
+
// to it, the count starts again and a real re-read goes through.
|
|
1754
|
+
if (call.name === 'read_file' && call.args?.path) {
|
|
1755
|
+
const key = String(call.args.path);
|
|
1756
|
+
const seen = (this.reads ??= new Map()).get(key) ?? 0;
|
|
1757
|
+
if (seen >= 2 && !this.sinceCheck?.has(key)) {
|
|
1758
|
+
this.reads.set(key, seen + 1);
|
|
1759
|
+
return {
|
|
1760
|
+
content: `You have already read ${key} ${seen} times this turn and nothing has written to it since, ` +
|
|
1761
|
+
'so its contents are unchanged and already above. Use what is there. If you need a part ' +
|
|
1762
|
+
'you have lost, say which and read the files you still need together in one read_files.',
|
|
1763
|
+
summary: 'unchanged since you last read it',
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
this.reads.set(key, seen + 1);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1708
1769
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
1709
1770
|
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
1710
1771
|
if (call.name === 'delegate') return this.delegate(call.args?.tasks);
|
|
@@ -2008,10 +2069,15 @@ export class Agent {
|
|
|
2008
2069
|
// nothing on exactly the builds that needed it, and this is the only one
|
|
2009
2070
|
// that opens the page, presses its buttons and finds out whether any of it
|
|
2010
2071
|
// actually works.
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2072
|
+
// Whatever else is wrong. It used to wait until everything else was clean,
|
|
2073
|
+
// which meant the one check that opens the page and presses its buttons
|
|
2074
|
+
// was skipped on exactly the builds going badly — and a type error and a
|
|
2075
|
+
// dead button are two separate things to fix, so finding them in one pass
|
|
2076
|
+
// costs a round trip less than finding them in two. The visual review
|
|
2077
|
+
// inside the look still stands itself down on a page that is broken;
|
|
2078
|
+
// reviewing the typography of an error overlay helps nobody.
|
|
2079
|
+
const seen = await this.lookOnceThisTurn(root, changed);
|
|
2080
|
+
if (seen) problems.push(seen);
|
|
2015
2081
|
|
|
2016
2082
|
return problems.length ? problems.join('\n\n') : null;
|
|
2017
2083
|
}
|
|
@@ -2031,7 +2097,7 @@ export class Agent {
|
|
|
2031
2097
|
async lookOnceThisTurn(root, changed) {
|
|
2032
2098
|
if (this.lookedThisTurn) return null;
|
|
2033
2099
|
|
|
2034
|
-
const pages = [...changed].filter((f) =>
|
|
2100
|
+
const pages = [...changed].filter((f) => PAGE.test(f));
|
|
2035
2101
|
if (!pages.length) return null;
|
|
2036
2102
|
this.lookedThisTurn = true;
|
|
2037
2103
|
|
|
@@ -2051,7 +2117,12 @@ export class Agent {
|
|
|
2051
2117
|
const html = pages.find((f) => /\.html?$/i.test(f));
|
|
2052
2118
|
if (!html) return null;
|
|
2053
2119
|
return await withStaticServer(path.dirname(path.resolve(root, html)), look);
|
|
2054
|
-
} catch {
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
// Say so, quietly, rather than skipping in silence. A browser that will
|
|
2122
|
+
// not start is a reason to carry on without the look — but a check that
|
|
2123
|
+
// stops running and never mentions it is worse than one that was never
|
|
2124
|
+
// written, because everything downstream still believes it ran.
|
|
2125
|
+
this.ui.note(`could not open the app to check it: ${String(err?.failed ?? err?.message ?? err).split('\n')[0]}`);
|
|
2055
2126
|
return null;
|
|
2056
2127
|
}
|
|
2057
2128
|
}
|
package/src/core/provider.js
CHANGED
|
@@ -795,6 +795,69 @@ function scalarArgs(text) {
|
|
|
795
795
|
return out;
|
|
796
796
|
}
|
|
797
797
|
|
|
798
|
+
/**
|
|
799
|
+
* Rebuild the edits out of a broken edit_file or edit_files call.
|
|
800
|
+
*
|
|
801
|
+
* The same problem as a broken write, one level down: an edit carries two
|
|
802
|
+
* slabs of somebody's source as escaped JSON strings, and one stray character
|
|
803
|
+
* loses the call. edit_files failed this way three times in a row in a traced
|
|
804
|
+
* build, at the end of a turn, which is a turn that ends having undone nothing
|
|
805
|
+
* and fixed nothing.
|
|
806
|
+
*
|
|
807
|
+
* Safer than it sounds. Every edit is applied by replaceOnce, which requires
|
|
808
|
+
* old_string to appear exactly once and refuses otherwise — so an edit
|
|
809
|
+
* recovered wrongly does not corrupt a file, it fails to match and says so.
|
|
810
|
+
* The risk of guessing is a clear error; the cost of not guessing is the whole
|
|
811
|
+
* call. Truncated replies are still refused upstream, where half a string
|
|
812
|
+
* would mean half a file.
|
|
813
|
+
*
|
|
814
|
+
* Each edit is attributed to the nearest "path" before it, which is how
|
|
815
|
+
* edit_files nests them; edit_file has exactly one of each.
|
|
816
|
+
*/
|
|
817
|
+
export function salvageEdits(text) {
|
|
818
|
+
const heads = [...text.matchAll(/"old_string"\s*:\s*"/g)];
|
|
819
|
+
if (!heads.length) return null;
|
|
820
|
+
|
|
821
|
+
const paths = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
|
|
822
|
+
const NEW = '"new_string"';
|
|
823
|
+
const out = [];
|
|
824
|
+
|
|
825
|
+
for (let i = 0; i < heads.length; i++) {
|
|
826
|
+
const oldFrom = heads[i].index + heads[i][0].length;
|
|
827
|
+
const newKey = text.indexOf(NEW, oldFrom);
|
|
828
|
+
if (newKey < 0) return null;
|
|
829
|
+
|
|
830
|
+
const opens = /^\s*:\s*"/.exec(text.slice(newKey + NEW.length));
|
|
831
|
+
if (!opens) return null;
|
|
832
|
+
const newFrom = newKey + NEW.length + opens[0].length;
|
|
833
|
+
|
|
834
|
+
// The value runs until whatever structure comes next — the following edit,
|
|
835
|
+
// or the following file — and the trailing JSON punctuation comes off.
|
|
836
|
+
const nextEdit = i + 1 < heads.length ? heads[i + 1].index : text.length;
|
|
837
|
+
const nextPath = paths.find((m) => m.index > newFrom)?.index ?? text.length;
|
|
838
|
+
const strip = (v) => v.replace(/"[\s,{}[\]]*$/, '');
|
|
839
|
+
|
|
840
|
+
const old_string = unescapeLoose(strip(text.slice(oldFrom, newKey)));
|
|
841
|
+
const new_string = unescapeLoose(strip(text.slice(newFrom, Math.min(nextEdit, nextPath))));
|
|
842
|
+
const owner = paths.filter((m) => m.index < heads[i].index).pop();
|
|
843
|
+
|
|
844
|
+
if (!owner || !old_string) return null;
|
|
845
|
+
out.push({ path: unescapeLoose(owner[1]), old_string, new_string });
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return out.length ? out : null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The same edits, grouped under their file, which is edit_files' own shape. */
|
|
852
|
+
export function groupEdits(edits) {
|
|
853
|
+
const byPath = new Map();
|
|
854
|
+
for (const { path, old_string, new_string } of edits) {
|
|
855
|
+
if (!byPath.has(path)) byPath.set(path, []);
|
|
856
|
+
byPath.get(path).push({ old_string, new_string });
|
|
857
|
+
}
|
|
858
|
+
return [...byPath].map(([path, list]) => ({ path, edits: list }));
|
|
859
|
+
}
|
|
860
|
+
|
|
798
861
|
export function salvageWrites(text) {
|
|
799
862
|
const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
|
|
800
863
|
if (!heads.length) return null;
|
|
@@ -867,6 +930,18 @@ export function readCall({ id, name, raw, cutOff = false }) {
|
|
|
867
930
|
}
|
|
868
931
|
} catch { /* beyond general repair — try the file-write shape next */ }
|
|
869
932
|
|
|
933
|
+
if (name === 'edit_file' || name === 'edit_files' || name === 'multi_edit') {
|
|
934
|
+
const edits = salvageEdits(text);
|
|
935
|
+
if (edits) {
|
|
936
|
+
if (name === 'edit_file') call.args = edits[0];
|
|
937
|
+
else if (name === 'multi_edit') {
|
|
938
|
+
call.args = { path: edits[0].path, edits: edits.map(({ old_string, new_string }) => ({ old_string, new_string })) };
|
|
939
|
+
} else call.args = { files: groupEdits(edits) };
|
|
940
|
+
call.repaired = true;
|
|
941
|
+
return call;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
870
945
|
const salvaged = SALVAGEABLE.has(name) ? salvageWrites(text) : null;
|
|
871
946
|
if (salvaged) {
|
|
872
947
|
if (name === 'write_file') call.args = salvaged[0];
|