ucode-agent 1.26.1 → 1.27.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/src/core/loop.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  import {
29
29
  tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
30
30
  } from '../tools/index.js';
31
- import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
31
+ import { projectMap, loadMemory, hasCode, remember, MEMORY_FILE } from './context.js';
32
32
  import { autoUpdate } from './updater.js';
33
33
  import { closeBrowser, forgetReviews } from '../tools/browser.js';
34
34
  import {
@@ -146,6 +146,10 @@ const TRACE_FILE = process.env.UCODE_TRACE
146
146
  const THIN_RESULTS = new Set([
147
147
  'read_file', 'read_files', 'grep', 'glob', 'list_dir', 'run_command', 'run_commands',
148
148
  'look_at_app', 'web_search', 'edit_file', 'multi_edit', 'edit_files',
149
+ // create_app hands back the starter's files in full so they are never read.
150
+ // That is worth a round trip once and nothing at all after the next few
151
+ // steps, by which point they are on disk like any other file.
152
+ 'create_app',
149
153
  ]);
150
154
 
151
155
  /** Replace long strings in old tool arguments with a note of their size. */
@@ -396,10 +400,93 @@ function workerPrompt({ cwd, name, memory, skills, map }) {
396
400
  /** The files a writing tool call touches. */
397
401
  function pathsOf(call) {
398
402
  const a = call.args ?? {};
399
- if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
403
+ // create_app writes the app in the same call it scaffolds it, so those
404
+ // files are changes like any other: they are checked, and they are counted.
405
+ if (call.name === 'batch_write' || call.name === 'edit_files' || call.name === 'create_app') {
406
+ return (a.files ?? []).map((f) => f?.path).filter(Boolean);
407
+ }
400
408
  return a.path ? [a.path] : [];
401
409
  }
402
410
 
411
+ /**
412
+ * Is everything that changed part of a page that has nothing to run?
413
+ *
414
+ * The verify nudge exists so an app is not handed over untested. It costs a
415
+ * whole round trip, so it has to be about the thing that was built: a folder
416
+ * with an index.html and no package.json cannot be started or tested, and its
417
+ * files were parsed on the spot as they were written. Running the surrounding
418
+ * repository's `npm test` against it proves nothing and takes half a minute.
419
+ */
420
+ async function pagesOnly(paths, root) {
421
+ const folders = new Map();
422
+ for (const rel of paths) {
423
+ const dir = String(rel).replace(/\\/g, '/').split('/')[0];
424
+ if (!dir || dir.includes('.')) return false; // a file at the root belongs to the project
425
+ if (!folders.has(dir)) {
426
+ folders.set(dir, (await exists(path.join(root, dir, 'index.html'))) &&
427
+ !(await exists(path.join(root, dir, 'package.json'))));
428
+ }
429
+ if (!folders.get(dir)) return false;
430
+ }
431
+ return folders.size > 0;
432
+ }
433
+
434
+ /**
435
+ * Reads the file paths out of a tool call's arguments while they stream.
436
+ *
437
+ * The arguments of a call that writes an app are tens of kilobytes of JSON
438
+ * arriving over a minute or more. Only the paths are wanted, they appear in
439
+ * the order the files are written, and each one is complete long before the
440
+ * content that follows it — so a plain scan for the next `"path": "..."`
441
+ * says exactly where the model has got to.
442
+ *
443
+ * Scanning resumes from where it left off rather than re-reading the whole
444
+ * string on every delta, which is the difference between a few thousand
445
+ * characters of work and a few million over one call.
446
+ */
447
+ const PATH_IN_ARGS = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
448
+
449
+ export class Writing {
450
+ constructor() {
451
+ this.at = new Map(); // call index -> how far it has been scanned
452
+ this.count = new Map(); // call index -> files named so far
453
+ this.last = null;
454
+ }
455
+
456
+ /** The line to show, or null when nothing new has been named. */
457
+ seen(index, name, args) {
458
+ if (!WRITES.has(name)) return null;
459
+ const from = this.at.get(index) ?? 0;
460
+ if (args.length <= from) return null;
461
+
462
+ // Overlap by the length of the longest thing a path match can straddle,
463
+ // so a path split across two deltas is not missed.
464
+ const window = args.slice(Math.max(0, from - 512));
465
+ this.at.set(index, args.length);
466
+
467
+ let found = null;
468
+ let extra = 0;
469
+ PATH_IN_ARGS.lastIndex = 0;
470
+ for (const match of window.matchAll(PATH_IN_ARGS)) {
471
+ if (match[1] === this.last) continue;
472
+ found = match[1];
473
+ extra++;
474
+ }
475
+ if (!found) return null;
476
+
477
+ const total = (this.count.get(index) ?? 0) + extra;
478
+ this.count.set(index, total);
479
+ this.last = found;
480
+ return `writing ${shortenPath(found, 42)}${total > 1 ? ` · ${total} files so far` : ''}`;
481
+ }
482
+ }
483
+
484
+ /** Tools that only mean anything once there is code in the folder. */
485
+ const LOOKUP_TOOLS = new Set(['find_symbol', 'outline', 'rename_symbol', 'type_of']);
486
+
487
+ /** A request that plainly wants the internet keeps web_search in a new project. */
488
+ const WANTS_WEB = /\b(?:search|google|web|online|internet|latest|current|news|docs|documentation|api reference|look up|find out about)\b/i;
489
+
403
490
  const exists = (p) => access(p).then(() => true, () => false);
404
491
 
405
492
  /** Skills reach the model as one extra tool, so bodies load only when wanted. */
@@ -460,8 +547,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
460
547
  'something is incomplete, say which part and why.',
461
548
  '',
462
549
  'BE FAST. Every tool call is a round trip, and round trips are nearly all of the',
463
- 'time a build takes. So: write a whole app in ONE batch_write rather than a',
464
- 'write_file per file. Read every file you need in ONE read_files. Never read a',
550
+ 'time a build takes. So: a new app is ONE create_app call with its files passed',
551
+ 'in — the starter and the whole app together, not a scaffold and then a write.',
552
+ 'Its starter files come back inside that result, so there is nothing to read',
553
+ 'afterwards. Everything else goes in ONE batch_write rather than a write_file',
554
+ 'per file. Read every file you need in ONE read_files. Never read a',
465
555
  'file you just wrote, and never read one back after edit_file — the result',
466
556
  'already contains it. Do not re-check work the checks have already reported on.',
467
557
  'Fast is not sloppy: it is the same work with the waiting taken out.',
@@ -482,37 +572,45 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
482
572
  ' - One line when you move between the big pieces of work: "The layout is done,',
483
573
  ' now the animations."',
484
574
  ' - One line at the end saying what it does and how to try it.',
485
- 'That closing line is ONE OR TWO SENTENCES. Never a checklist, never a feature',
486
- 'list, never ticks or bullets walking through the request item by item. "Tide is',
487
- 'built - open tide/index.html, or serve the folder and visit it." Anything longer',
488
- 'is a status report nobody asked for, and it is the last thing on screen, so it',
489
- 'is what the whole session looks like.',
575
+ 'That closing line is ONE OR TWO SENTENCES, and SIX LINES IS THE HARD CEILING.',
576
+ 'Never a checklist, never a feature list, never ticks or bullets walking through',
577
+ 'the request item by item, never a list of the files you touched - the user',
578
+ 'watched them go past. "Tide is built - open tide/index.html, or serve the folder',
579
+ 'and visit it." Anything longer is a status report nobody asked for, and it is',
580
+ 'the last thing on screen, so it is what the whole session looks like. ucode cuts',
581
+ 'the closing message to eight lines before it is drawn, so anything past that is',
582
+ 'written for nobody: say the one thing that matters and stop.',
490
583
  'That is all. A line before every tool call is not narration, it is noise: the',
491
584
  'steps already show on screen, and repeating them in words buries the few',
492
585
  'sentences worth reading.',
493
586
  '',
494
- 'YOUR FIRST WORDS, BEFORE ANY TOOL CALL, EVERY TIME. Nothing else appears on',
495
- 'screen while you work, so if you say nothing the user watches a blank page.',
496
- '',
497
- 'Open with one plain sentence naming the thing and the shape of it:',
498
- ' I will build Tide for you - a tasks app in a single HTML file, dark with a',
499
- ' teal accent.',
500
- 'Then one line whenever you start a new piece of the work: Now the filter row.',
501
- 'Then one line at the end saying it is done.',
502
- '',
503
- 'Plain words only. No methodology, no "component-based approach", no',
504
- '"structured design", no listing the qualities the work will have. Never',
505
- '"I need to", never "Let me", never a plan of which files you will touch, never',
506
- 'the request repeated back. Say it the way you would to someone watching over',
507
- 'your shoulder, who can already see the screen.',
587
+ 'YOUR FIRST WORDS, BEFORE ANY TOOL CALL, EVERY TIME. Nothing else appears on',
588
+ 'screen while you work, so if you say nothing the user watches a blank page.',
589
+ '',
590
+ 'Open with one plain sentence naming the thing and the shape of it:',
591
+ ' I will build Tide for you - a tasks app in a single HTML file, dark with a',
592
+ ' teal accent.',
593
+ 'Then one line whenever you start a new piece of the work: Now the filter row.',
594
+ 'Then one line at the end saying it is done.',
595
+ '',
596
+ 'Plain words only. No methodology, no "component-based approach", no',
597
+ '"structured design", no listing the qualities the work will have. Never',
598
+ '"I need to", never "Let me", never a plan of which files you will touch, never',
599
+ 'the request repeated back. Say it the way you would to someone watching over',
600
+ 'your shoulder, who can already see the screen.',
508
601
  '',
509
602
 
510
603
  '',
511
604
  'Before you guess at an API, ask: type_of gives the exact signature from the',
512
605
  'TypeScript this project has installed, and find_symbol says where something is declared without',
513
606
  'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
514
- 'find-and-replace that matches too much is the most common broken edit. Reach for',
515
- 'add_block before writing a table, an empty state or a dashboard by hand.',
607
+ 'find-and-replace that matches too much is the most common broken edit.',
608
+ '',
609
+ 'CALL add_block BEFORE WRITING A LIST, A FILTER ROW, A STORE, A DIALOG, A TOAST, A',
610
+ 'THEME TOGGLE, A TABLE OR AN EMPTY STATE BY HAND. It has all of those, written for',
611
+ 'whichever starter this app uses, keyboard and empty states included, and each one',
612
+ 'is a hundred lines you do not have to type. Typing is the slowest part of a build:',
613
+ 'a page assembled from blocks is finished minutes before the same page typed out.',
516
614
  '',
517
615
  ...(mode === 'plan' ? [
518
616
  'You are in PLAN MODE. Reading, searching and research are available; every tool',
@@ -654,6 +752,7 @@ export class Agent {
654
752
  this.session = newSession(cwd, model());
655
753
  this.working = [];
656
754
  this.loaded = new Set();
755
+ this.short = new Set(); // loaded as a digest, so load_skill can still fetch the whole thing
657
756
  this.abort = null;
658
757
  this.busy = false;
659
758
  this.check = null;
@@ -927,9 +1026,14 @@ export class Agent {
927
1026
  autoLoad(input) {
928
1027
  for (const skill of autoLoadFor(this.skills, input)) {
929
1028
  if (this.loaded.has(skill.name)) continue;
1029
+ // The short form where the skill has one. Everything loaded here is
1030
+ // re-read by the provider on every step of the build, so the depth is
1031
+ // left behind load_skill and the rules come now.
1032
+ const message = skillMessage(skill, { automatic: true, short: true });
930
1033
  this.loaded.add(skill.name);
931
- this.push(skillMessage(skill, { automatic: true }));
932
- this.ui.note(`${skill.name} skill loaded for this`);
1034
+ if (message.short) this.short.add(skill.name);
1035
+ this.push(message);
1036
+ this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
933
1037
  }
934
1038
  }
935
1039
 
@@ -950,6 +1054,10 @@ export class Agent {
950
1054
  projectMap(this.cwd).catch(() => ''),
951
1055
  loadMemory(this.cwd).catch(() => ''),
952
1056
  ]);
1057
+ // Nothing to look up in an empty folder, so those tools do not go out with
1058
+ // the request. Decided per turn: the moment there is code, they are back.
1059
+ this.fresh = !hasCode(this.map);
1060
+ this.wantsWeb = WANTS_WEB.test(input);
953
1061
  await this.persist();
954
1062
 
955
1063
  // A busy model was swapped for a fallback earlier; after a few minutes the
@@ -1003,16 +1111,28 @@ export class Agent {
1003
1111
  }
1004
1112
  }
1005
1113
 
1006
- /** The tools the model may see, given the mode. */
1114
+ /**
1115
+ * The tools the model may see, given the mode and what is in the folder.
1116
+ *
1117
+ * A new project has nothing to look up: no symbol to find, nothing to
1118
+ * rename, no types to ask about, and — unless the request says otherwise —
1119
+ * nothing to search the web for. Their schemas are eight hundred tokens the
1120
+ * provider re-reads on every step of the build, and they are also five more
1121
+ * wrong turns available to a model deciding what to do next.
1122
+ */
1007
1123
  toolsNow() {
1008
1124
  const all = [...tools, loadSkillTool, planTool, delegateTool];
1009
- if (this.ui.mode !== 'plan') return all;
1010
- return all.filter((t) => !WRITES.has(t.name));
1125
+ const live = this.fresh
1126
+ ? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
1127
+ : all;
1128
+ if (this.ui.mode !== 'plan') return live;
1129
+ return live.filter((t) => !WRITES.has(t.name));
1011
1130
  }
1012
1131
 
1013
1132
  /** Model, tools, model, until it answers with prose. */
1014
1133
  async run() {
1015
1134
  const available = this.toolsNow();
1135
+ this.offering = new Set(available.map((t) => t.name));
1016
1136
  let argRetries = 0;
1017
1137
  let continuations = 0;
1018
1138
  let askedToVerify = false;
@@ -1060,6 +1180,16 @@ export class Agent {
1060
1180
  this.early.set(call.id, this.execute(call));
1061
1181
  }
1062
1182
  };
1183
+ // A whole app is one tool call whose arguments take a minute or two
1184
+ // to arrive, and nothing can be started until they have. What can
1185
+ // happen is saying where it has got to: the files are named in the
1186
+ // order they are written, so the spinner names the one being
1187
+ // written now instead of sitting on "working" for ninety seconds.
1188
+ const writing = new Writing();
1189
+ opts.onToolArgs = ({ index, name, args }) => {
1190
+ const at = writing.seen(index, name, args);
1191
+ if (at) this.ui.updateSpinner(at);
1192
+ };
1063
1193
  }
1064
1194
 
1065
1195
  var asked = Date.now();
@@ -1144,9 +1274,12 @@ export class Agent {
1144
1274
  // Text that turns out to be narration ahead of a tool call folds into a
1145
1275
  // status line instead — that is where the live commentary comes from.
1146
1276
  const narrating = reply.toolCalls.length > 0;
1147
- if (streaming) this.ui.streamEnd({ asNarration: narrating });
1277
+ // No tool calls left and something was actually done: this is the closing
1278
+ // message, the last thing on screen, and it gets cut to eight lines.
1279
+ const closing = !narrating && (this.touched.size > 0 || this.ranSomething);
1280
+ if (streaming) this.ui.streamEnd({ asNarration: narrating, closing });
1148
1281
  else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
1149
- else if (reply.text) this.ui.assistant(reply.text);
1282
+ else if (reply.text) this.ui.assistant(reply.text, { closing });
1150
1283
 
1151
1284
  if (reply.toolCalls.length === 0) {
1152
1285
  // The answer stopped at the provider's output cap rather than at the
@@ -1184,8 +1317,14 @@ export class Agent {
1184
1317
  }
1185
1318
  }
1186
1319
 
1187
- // It changed code and never ran anything. Send it back once.
1188
- if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
1320
+ // It changed code and never ran anything. Send it back once — but
1321
+ // only if this project's check would actually exercise what changed.
1322
+ // A page and a stylesheet in a repo that happens to have `npm test`
1323
+ // was costing a whole round trip to run someone else's unit tests
1324
+ // against a file they have never heard of, when the page's own script
1325
+ // was parsed locally a moment ago.
1326
+ if (this.touched.size && !this.ranSomething && this.check && !askedToVerify &&
1327
+ !(await pagesOnly(this.touched, this.cwd))) {
1189
1328
  askedToVerify = true;
1190
1329
  if (reply.text) this.push({ role: 'assistant', content: reply.text });
1191
1330
  this.push({
@@ -1221,7 +1360,12 @@ export class Agent {
1221
1360
  continue;
1222
1361
  }
1223
1362
 
1224
- this.push({ role: 'assistant', content: reply.text });
1363
+ // An assistant message with no content and no tool calls is not a turn,
1364
+ // it is a hole. Providers reject the whole conversation as malformed
1365
+ // once one is in it — which showed up as HTTP 400 on every request
1366
+ // after the model went quiet, with the conversation at 0% full and
1367
+ // "usually an oversized conversation" printed underneath it.
1368
+ if (reply.text?.trim()) this.push({ role: 'assistant', content: reply.text });
1225
1369
  if (!reply.text?.trim()) {
1226
1370
  // Silence after being asked to speak is not a finished turn. Saying
1227
1371
  // "Done" here is the worst thing available: the user believes it,
@@ -1491,6 +1635,20 @@ export class Agent {
1491
1635
  });
1492
1636
  }
1493
1637
 
1638
+ // A tool that was not offered does not run, whatever the model calls it.
1639
+ // The list it is sent is the list that exists: plan mode withholds every
1640
+ // tool that writes, and a new project is not sent the ones that look code
1641
+ // up — and a model naming one from memory was, until this check, executing
1642
+ // it. A withheld tool has to be refused, not just left out of the menu.
1643
+ if (!this.offering?.has(call.name)) {
1644
+ throw new ToolFailure({
1645
+ kind: 'no_such_tool',
1646
+ attempted: `calling ${call.name}`,
1647
+ failed: `${call.name} is not available${this.ui.mode === 'plan' ? ' in plan mode' : ' in this project'}.`,
1648
+ fix: `Use one of the tools you were given: ${[...this.offering ?? []].join(', ')}.`,
1649
+ });
1650
+ }
1651
+
1494
1652
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1495
1653
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1496
1654
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
@@ -1604,9 +1762,11 @@ export class Agent {
1604
1762
  async runWorker(task, index) {
1605
1763
  const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
1606
1764
  const touched = new Set();
1765
+ // A worker gets what the lead has, digest included: its prompt is re-read
1766
+ // on every step it takes, the same as the lead's.
1607
1767
  const skills = this.skills
1608
1768
  .filter((s) => this.loaded.has(s.name))
1609
- .map((s) => `--- ${s.name} ---\n${s.body}`)
1769
+ .map((s) => `--- ${s.name} ---\n${this.short.has(s.name) && s.digest ? s.digest : s.body}`)
1610
1770
  .join('\n\n');
1611
1771
  const messages = [
1612
1772
  { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
@@ -1840,11 +2000,14 @@ export class Agent {
1840
2000
  });
1841
2001
  }
1842
2002
 
1843
- if (this.loaded.has(skill.name)) {
2003
+ // Loaded in full already: nothing to do. Loaded as a digest: this is the
2004
+ // model asking for the depth, which is exactly what the digest points at.
2005
+ if (this.loaded.has(skill.name) && !this.short.has(skill.name)) {
1844
2006
  return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
1845
2007
  }
1846
2008
 
1847
2009
  this.loaded.add(skill.name);
2010
+ this.short.delete(skill.name);
1848
2011
  this.push(skillMessage(skill));
1849
2012
  return {
1850
2013
  content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
@@ -1882,7 +2045,11 @@ export class Agent {
1882
2045
  { role: 'user', content: forSummary(older) },
1883
2046
  ],
1884
2047
  [],
1885
- { signal: this.abort?.signal, temperature: 0 }
2048
+ // Always the quick model, whatever the user picked for the work
2049
+ // itself. This is a mechanical restatement in the middle of a
2050
+ // build the user is waiting on; a reasoning model would think
2051
+ // about it for half a minute and produce the same paragraph.
2052
+ { signal: this.abort?.signal, temperature: 0, model: DEFAULT_MODEL },
1886
2053
  );
1887
2054
  return reply.text;
1888
2055
  },
@@ -2208,6 +2375,7 @@ ${out.content}` });
2208
2375
  this.session = loaded;
2209
2376
  this.working = [...loaded.messages];
2210
2377
  this.loaded = new Set(loaded.messages.filter((m) => m.skill).map((m) => m.skill));
2378
+ this.short = new Set(loaded.messages.filter((m) => m.skill && m.short).map((m) => m.skill));
2211
2379
  if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
2212
2380
  return true;
2213
2381
  } catch (err) {
@@ -2258,6 +2426,7 @@ ${out.content}` });
2258
2426
  this.session = newSession(this.cwd, model());
2259
2427
  this.working = [];
2260
2428
  this.loaded = new Set();
2429
+ this.short = new Set(); // loaded as a digest, so load_skill can still fetch the whole thing
2261
2430
  this.showHeader();
2262
2431
  }
2263
2432
 
@@ -2278,8 +2447,9 @@ ${out.content}` });
2278
2447
  this.ui.blank();
2279
2448
  for (const s of this.skills) {
2280
2449
  const live = this.loaded.has(s.name);
2450
+ const how = live && this.short.has(s.name) ? dim(' · short form') : '';
2281
2451
  const auto = s.triggers.length ? dim(' · loads itself') : '';
2282
- this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
2452
+ this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${how}${auto}`);
2283
2453
  this.ui.write(` ${dim(s.description)}`);
2284
2454
  }
2285
2455
  this.ui.blank();