ucode-agent 1.53.0 → 1.54.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.53.0",
3
+ "version": "1.54.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA, Cohere, Nex AGI, DeepSeek and Qwen models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -363,7 +363,9 @@ const MAX_WORKERS = 3;
363
363
  const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
364
364
 
365
365
  /** Workers build; they do not plan, delegate further, or load skills themselves. */
366
- const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
366
+ // create_app too: a worker builds a part of the app the lead already made,
367
+ // and a second app from a worker would also end the lead's turn (see wrapUp).
368
+ const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill', 'create_app']);
367
369
 
368
370
  const planTool = {
369
371
  name: 'update_plan',
@@ -461,6 +463,28 @@ function pathsOf(call) {
461
463
  return a.path ? [a.path] : [];
462
464
  }
463
465
 
466
+ /**
467
+ * A relative path with the project's own trailing folder names in front of
468
+ * it, taken back off: "scratchpad/run1/taskr" inside .../scratchpad/run1 is
469
+ * "taskr". Left alone when something by that first name is really there.
470
+ * ponytail: a genuinely new folder named like the project's own (run1/ inside
471
+ * run1) lands one level up; tell the two apart if that ever happens for real.
472
+ */
473
+ export function unrepeat(p, cwd) {
474
+ if (!p || path.isAbsolute(p)) return p;
475
+ const parts = p.split(/[\\/]+/);
476
+ if (parts.length < 2 || existsSync(path.join(cwd, parts[0]))) return p;
477
+ const own = path.resolve(cwd).split(/[\\/]+/).filter(Boolean);
478
+ const same = process.platform === 'win32' || process.platform === 'darwin'
479
+ ? (x, y) => x.toLowerCase() === y.toLowerCase()
480
+ : (x, y) => x === y;
481
+ for (let n = Math.min(own.length, parts.length - 1); n > 0; n--) {
482
+ const tail = own.slice(-n);
483
+ if (tail.every((s, i) => same(s, parts[i]))) return parts.slice(n).join('/');
484
+ }
485
+ return p;
486
+ }
487
+
464
488
  /**
465
489
  * Is everything that changed part of a page that has nothing to run?
466
490
  *
@@ -1193,6 +1217,7 @@ export class Agent {
1193
1217
  this.declines = 0;
1194
1218
  this.apps = [];
1195
1219
  this.wrote = new Map();
1220
+ this.rewrite = null; // an Esc mid-cut must not end the next turn early
1196
1221
  forgetReviews(); // a new request: its apps get a fresh design review
1197
1222
  setRequest(input); // create_app checks this before choosing a starter
1198
1223
  const images = await this.attachImages(input);
@@ -1312,10 +1337,13 @@ export class Agent {
1312
1337
  let reply;
1313
1338
  let streaming = false;
1314
1339
  this.early = new Map();
1340
+ // Its own signal, so one reply can be cut off without ending the turn.
1341
+ // any() also sees an Esc that landed before this step began.
1342
+ const cut = new AbortController();
1315
1343
 
1316
1344
  try {
1317
1345
  const opts = {
1318
- signal: this.abort.signal,
1346
+ signal: AbortSignal.any([this.abort.signal, cut.signal]),
1319
1347
  onWait: (text) => this.ui.updateSpinner(text),
1320
1348
  };
1321
1349
  // Only a real terminal has somewhere to stream into.
@@ -1344,6 +1372,9 @@ export class Agent {
1344
1372
  // written now instead of sitting on "working" for ninety seconds.
1345
1373
  const writing = new Writing();
1346
1374
  opts.onToolArgs = ({ index, name, args }) => {
1375
+ // create_app again over the app it already wrote. It would be
1376
+ // refused anyway, after another ninety seconds of streaming it.
1377
+ if (name === 'create_app' && !this.rewrite && (this.rewrite = this.builtApp())) cut.abort();
1347
1378
  const at = writing.seen(index, name, args);
1348
1379
  if (at) this.ui.updateSpinner(at);
1349
1380
  };
@@ -1372,6 +1403,12 @@ export class Agent {
1372
1403
  this.ui.thinkingEnd();
1373
1404
  if (streaming) this.ui.streamEnd();
1374
1405
 
1406
+ if (this.rewrite && !this.abort.signal.aborted) {
1407
+ this.ui.stopSpinner();
1408
+ if (await this.wrapUp(fixRounds++)) continue;
1409
+ return;
1410
+ }
1411
+
1375
1412
  // The model invented a tool and the provider rejected the request
1376
1413
  // outright. Tell it what it did and let it try again.
1377
1414
  if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
@@ -1416,6 +1453,13 @@ export class Agent {
1416
1453
  // the thinking timer has to be closed out here as well.
1417
1454
  this.ui.thinkingEnd();
1418
1455
  this.ui.stopSpinner();
1456
+ // Cut off while writing the app again, but the stream ended quietly
1457
+ // instead of throwing. Its half-written calls must not run.
1458
+ if (this.rewrite && !this.abort.signal.aborted) {
1459
+ if (streaming) this.ui.streamEnd();
1460
+ if (await this.wrapUp(fixRounds++)) continue;
1461
+ return;
1462
+ }
1419
1463
  this.record(reply.usage);
1420
1464
  this.ui.step?.();
1421
1465
  this.stats.steps++;
@@ -1544,6 +1588,10 @@ export class Agent {
1544
1588
 
1545
1589
  const badArgs = await this.runCalls(reply.toolCalls);
1546
1590
  if (this.abort.signal.aborted) return;
1591
+ if (this.rewrite) {
1592
+ if (await this.wrapUp(fixRounds++)) continue;
1593
+ return;
1594
+ }
1547
1595
 
1548
1596
  // Malformed arguments go back to the model, but only so many times.
1549
1597
  if (badArgs) {
@@ -1831,7 +1879,9 @@ export class Agent {
1831
1879
  // to the model, which can act on it, and not to the screen. A refusal the
1832
1880
  // user made, and anything that actually failed, still shows.
1833
1881
  if (err instanceof Declined) this.ui.toolFailed('declined');
1834
- else if (err.kind !== 'bad_args') this.ui.toolFailed(`${err.kind}: ${err.failed}`);
1882
+ // A rewrite of the finished app is not a failure the user needs to see:
1883
+ // the turn is about to end on the app already written.
1884
+ else if (err.kind !== 'bad_args' && !this.rewrite) this.ui.toolFailed(`${err.kind}: ${err.failed}`);
1835
1885
 
1836
1886
  // Declines that keep coming are not decisions, they are a wall.
1837
1887
  //
@@ -1862,6 +1912,54 @@ export class Agent {
1862
1912
  return err.kind === 'bad_args';
1863
1913
  }
1864
1914
 
1915
+ /** The app folder this turn already wrote a real file into (2 KB or more), or null. */
1916
+ builtApp() {
1917
+ for (const app of this.apps ?? []) {
1918
+ for (const [abs, size] of this.wrote ?? []) {
1919
+ const rel = path.relative(app, abs);
1920
+ if (size >= 2000 && !rel.startsWith('..') && !path.isAbsolute(rel)) return app;
1921
+ }
1922
+ }
1923
+ return null;
1924
+ }
1925
+
1926
+ /**
1927
+ * End a turn whose app is already written, without asking the model again.
1928
+ *
1929
+ * DeepSeek writes the whole app in its first create_app, then writes it
1930
+ * again — six more times in one traced build, ninety seconds each, every one
1931
+ * refused. Refusing hands the turn straight back, so on screen the build
1932
+ * kept starting over and never said it was done. The app on disk is the
1933
+ * answer: check it once, hand back real errors, otherwise say so and stop.
1934
+ *
1935
+ * Returns true when there are errors for the model to fix.
1936
+ */
1937
+ async wrapUp(fixRounds) {
1938
+ const app = this.rewrite;
1939
+ this.rewrite = null;
1940
+ const show = path.relative(this.cwd, app).split(path.sep).join('/') || '.';
1941
+ // Everything this turn wrote, by where it really landed: create_app moves
1942
+ // bare names into its folder, so the names the model used can miss.
1943
+ for (const abs of this.wrote.keys()) this.sinceCheck.add(path.relative(this.cwd, abs));
1944
+ const problems = await this.autoCheck();
1945
+ if (problems && fixRounds < MAX_FIX_ROUNDS) {
1946
+ this.push({
1947
+ role: 'user',
1948
+ content:
1949
+ `The app is already written in ${show}; do not call create_app again. ucode checked it ` +
1950
+ `and found errors. Fix them with edit_file, the smallest edits that do it, then finish.\n\n${problems}`,
1951
+ });
1952
+ return true;
1953
+ }
1954
+ const page = existsSync(path.join(app, 'index.html')) ? `${show}/index.html` : show;
1955
+ const text = problems
1956
+ ? `The app is in \`${show}\`, but the check still finds problems:\n\n${problems}`
1957
+ : `Done — the app is in \`${show}\`. Open \`${page}\` in your browser to try it.`;
1958
+ this.push({ role: 'assistant', content: text });
1959
+ this.ui.assistant(text, { closing: true });
1960
+ return false;
1961
+ }
1962
+
1865
1963
  /** The files a whole-file write names, resolved the way the tool will resolve them. */
1866
1964
  writesOf(call) {
1867
1965
  const a = call.args ?? {};
@@ -1903,6 +2001,18 @@ export class Agent {
1903
2001
  });
1904
2002
  }
1905
2003
 
2004
+ // Paths that start by repeating the project's own folder names. Told the
2005
+ // working directory was .../scratchpad/run1, DeepSeek made its app at
2006
+ // scratchpad/run1/taskr — the tree copied inside itself — and every later
2007
+ // read of taskr/ missed, so it wrote the whole app again, six times.
2008
+ const a = call.args;
2009
+ if (a && typeof a === 'object') {
2010
+ const fix = (p) => unrepeat(p, this.cwd);
2011
+ for (const k of ['folder', 'path']) if (typeof a[k] === 'string') a[k] = fix(a[k]);
2012
+ if (Array.isArray(a.paths)) a.paths = a.paths.map(fix);
2013
+ if (Array.isArray(a.files)) for (const f of a.files) if (typeof f?.path === 'string') f.path = fix(f.path);
2014
+ }
2015
+
1906
2016
  // A read of "index.html" right after create_app put it in tasker/. The
1907
2017
  // model named the file that way, so it looks for it that way: DeepSeek got
1908
2018
  // three not-founds and wrote the whole app again. A missing path that
@@ -1946,6 +2056,19 @@ export class Agent {
1946
2056
  this.reads.set(key, seen + 1);
1947
2057
  }
1948
2058
 
2059
+ // A whole app already written this turn, and create_app called again. The
2060
+ // turn ends here (see wrapUp) instead of handing the refusal back to be
2061
+ // retried: DeepSeek wrote the finished app, then wrote it six more times.
2062
+ if (call.name === 'create_app' && (this.rewrite = this.builtApp())) {
2063
+ const show = path.relative(this.cwd, this.rewrite) || '.';
2064
+ throw new ToolFailure({
2065
+ kind: 'already_made',
2066
+ attempted: `creating ${show} again`,
2067
+ failed: `${show} was already written this turn; nothing was changed.`,
2068
+ fix: 'Do not call create_app again. To fix something in it, use edit_file on that part.',
2069
+ });
2070
+ }
2071
+
1949
2072
  // create_app again for the app this turn already made. DeepSeek scaffolded
1950
2073
  // tasker with no files, then called create_app twice more — once with no
1951
2074
  // folder or name at all — and was refused both times while the empty
@@ -578,6 +578,7 @@ const LENIENT = new Set(['create_app.files']);
578
578
  const ALIASES = {
579
579
  path: ['file', 'filename', 'file_path', 'filepath'],
580
580
  content: ['contents', 'text', 'code', 'body'],
581
+ paths: ['files'],
581
582
  };
582
583
 
583
584
  function check(name, args) {
@@ -598,6 +599,10 @@ function check(name, args) {
598
599
  const other = others.find((o) => args[o] != null && !schema.properties[o]);
599
600
  if (other) { args[key] = args[other]; delete args[other]; }
600
601
  }
602
+ // read_files sent as files: [{ path }], the shape batch_write takes.
603
+ if (Array.isArray(args.paths)) {
604
+ args.paths = args.paths.map((p) => (typeof p?.path === 'string' ? p.path : p));
605
+ }
601
606
 
602
607
  for (const key of schema.required ?? []) {
603
608
  const one = key.endsWith('s') ? key.slice(0, -1) : '';