ucode-agent 1.39.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/core/loop.js +69 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.39.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);
@@ -2036,7 +2097,7 @@ export class Agent {
2036
2097
  async lookOnceThisTurn(root, changed) {
2037
2098
  if (this.lookedThisTurn) return null;
2038
2099
 
2039
- const pages = [...changed].filter((f) => /\.(?:html?|tsx|jsx)$/i.test(f));
2100
+ const pages = [...changed].filter((f) => PAGE.test(f));
2040
2101
  if (!pages.length) return null;
2041
2102
  this.lookedThisTurn = true;
2042
2103
 
@@ -2056,7 +2117,12 @@ export class Agent {
2056
2117
  const html = pages.find((f) => /\.html?$/i.test(f));
2057
2118
  if (!html) return null;
2058
2119
  return await withStaticServer(path.dirname(path.resolve(root, html)), look);
2059
- } 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]}`);
2060
2126
  return null;
2061
2127
  }
2062
2128
  }