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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.38.0",
3
+ "version": "1.40.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
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() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
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
- if (!problems.length) {
2012
- const seen = await this.lookOnceThisTurn(root, changed);
2013
- if (seen) problems.push(seen);
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) => /\.(?:html?|tsx|jsx)$/i.test(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
  }
@@ -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];